Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

27 changes: 27 additions & 0 deletions packages/openapi/v2.1/contracts/balancesContract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
API_BALANCE_V1_EXAMPLE,
CheckResponseV3Schema,
CreateBalanceParamsV0Schema,
DeleteBalanceParamsV0Schema,
ExtCheckParamsSchema,
TrackParamsSchema,
TrackResponseV3Schema,
Expand Down Expand Up @@ -145,3 +146,29 @@ export const balancesUpdateContract = oc
}),
)
.output(SuccessResponseSchema);

export const balancesDeleteContract = oc
.route({
method: "POST",
path: "/v1/balances.delete",
operationId: "deleteBalance",
tags: ["balances"],
description:
"Delete a balance for a customer feature. Can only delete a balance that is not attached to a price (eg. you cannot delete messages that have an overage price).",
spec: (spec) => ({
...spec,
"x-speakeasy-name-override": "delete",
}),
})
.input(
DeleteBalanceParamsV0Schema.meta({
title: "DeleteBalanceParams",
examples: [
{
customer_id: "cus_123",
feature_id: "api_calls",
},
],
}),
)
.output(SuccessResponseSchema);
2 changes: 2 additions & 0 deletions packages/openapi/v2.1/contracts/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { oc } from "@orpc/contract";
import {
balancesCheckContract,
balancesCreateContract,
balancesDeleteContract,
balancesTrackContract,
balancesUpdateContract,
} from "./balancesContract.js";
Expand Down Expand Up @@ -83,6 +84,7 @@ export const v2_1ContractRouter = oc.router({
// Balances
balancesCreate: balancesCreateContract,
balancesUpdate: balancesUpdateContract,
balancesDelete: balancesDeleteContract,
balancesCheck: balancesCheckContract,
balancesTrack: balancesTrackContract,

Expand Down
5 changes: 5 additions & 0 deletions server/src/external/autumn/autumnCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
type CreateRewardProgram,
type CustomerBillingControlsInput,
CustomerExpand,
type DeleteBalanceParamsV0,
EntityExpand,
ErrCode,
type LegacyVersion,
Expand Down Expand Up @@ -844,6 +845,10 @@ export class AutumnInt {
const data = await this.post(`/balances/update`, params);
return data;
},
delete: async (params: DeleteBalanceParamsV0) => {
const data = await this.post(`/balances.delete`, params);
return data;
},
};

subscriptions = {
Expand Down
4 changes: 3 additions & 1 deletion server/src/internal/balances/balancesRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Hono } from "hono";
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
import { handleCheck } from "../api/check/handleCheck.js";
import { handleCreateBalance } from "./handlers/handleCreateBalance.js";
import { handleDeleteBalance } from "./handlers/handleDeleteBalance.js";
import { handleListBalances } from "./handlers/handleListBalances.js";
import { handleTrack } from "./handlers/handleTrack.js";
import { handleUpdateBalance } from "./handlers/handleUpdateBalance.js";
Expand All @@ -24,9 +25,10 @@ balancesRouter.post("/check", ...handleCheck);

// Legacy
balancesRouter.post("/usage", ...handleSetUsage);

export const balancesRpcRouter = new Hono<HonoEnv>();
balancesRpcRouter.post("/balances.create", ...handleCreateBalance);
balancesRpcRouter.post("/balances.update", ...handleUpdateBalance);
balancesRpcRouter.post("/balances.delete", ...handleDeleteBalance);

balancesRpcRouter.post("/balances.track", ...handleTrack);
balancesRpcRouter.post("/balances.check", ...handleCheck);
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import {
apiPlanItem,
type CreateBalanceParamsV0,
type CustomerEntitlement,
createBalanceParamsV0ToPlanItemV0,
type Entitlement,
enrichEntitlementWithFeature,
type Feature,
type FullCustomer,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { initCusEntitlement } from "@/internal/customers/add-product/initCusEnt";
import { initNextResetAt } from "@/internal/customers/cusProducts/insertCusProduct/initCusEnt/initNextResetAt";
import { initCustomerEntitlement } from "@/internal/billing/v2/utils/initFullCustomerProduct/initCustomerEntitlement/initCustomerEntitlement";
import { toFeature } from "@/internal/products/product-items/productItemUtils/itemToPriceAndEnt";

export const prepareNewBalanceForInsertion = async ({
Expand All @@ -21,7 +22,10 @@ export const prepareNewBalanceForInsertion = async ({
feature: Feature;
fullCustomer: FullCustomer;
params: CreateBalanceParamsV0;
}) => {
}): Promise<{
newEntitlement: Entitlement;
newCustomerEntitlement: CustomerEntitlement;
}> => {
const planItem = createBalanceParamsV0ToPlanItemV0({
ctx,
params,
Expand Down Expand Up @@ -50,22 +54,16 @@ export const prepareNewBalanceForInsertion = async ({
feature,
});

const newCustomerEntitlement = initCusEntitlement({
const newCustomerEntitlement = initCustomerEntitlement({
initContext: {
fullCustomer,
featureQuantities: [],
resetCycleAnchor: Date.now(),
freeTrial: null,
now: Date.now(),
},
entitlement: newEntitlementWithFeature,
customer: fullCustomer,
cusProductId: null,
freeTrial: null,
nextResetAt:
initNextResetAt({
entitlement: newEntitlementWithFeature,
now: Date.now(),
}) ?? Date.now(),
entities: entity ? [entity] : [],
carryExistingUsages: false,
replaceables: [],
now: Date.now(),
productOptions: undefined,
expires_at: params.expires_at ?? null,
});

// If entity is provided, assign balance to entity instead of customer-level
Expand All @@ -80,6 +78,10 @@ export const prepareNewBalanceForInsertion = async ({
newCustomerEntitlement.next_reset_at = null;
}

if (params.balance_id) {
newCustomerEntitlement.external_id = params.balance_id;
}

return {
newEntitlement,
newCustomerEntitlement,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import {
type CreateBalanceParamsV0,
ErrCode,
type Feature,
FeatureType,
type FullCustomer,
RecaseError,
ValidateCreateBalanceParamsSchema,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService";
import { getApiCustomerBase } from "@/internal/customers/cusUtils/apiCusUtils/getApiCustomerBase";

export const validateCreateBalanceParams = async ({
Expand Down Expand Up @@ -38,6 +40,38 @@ export const validateCreateBalanceParams = async ({
message: `Cannot give an entity a balance of its own feature type`,
});
}

if (params.balance_id) {
await validateBalanceIdUnique({
ctx,
balanceId: params.balance_id,
internalCustomerId: fullCustomer.internal_id,
});
}
};

const validateBalanceIdUnique = async ({
ctx,
balanceId,
internalCustomerId,
}: {
ctx: AutumnContext;
balanceId: string;
internalCustomerId: string;
}) => {
const existing = await CusEntService.get({
ctx,
externalId: balanceId,
internalCustomerId,
});

if (existing) {
throw new RecaseError({
message: `balance_id '${balanceId}' is already in use for this customer`,
code: ErrCode.InvalidRequest,
statusCode: 409,
});
}
};

const validateBooleanEntitlementConflict = async ({
Expand Down
75 changes: 75 additions & 0 deletions server/src/internal/balances/deleteBalance/deleteBalance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import {
type DeleteBalanceParamsV0,
fullCustomerToCustomerEntitlements,
isPaidCustomerEntitlement,
RecaseError,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { CusService } from "@/internal/customers/CusService";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService";
import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService";
import { deleteCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/deleteCachedFullCustomer";
import { buildCustomerEntitlementFilters } from "../utils/buildCustomerEntitlementFilters";

export const deleteBalance = async ({
ctx,
params,
}: {
ctx: AutumnContext;
params: DeleteBalanceParamsV0;
}) => {
const { customer_id, entity_id, feature_id } = params;

// 1. Get full customer
const fullCustomer = await CusService.getFull({
ctx,
idOrInternalId: customer_id,
entityId: entity_id,
withEntities: true,
withSubs: true,
});

// 2. Get balance
const customerEntitlements = fullCustomerToCustomerEntitlements({
fullCustomer,
featureId: feature_id,
entity: fullCustomer.entity,
customerEntitlementFilters: buildCustomerEntitlementFilters({ params }),
});

if (customerEntitlements.length === 0) {
throw new RecaseError({
message: `Balance not found for feature ${feature_id} and customer ${customer_id}`,
});
}

for (const cusEnt of customerEntitlements) {
if (isPaidCustomerEntitlement(cusEnt)) {
throw new RecaseError({
message: `Cannot delete paid balance for feature ${feature_id} and customer ${customer_id}`,
});
}
}

for (const cusEnt of customerEntitlements) {
await CusEntService.delete({
db: ctx.db,
id: cusEnt.id,
});

if (cusEnt.customer_product_id) {
await CusProductService.update({
ctx,
cusProductId: cusEnt.customer_product_id,
updates: {
is_custom: true,
},
});
}
}

await deleteCachedFullCustomer({
ctx,
customerId: fullCustomer.id ?? "",
});
};
18 changes: 18 additions & 0 deletions server/src/internal/balances/handlers/handleDeleteBalance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { DeleteBalanceParamsV0Schema } from "@autumn/shared";
import { createRoute } from "@/honoMiddlewares/routeHandler";
import { deleteBalance } from "../deleteBalance/deleteBalance";

export const handleDeleteBalance = createRoute({
body: DeleteBalanceParamsV0Schema,
handler: async (c) => {
const ctx = c.get("ctx");
const params = c.req.valid("json");

await deleteBalance({
ctx,
params,
});

return c.json({ success: true });
},
});
Loading
Loading