feat: subscription update invoice mode
This commit is contained in:
@@ -173,7 +173,7 @@ export const getStripeSubItems = async ({
|
||||
continue;
|
||||
}
|
||||
|
||||
const lineItem = stripeItem;
|
||||
const { lineItem } = stripeItem;
|
||||
|
||||
subItems.push(lineItem);
|
||||
}
|
||||
|
||||
28
server/src/external/stripe/webhookHandlers/handleInvoicePaid/handleDeferredAutumnBillingPlan.ts
vendored
Normal file
28
server/src/external/stripe/webhookHandlers/handleInvoicePaid/handleDeferredAutumnBillingPlan.ts
vendored
Normal 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 });
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -47,7 +47,7 @@ server/src/internal/billing/v2/
|
||||
│ │ │ │
|
||||
│ │ │ ├── invoice/ # Invoice operations
|
||||
│ │ │ │ ├── lineItemsToStripeLines.ts
|
||||
│ │ │ │ ├── createAndPayInvoice.ts
|
||||
│ │ │ │ ├── createInvoiceForBilling.ts
|
||||
│ │ │ │ ├── payStripeInvoice.ts
|
||||
│ │ │ │ └── index.ts
|
||||
│ │ │ │
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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",
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"],
|
||||
});
|
||||
|
||||
|
||||
@@ -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"],
|
||||
});
|
||||
|
||||
|
||||
@@ -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"],
|
||||
});
|
||||
|
||||
|
||||
@@ -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", {
|
||||
|
||||
Reference in New Issue
Block a user