feat: subscription update invoice mode

This commit is contained in:
Charlie Lamb
2026-01-01 16:32:19 +00:00
parent 1c25064f1f
commit c820940f7a
19 changed files with 696 additions and 171 deletions

View File

@@ -173,7 +173,7 @@ export const getStripeSubItems = async ({
continue;
}
const lineItem = stripeItem;
const { lineItem } = stripeItem;
subItems.push(lineItem);
}

View File

@@ -0,0 +1,28 @@
import type { Metadata } from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import type { DeferredAutumnBillingPlanData } from "@/internal/billing/v2/billingPlan";
import { executeAutumnBillingPlan } from "@/internal/billing/v2/execute/executeAutumnBillingPlan";
import { MetadataService } from "@/internal/metadata/MetadataService";
export const handleDeferredAutumnBillingPlan = async ({
ctx,
metadata,
}: {
ctx: AutumnContext;
metadata: Metadata;
}) => {
const { logger, db } = ctx;
const data = metadata.data as DeferredAutumnBillingPlanData;
if (data.orgId !== ctx.org.id || data.env !== ctx.env) {
logger.warn("Deferred billing plan org/env mismatch, skipping");
return;
}
await executeAutumnBillingPlan({
ctx,
autumnBillingPlan: data.autumnBillingPlan,
});
await MetadataService.delete({ db, id: metadata.id });
};

View File

@@ -3,6 +3,7 @@ import type Stripe from "stripe";
import type { AutumnContext } from "../../../../honoUtils/HonoEnv";
import type { AttachParams } from "../../../../internal/customers/cusProducts/AttachParams";
import { MetadataService } from "../../../../internal/metadata/MetadataService";
import { handleDeferredAutumnBillingPlan } from "./handleDeferredAutumnBillingPlan";
import { handleInvoiceActionRequiredCompleted } from "./handleInvoiceActionRequiredCompleted";
import { handleInvoiceCheckoutPaid } from "./handleInvoiceCheckoutPaid";
@@ -24,6 +25,13 @@ export const handleInvoicePaidMetadata = async ({
if (!metadata) return;
// Handle deferred billing plan (v2 flow)
if (metadata.type === MetadataType.DeferredAutumnBillingPlan) {
await handleDeferredAutumnBillingPlan({ ctx, metadata });
return;
}
// Legacy v1 flows below
const data = metadata.data as unknown as AttachParams;
const reqMatch =
data.org?.id === ctx.org.id && data.customer?.env === ctx.env;

View File

@@ -47,7 +47,7 @@ server/src/internal/billing/v2/
│ │ │ │
│ │ │ ├── invoice/ # Invoice operations
│ │ │ │ ├── lineItemsToStripeLines.ts
│ │ │ │ ├── createAndPayInvoice.ts
│ │ │ │ ├── createInvoiceForBilling.ts
│ │ │ │ ├── payStripeInvoice.ts
│ │ │ │ └── index.ts
│ │ │ │

View File

@@ -1,4 +1,5 @@
import {
type AppEnv,
EntitlementSchema,
FreeTrialSchema,
LineItemSchema,
@@ -59,8 +60,16 @@ export const StripeSubscriptionScheduleActionSchema = z.discriminatedUnion(
],
);
export const InvoiceModeSchema = z.object({
finalizeInvoice: z.boolean().default(false),
enableProductImmediately: z.boolean().default(true),
});
export type InvoiceMode = z.infer<typeof InvoiceModeSchema>;
export const StripeInvoiceActionSchema = z.object({
addLineParams: z.custom<import("stripe").Stripe.InvoiceAddLinesParams>(),
invoiceMode: InvoiceModeSchema.optional(),
});
export type StripeSubscriptionScheduleAction = z.infer<
@@ -103,3 +112,13 @@ export const BillingPlanSchema = z.object({
export type BillingPlan = z.infer<typeof BillingPlanSchema>;
export type AutumnBillingPlan = z.infer<typeof AutumnBillingPlanSchema>;
export type StripeBillingPlan = z.infer<typeof StripeBillingPlanSchema>;
export type StripeInvoiceMetadata = {
autumn_metadata_id: string;
};
export type DeferredAutumnBillingPlanData = {
orgId: string;
env: AppEnv;
autumnBillingPlan: AutumnBillingPlan;
};

View File

@@ -1,20 +1,25 @@
import type Stripe from "stripe";
import { MetadataType } from "@autumn/shared";
import { isStripeSubscriptionCanceled } from "@/external/stripe/subscriptions/utils/classifyStripeSubscriptionUtils";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import type { BillingContext } from "@/internal/billing/v2/billingContext";
import type { BillingPlan } from "@/internal/billing/v2/billingPlan";
import type {
BillingPlan,
StripeInvoiceMetadata,
} from "@/internal/billing/v2/billingPlan";
import { addStripeSubscriptionIdToBillingPlan } from "@/internal/billing/v2/execute/addStripeSubscriptionIdToBillingPlan";
import { addStripeSubscriptionScheduleIdToBillingPlan } from "@/internal/billing/v2/execute/addStripeSubscriptionScheduleIdToBillingPlan";
import { executeAutumnBillingPlan } from "@/internal/billing/v2/execute/executeAutumnBillingPlan";
import { executeStripeInvoiceAction } from "@/internal/billing/v2/execute/executeStripeInvoiceAction";
import { handleStripeSubscriptionUncancel } from "@/internal/billing/v2/execute/executeStripeSubscriptionActions/handleStripeSubscriptionUncancel";
import { removeStripeSubscriptionIdFromBillingPlan } from "@/internal/billing/v2/execute/removeStripeSubscriptionIdFromBillingPlan";
import { executeStripeSubscriptionAction } from "@/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionAction";
import { executeStripeSubscriptionScheduleAction } from "@/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionScheduleAction";
import { createInvoiceForBilling } from "@/internal/billing/v2/providers/stripe/utils/invoices/createInvoiceForBilling";
import { logBillingPlan } from "@/internal/billing/v2/utils/logBillingPlan";
import { upsertInvoiceFromBilling } from "@/internal/billing/v2/utils/upsertFromStripe/upsertInvoiceFromBilling";
import { upsertSubscriptionFromBilling } from "@/internal/billing/v2/utils/upsertFromStripe/upsertSubscriptionFromBilling";
import { addSubIdToCache } from "@/internal/customers/cusCache/subCacheUtils";
import { MetadataService } from "@/internal/metadata/MetadataService";
import { generateId } from "@/utils/genUtils";
export const executeBillingPlan = async ({
ctx,
@@ -35,17 +40,40 @@ export const executeBillingPlan = async ({
await handleStripeSubscriptionUncancel({ ctx, billingContext, billingPlan });
const enableProductImmediately =
stripeInvoiceAction?.invoiceMode?.enableProductImmediately !== false;
if (stripeInvoiceAction) {
const result = await executeStripeInvoiceAction({
let invoiceMetadata: StripeInvoiceMetadata | undefined;
if (!enableProductImmediately) {
const metadataId = generateId("meta");
await MetadataService.insert({
db: ctx.db,
data: {
id: metadataId,
type: MetadataType.DeferredAutumnBillingPlan,
data: {
orgId: ctx.org.id,
env: ctx.env,
autumnBillingPlan: billingPlan.autumn,
},
},
});
invoiceMetadata = { autumn_metadata_id: metadataId };
}
const { invoice } = await createInvoiceForBilling({
ctx,
billingContext,
stripeInvoiceAction,
invoiceMetadata,
});
if (result.invoice) {
if (invoice) {
await upsertInvoiceFromBilling({
ctx,
stripeInvoice: result.invoice,
stripeInvoice: invoice,
fullProducts: billingContext.fullProducts,
fullCustomer: billingContext.fullCustomer,
});
@@ -113,18 +141,13 @@ export const executeBillingPlan = async ({
}
}
console.log(
"Inserting new customer product:",
billingPlan.autumn.insertCustomerProducts.map((cp) => ({
name: cp.product.name,
id: cp.id,
status: cp.status,
})),
);
await executeAutumnBillingPlan({
ctx,
autumnBillingPlan: billingPlan.autumn,
});
// if not enabling product immediately, it will be handled in webhook
if (enableProductImmediately) {
await executeAutumnBillingPlan({
ctx,
autumnBillingPlan: billingPlan.autumn,
});
}
return billingPlan;
return { billingPlan };
};

View File

@@ -1,31 +0,0 @@
import type { BillingContext } from "@/internal/billing/v2/billingContext";
import { createStripeCli } from "../../../../external/connect/createStripeCli";
import type { AutumnContext } from "../../../../honoUtils/HonoEnv";
import type { StripeInvoiceAction } from "../billingPlan";
import { createAndPayInvoice } from "../providers/stripe/utils/invoices/createAndPayInvoice";
export const executeStripeInvoiceAction = async ({
ctx,
billingContext,
stripeInvoiceAction,
}: {
ctx: AutumnContext;
billingContext: BillingContext;
stripeInvoiceAction: StripeInvoiceAction;
}) => {
const { org, env } = ctx;
const { addLineParams } = stripeInvoiceAction;
const stripeCli = createStripeCli({ org, env });
// 1. Create and pay invoice
const result = await createAndPayInvoice({
stripeCli,
stripeCusId: billingContext.stripeCustomer?.id,
stripeLineItems: addLineParams.lines,
paymentMethod: billingContext.paymentMethod,
onPaymentFailure: "return_url",
});
return result;
};

View File

@@ -1,5 +1,5 @@
import type { LineItem } from "@autumn/shared";
import type { StripeInvoiceAction } from "../../../billingPlan";
import type { InvoiceMode, StripeInvoiceAction } from "../../../billingPlan";
import { lineItemsToStripeLines } from "../utils/invoiceLines/lineItemsToStripeLines";
/**
@@ -8,8 +8,10 @@ import { lineItemsToStripeLines } from "../utils/invoiceLines/lineItemsToStripeL
*/
export const buildStripeInvoiceAction = ({
autumnLineItems,
invoiceMode,
}: {
autumnLineItems: LineItem[];
invoiceMode?: InvoiceMode;
}): StripeInvoiceAction | undefined => {
if (autumnLineItems.length === 0) {
return undefined;
@@ -17,5 +19,8 @@ export const buildStripeInvoiceAction = ({
const lines = lineItemsToStripeLines({ lineItems: autumnLineItems });
return { addLineParams: { lines } };
return {
addLineParams: { lines },
invoiceMode,
};
};

View File

@@ -0,0 +1,41 @@
import { type AppEnv, MetadataType } from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { MetadataService } from "@/internal/metadata/MetadataService";
import { generateId } from "@/utils/genUtils";
import type { AutumnBillingPlan } from "../../../../billingPlan";
export type DeferredAutumnBillingPlanData = {
version: 2;
orgId: string;
env: AppEnv;
autumnBillingPlan: AutumnBillingPlan;
};
export const storeSubscriptionUpdatePlan = async ({
ctx,
autumnBillingPlan,
}: {
ctx: AutumnContext;
autumnBillingPlan: AutumnBillingPlan;
}): Promise<string> => {
const id = generateId("meta");
const { db, org, env } = ctx;
const data: DeferredAutumnBillingPlanData = {
version: 2,
orgId: org.id,
env,
autumnBillingPlan,
};
await MetadataService.insert({
db,
data: {
id,
type: MetadataType.DeferredAutumnBillingPlan,
data,
},
});
return id;
};

View File

@@ -1,82 +0,0 @@
import {
type PayInvoiceResult,
type PaymentFailureMode,
payStripeInvoice,
} from "@server/internal/billing/v2/providers/stripe/utils/invoices/payStripeInvoice";
import {
addStripeInvoiceLines,
createStripeInvoice,
finalizeStripeInvoice,
} from "@server/internal/billing/v2/providers/stripe/utils/invoices/stripeInvoiceOps";
import type Stripe from "stripe";
// ============================================
// Types
// ============================================
export type CreateAndPayInvoiceParams = {
stripeCli: Stripe;
stripeCusId: string;
stripeSubId?: string;
stripeLineItems: Stripe.InvoiceAddLinesParams.Line[];
paymentMethod?: Stripe.PaymentMethod | null;
discounts?: { coupon: string }[];
description?: string;
onPaymentFailure?: PaymentFailureMode;
};
export type CreateAndPayInvoiceResult = PayInvoiceResult;
// ============================================
// Create and Pay Invoice
// ============================================
/**
* Full invoice workflow: create → add lines → finalize → pay
*/
export const createAndPayInvoice = async ({
stripeCli,
stripeCusId,
stripeSubId,
stripeLineItems,
paymentMethod,
description,
onPaymentFailure = "return_url",
}: CreateAndPayInvoiceParams): Promise<CreateAndPayInvoiceResult> => {
// 2. Create draft invoice
const invoice = await createStripeInvoice({
stripeCli,
stripeCusId,
stripeSubId,
description,
});
// 3. Add lines to invoice
await addStripeInvoiceLines({
stripeCli,
invoiceId: invoice.id,
lines: stripeLineItems,
});
// 4. Finalize invoice
const finalizedInvoice = await finalizeStripeInvoice({
stripeCli,
invoiceId: invoice.id,
});
// 5. If already paid (e.g. total <= 0), return early
if (finalizedInvoice.status === "paid") {
return {
paid: true,
invoice: finalizedInvoice,
};
}
// 6. Pay invoice
return payStripeInvoice({
stripeCli,
invoiceId: finalizedInvoice.id,
paymentMethod,
onFailure: onPaymentFailure,
});
};

View File

@@ -0,0 +1,69 @@
import type { BillingContext } from "@server/internal/billing/v2/billingContext";
import type {
StripeInvoiceAction,
StripeInvoiceMetadata,
} from "@server/internal/billing/v2/billingPlan";
import {
type PayInvoiceResult,
payStripeInvoice,
} from "@server/internal/billing/v2/providers/stripe/utils/invoices/payStripeInvoice";
import {
addStripeInvoiceLines,
createStripeInvoice,
finalizeStripeInvoice,
} from "@server/internal/billing/v2/providers/stripe/utils/invoices/stripeInvoiceOps";
import { createStripeCli } from "@/external/connect/createStripeCli";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
export const createInvoiceForBilling = async ({
ctx,
billingContext,
stripeInvoiceAction,
invoiceMetadata,
}: {
ctx: AutumnContext;
billingContext: BillingContext;
stripeInvoiceAction: StripeInvoiceAction;
invoiceMetadata?: StripeInvoiceMetadata;
}): Promise<PayInvoiceResult> => {
const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env });
const { addLineParams, invoiceMode } = stripeInvoiceAction;
const shouldFinalizeInvoice = invoiceMode?.finalizeInvoice ?? false;
const shouldPayImmediately = invoiceMode?.enableProductImmediately ?? true;
const draftInvoice = await createStripeInvoice({
stripeCli,
stripeCusId: billingContext.stripeCustomer.id,
metadata: invoiceMetadata,
});
await addStripeInvoiceLines({
stripeCli,
invoiceId: draftInvoice.id,
lines: addLineParams.lines,
});
if (!shouldFinalizeInvoice) {
return { paid: false, invoice: draftInvoice };
}
const finalizedInvoice = await finalizeStripeInvoice({
stripeCli,
invoiceId: draftInvoice.id,
});
if (finalizedInvoice.status === "paid") {
return { paid: true, invoice: finalizedInvoice };
}
if (!shouldPayImmediately) {
return { paid: false, invoice: finalizedInvoice };
}
return payStripeInvoice({
stripeCli,
invoiceId: finalizedInvoice.id,
paymentMethod: billingContext.paymentMethod,
onFailure: "return_url",
});
};

View File

@@ -13,6 +13,7 @@ export type CreateInvoiceParams = {
collectionMethod?: "charge_automatically" | "send_invoice";
daysUntilDue?: number;
description?: string;
metadata?: Stripe.MetadataParam;
};
export const createStripeInvoice = async ({
@@ -23,6 +24,7 @@ export const createStripeInvoice = async ({
collectionMethod = "charge_automatically",
daysUntilDue,
description,
metadata,
}: CreateInvoiceParams): Promise<Stripe.Invoice> => {
const invoice = await stripeCli.invoices.create({
customer: stripeCusId,
@@ -30,6 +32,7 @@ export const createStripeInvoice = async ({
...(stripeSubId ? { subscription: stripeSubId } : {}),
...(currency ? { currency } : {}),
...(description ? { description } : {}),
...(metadata ? { metadata } : {}),
collection_method: collectionMethod,
days_until_due:
collectionMethod === "send_invoice" ? (daysUntilDue ?? 30) : undefined,

View File

@@ -26,12 +26,13 @@ export const evaluateSubscriptionUpdatePlan = ({
updatedCustomerProducts,
});
const shouldFinalizeInvoice = params.finalize_invoice !== false;
const stripeInvoiceAction = shouldFinalizeInvoice
? buildStripeInvoiceAction({
autumnLineItems: autumnBillingPlan.autumnLineItems,
})
: undefined;
const stripeInvoiceAction = buildStripeInvoiceAction({
autumnLineItems: autumnBillingPlan.autumnLineItems,
invoiceMode: {
finalizeInvoice: params.finalize_invoice === true,
enableProductImmediately: params.enable_product_immediately !== false,
},
});
return {
subscriptionAction: stripeSubscriptionAction,

View File

@@ -0,0 +1,460 @@
import { beforeAll, describe, expect, test } from "bun:test";
import { type ApiCustomer, ApiVersion } from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features.js";
import { completeInvoiceCheckout } from "@tests/utils/stripeUtils/completeInvoiceCheckout.js";
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
import chalk from "chalk";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { CusService } from "@/internal/customers/CusService.js";
import { timeout } from "@/utils/genUtils.js";
import { constructPrepaidItem } from "@/utils/scriptUtils/constructItem.js";
import { constructRawProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0";
const billingUnits = 12;
const pricePerUnit = 8;
describe(`${chalk.yellowBright("subscription-update: invoice mode - default behavior (draft invoice, immediate entitlements)")}`, () => {
const customerId = "sub-update-invoice-default";
const autumnV1 = new AutumnInt({ version: ApiVersion.V1_2 });
const prepaidProduct = constructRawProduct({
id: "prepaid_messages",
items: [
constructPrepaidItem({
featureId: TestFeature.Messages,
billingUnits,
price: pricePerUnit,
}),
],
});
beforeAll(async () => {
await initCustomerV3({
ctx,
customerId,
withTestClock: true,
attachPm: "success",
});
await initProductsV0({
ctx,
products: [prepaidProduct],
prefix: customerId,
});
await autumnV1.attach({
customer_id: customerId,
product_id: prepaidProduct.id,
options: [
{
feature_id: TestFeature.Messages,
quantity: 10 * billingUnits,
},
],
});
});
test("should default to draft invoice with immediate entitlements when only invoice: true is passed", async () => {
const beforeUpdate = await CusService.getFull({
db: ctx.db,
idOrInternalId: customerId,
orgId: ctx.org.id,
env: ctx.env,
});
const customerProduct = beforeUpdate.customer_products.find(
(cp) => cp.product.id === prepaidProduct.id,
);
const beforeEntitlement = customerProduct?.customer_entitlements.find(
(ent) => ent.entitlement.feature_id === TestFeature.Messages,
);
const beforeBalance = beforeEntitlement?.balance || 0;
await autumnV1.subscriptionUpdate({
customer_id: customerId,
product_id: prepaidProduct.id,
options: [
{
feature_id: TestFeature.Messages,
quantity: 15 * billingUnits,
},
],
invoice: true,
});
const afterUpdate = await CusService.getFull({
db: ctx.db,
idOrInternalId: customerId,
orgId: ctx.org.id,
env: ctx.env,
});
const afterCustomerProduct = afterUpdate.customer_products.find(
(cp) => cp.product.id === prepaidProduct.id,
);
const afterEntitlement = afterCustomerProduct?.customer_entitlements.find(
(ent) => ent.entitlement.feature_id === TestFeature.Messages,
);
const afterBalance = afterEntitlement?.balance || 0;
expect(afterBalance).toBe(beforeBalance + 60);
const customer = await autumnV1.customers.get<ApiCustomer>(customerId);
const balance = customer.balances?.[TestFeature.Messages];
expect(balance?.purchased_balance).toBe(180);
const draftInvoice = customer.invoices?.find(
(inv) => inv.status === "draft",
);
expect(draftInvoice).toBeDefined();
});
});
describe(`${chalk.yellowBright("subscription-update: invoice mode - draft invoice with immediate entitlements (explicit)")}`, () => {
const customerId = "sub-update-invoice-draft";
const autumnV1 = new AutumnInt({ version: ApiVersion.V1_2 });
const prepaidProduct = constructRawProduct({
id: "prepaid_messages",
items: [
constructPrepaidItem({
featureId: TestFeature.Messages,
billingUnits,
price: pricePerUnit,
}),
],
});
beforeAll(async () => {
await initCustomerV3({
ctx,
customerId,
withTestClock: true,
attachPm: "success",
});
await initProductsV0({
ctx,
products: [prepaidProduct],
prefix: customerId,
});
await autumnV1.attach({
customer_id: customerId,
product_id: prepaidProduct.id,
options: [
{
feature_id: TestFeature.Messages,
quantity: 10 * billingUnits,
},
],
});
});
test("should create draft invoice and update entitlements immediately", async () => {
const beforeUpdate = await CusService.getFull({
db: ctx.db,
idOrInternalId: customerId,
orgId: ctx.org.id,
env: ctx.env,
});
const customerProduct = beforeUpdate.customer_products.find(
(cp) => cp.product.id === prepaidProduct.id,
);
const beforeEntitlement = customerProduct?.customer_entitlements.find(
(ent) => ent.entitlement.feature_id === TestFeature.Messages,
);
const beforeBalance = beforeEntitlement?.balance || 0;
await autumnV1.subscriptionUpdate({
customer_id: customerId,
product_id: prepaidProduct.id,
options: [
{
feature_id: TestFeature.Messages,
quantity: 15 * billingUnits, // +5 units
},
],
invoice: true,
finalize_invoice: false,
enable_product_immediately: true,
});
// Entitlements should be updated immediately
const afterUpdate = await CusService.getFull({
db: ctx.db,
idOrInternalId: customerId,
orgId: ctx.org.id,
env: ctx.env,
});
const afterCustomerProduct = afterUpdate.customer_products.find(
(cp) => cp.product.id === prepaidProduct.id,
);
const afterEntitlement = afterCustomerProduct?.customer_entitlements.find(
(ent) => ent.entitlement.feature_id === TestFeature.Messages,
);
const afterBalance = afterEntitlement?.balance || 0;
// +5 units × 12 billing_units = +60 messages
expect(afterBalance).toBe(beforeBalance + 60);
// Verify via API that balance is updated and invoice is draft
const customer = await autumnV1.customers.get<ApiCustomer>(customerId);
const balance = customer.balances?.[TestFeature.Messages];
expect(balance?.purchased_balance).toBe(180); // 15 units × 12 = 180
const draftInvoice = customer.invoices?.find(
(inv) => inv.status === "draft",
);
expect(draftInvoice).toBeDefined();
});
});
describe(`${chalk.yellowBright("subscription-update: invoice mode - finalized invoice with immediate entitlements")}`, () => {
const customerId = "sub-update-invoice-finalized";
const autumnV1 = new AutumnInt({ version: ApiVersion.V1_2 });
const prepaidProduct = constructRawProduct({
id: "prepaid_messages",
items: [
constructPrepaidItem({
featureId: TestFeature.Messages,
billingUnits,
price: pricePerUnit,
}),
],
});
beforeAll(async () => {
await initCustomerV3({
ctx,
customerId,
withTestClock: true,
attachPm: "success",
});
await initProductsV0({
ctx,
products: [prepaidProduct],
prefix: customerId,
});
await autumnV1.attach({
customer_id: customerId,
product_id: prepaidProduct.id,
options: [
{
feature_id: TestFeature.Messages,
quantity: 10 * billingUnits,
},
],
});
});
test("should finalize invoice immediately and update entitlements", async () => {
const beforeUpdate = await CusService.getFull({
db: ctx.db,
idOrInternalId: customerId,
orgId: ctx.org.id,
env: ctx.env,
});
const customerProduct = beforeUpdate.customer_products.find(
(cp) => cp.product.id === prepaidProduct.id,
);
const beforeEntitlement = customerProduct?.customer_entitlements.find(
(ent) => ent.entitlement.feature_id === TestFeature.Messages,
);
const beforeBalance = beforeEntitlement?.balance || 0;
await autumnV1.subscriptionUpdate({
customer_id: customerId,
product_id: prepaidProduct.id,
options: [
{
feature_id: TestFeature.Messages,
quantity: 20 * billingUnits, // +10 units
},
],
invoice: true,
finalize_invoice: true,
enable_product_immediately: true,
});
// Entitlements should be updated immediately
const afterUpdate = await CusService.getFull({
db: ctx.db,
idOrInternalId: customerId,
orgId: ctx.org.id,
env: ctx.env,
});
const afterCustomerProduct = afterUpdate.customer_products.find(
(cp) => cp.product.id === prepaidProduct.id,
);
const afterEntitlement = afterCustomerProduct?.customer_entitlements.find(
(ent) => ent.entitlement.feature_id === TestFeature.Messages,
);
const afterBalance = afterEntitlement?.balance || 0;
// +10 units × 12 billing_units = +120 messages
expect(afterBalance).toBe(beforeBalance + 120);
// Verify via API that balance is updated and invoice is paid
const customer = await autumnV1.customers.get<ApiCustomer>(customerId);
const balance = customer.balances?.[TestFeature.Messages];
expect(balance?.purchased_balance).toBe(240); // 20 units × 12 = 240
const paidInvoice = customer.invoices?.find(
(inv) => inv.status === "paid",
);
expect(paidInvoice).toBeDefined();
});
});
describe(`${chalk.yellowBright("subscription-update: invoice mode - entitlements after payment")}`, () => {
const customerId = "sub-update-invoice-payment-required";
const autumnV1 = new AutumnInt({ version: ApiVersion.V1_2 });
const prepaidProduct = constructRawProduct({
id: "prepaid_messages",
items: [
constructPrepaidItem({
featureId: TestFeature.Messages,
billingUnits,
price: pricePerUnit,
}),
],
});
beforeAll(async () => {
await initCustomerV3({
ctx,
customerId,
withTestClock: true,
attachPm: "success",
});
await initProductsV0({
ctx,
products: [prepaidProduct],
prefix: customerId,
});
await autumnV1.attach({
customer_id: customerId,
product_id: prepaidProduct.id,
options: [
{
feature_id: TestFeature.Messages,
quantity: 10 * billingUnits,
},
],
});
});
test("should not update entitlements until payment is received via checkout", async () => {
const beforeUpdate = await CusService.getFull({
db: ctx.db,
idOrInternalId: customerId,
orgId: ctx.org.id,
env: ctx.env,
});
const customerProduct = beforeUpdate.customer_products.find(
(cp) => cp.product.id === prepaidProduct.id,
);
const beforeEntitlement = customerProduct?.customer_entitlements.find(
(ent) => ent.entitlement.feature_id === TestFeature.Messages,
);
const beforeBalance = beforeEntitlement?.balance || 0;
await autumnV1.subscriptionUpdate({
customer_id: customerId,
product_id: prepaidProduct.id,
options: [
{
feature_id: TestFeature.Messages,
quantity: 25 * billingUnits, // +15 units
},
],
invoice: true,
finalize_invoice: true,
enable_product_immediately: false,
});
// Entitlements should NOT be updated yet (waiting for payment)
const afterUpdate = await CusService.getFull({
db: ctx.db,
idOrInternalId: customerId,
orgId: ctx.org.id,
env: ctx.env,
});
const afterCustomerProduct = afterUpdate.customer_products.find(
(cp) => cp.product.id === prepaidProduct.id,
);
const afterEntitlement = afterCustomerProduct?.customer_entitlements.find(
(ent) => ent.entitlement.feature_id === TestFeature.Messages,
);
const afterBalance = afterEntitlement?.balance || 0;
// Balance should remain unchanged until payment
expect(afterBalance).toBe(beforeBalance);
// Verify via API that balance is NOT updated and invoice is open
const customer = await autumnV1.customers.get<ApiCustomer>(customerId);
const balance = customer.balances?.[TestFeature.Messages];
expect(balance?.purchased_balance).toBe(120); // Still 10 units × 12 = 120
const openInvoice = customer.invoices?.find(
(inv) => inv.status === "open",
);
expect(openInvoice).toBeDefined();
expect(openInvoice?.hosted_invoice_url).toBeDefined();
// Complete payment via checkout using Puppeteer
await completeInvoiceCheckout({
url: openInvoice!.hosted_invoice_url!,
});
// Wait for webhook processing
await timeout(10000);
// Entitlements should now be updated after payment
const afterPayment = await CusService.getFull({
db: ctx.db,
idOrInternalId: customerId,
orgId: ctx.org.id,
env: ctx.env,
});
const paidCustomerProduct = afterPayment.customer_products.find(
(cp) => cp.product.id === prepaidProduct.id,
);
const paidEntitlement = paidCustomerProduct?.customer_entitlements.find(
(ent) => ent.entitlement.feature_id === TestFeature.Messages,
);
const paidBalance = paidEntitlement?.balance || 0;
// +15 units × 12 billing_units = +180 messages
expect(paidBalance).toBe(beforeBalance + 180);
// Verify via API that balance is now updated and invoice is paid
const customerAfterPayment =
await autumnV1.customers.get<ApiCustomer>(customerId);
const balanceAfterPayment =
customerAfterPayment.balances?.[TestFeature.Messages];
expect(balanceAfterPayment?.purchased_balance).toBe(300); // 25 units × 12 = 300
// All invoices should now be paid
const unpaidInvoices = customerAfterPayment.invoices?.filter(
(inv) => inv.status !== "paid",
);
expect(unpaidInvoices?.length ?? 0).toBe(0);
});
});

View File

@@ -89,30 +89,4 @@ describe(`${chalk.yellowBright("subscription-update: invoice generation")}`, ()
expect(latestInvoice?.total).toBeGreaterThan(0);
});
test("should not create invoice when finalize_invoice is false", async () => {
const beforeUpdate = await autumnV1.customers.get<ApiCustomer>(customerId);
const invoiceCountBefore = beforeUpdate.invoices?.length || 0;
await autumnV1.subscriptionUpdate({
customer_id: customerId,
product_id: prepaidProduct.id,
options: [
{
feature_id: TestFeature.Messages,
quantity: 25 * billingUnits,
},
],
finalize_invoice: false,
});
const afterUpdate = await autumnV1.customers.get<ApiCustomer>(customerId);
const invoiceCountAfter = afterUpdate.invoices?.length || 0;
// Should not have created a finalized invoice
expect(invoiceCountAfter).toBe(invoiceCountBefore);
// But balance should still be updated
const balance = afterUpdate.balances?.[TestFeature.Messages];
expect(balance?.purchased_balance).toBe(25 * billingUnits);
});
});

View File

@@ -27,7 +27,9 @@ export const completeCheckoutForm = async (
) => {
const browser = await puppeteer.launch({
headless: false,
executablePath: "/Applications/Chromium.app/Contents/MacOS/Chromium",
executablePath:
process.env.TESTS_CHROMIUM_PATH ??
"/Applications/Chromium.app/Contents/MacOS/Chromium",
args: ["--no-sandbox", "--disable-setuid-sandbox"],
});

View File

@@ -27,7 +27,9 @@ export const completeInvoiceCheckout = async ({
// }
browser = await puppeteer.launch({
headless: false,
executablePath: "/Applications/Chromium.app/Contents/MacOS/Chromium",
executablePath:
process.env.TESTS_CHROMIUM_PATH ??
"/Applications/Chromium.app/Contents/MacOS/Chromium",
args: ["--no-sandbox", "--disable-setuid-sandbox"],
});

View File

@@ -27,7 +27,9 @@ export const completeInvoiceConfirmation = async ({
// }
browser = await puppeteer.launch({
headless: false,
executablePath: "/Applications/Chromium.app/Contents/MacOS/Chromium",
executablePath:
process.env.TESTS_CHROMIUM_PATH ??
"/Applications/Chromium.app/Contents/MacOS/Chromium",
args: ["--no-sandbox", "--disable-setuid-sandbox"],
});

View File

@@ -6,6 +6,7 @@ export enum MetadataType {
InvoiceActionRequired = "invoice_action_required",
InvoiceCheckout = "invoice_checkout",
CheckoutSessionCompleted = "checkout_session_completed",
DeferredAutumnBillingPlan = "deferred_autumn_billing_plan",
}
export const metadata = pgTable("metadata", {