This commit is contained in:
John Yeo
2026-01-14 15:15:18 +00:00
parent 3ba48cfddb
commit b4e5edb2d4
73 changed files with 2908 additions and 822 deletions

View File

@@ -48,6 +48,8 @@ in the test logs. Use your common sense
- This codebase uses Bun for all of its operations in `/server`, `/vite` and `/shared`. It uses Bun for the package management, Bun for the workspace management and Bun for the runtime. Prefer Bun over PNPM. If you ever want to trace a package dependency tree, run `bun why <package name>` which will tell you why a certain package was installed and by who.
- Prefer Guard clauses "if(!admin) return;" over explicity "if(admin) do X;" Early returns are better
- For single-line if statements (especially guard clauses), omit curly braces to keep code neat: `if (!isValid) throw error;` instead of wrapping in braces.
- Do not run "npx tsc" - run "tsc" instead as it is installed globally.
- **ALWAYS use `.meta()` for zod-openapi schema registration**, NOT `.openapi()`. Example: `ApiProductSchema.meta({ id: "Product" })`

View File

@@ -0,0 +1,29 @@
import type Stripe from "stripe";
import type { Logger } from "@/external/logtail/logtailUtils";
/**
* Logs core information from a Stripe invoice for debugging.
*/
export const logStripeInvoice = ({
logger,
stripeInvoice,
prefix,
}: {
logger: Logger;
stripeInvoice: Stripe.Invoice;
prefix?: string;
}) => {
const tag = prefix ? `[${prefix}]` : "";
logger.info(`${tag} Stripe Invoice`, {
id: stripeInvoice.id,
status: stripeInvoice.status,
total: stripeInvoice.total,
currency: stripeInvoice.currency,
hosted_invoice_url: stripeInvoice.hosted_invoice_url,
lines: stripeInvoice.lines.data.map((line) => ({
description: line.description,
amount: line.amount,
})),
});
};

View File

@@ -3,7 +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 { executeDeferredBillingPlan } from "@/internal/billing/v2/execute/executeDeferredBillingPlan";
import { handleInvoiceActionRequiredCompleted } from "./handleInvoiceActionRequiredCompleted";
import { handleInvoiceCheckoutPaid } from "./handleInvoiceCheckoutPaid";
@@ -26,11 +26,8 @@ export const handleInvoicePaidMetadata = async ({
if (!metadata) return;
// Handle deferred billing plan (v2 flow)
if (
metadata.type === MetadataType.InvoiceCheckoutV2 ||
metadata.type === MetadataType.InvoiceActionRequiredV2
) {
await handleDeferredAutumnBillingPlan({ ctx, metadata });
if (metadata.type === MetadataType.DeferredInvoice) {
await executeDeferredBillingPlan({ ctx, metadata });
return;
}

View File

@@ -0,0 +1,40 @@
import type { StripeWebhookContext } from "../../webhookMiddlewares/stripeWebhookContext.js";
import { setupStripeInvoicePaidContext } from "./setupStripeInvoicePaidContext.js";
export const handleStripeInvoicePaid = async ({
ctx,
}: {
ctx: StripeWebhookContext;
}) => {
const invoicePaidContext = await setupStripeInvoicePaidContext({ ctx });
if (!invoicePaidContext) {
ctx.logger.warn("[invoice.paid] invoicePaidContext not found, skipping");
return;
}
ctx.logger.debug(
`Received invoice.paid event for invoice ${invoicePaidContext.stripeInvoice.id}`,
);
// 1. Handle metadata-based payments (deferred billing, checkout, etc.)
// await handleInvoicePaidMetadata({ ctx, invoicePaidContext });
// 2. Handle discount/coupon rollover
// await handleInvoiceDiscounts({ ctx, invoicePaidContext });
// 3. Handle based on invoice type (subscription vs one-off)
if (invoicePaidContext.stripeSubscriptionId) {
// 3a. Convert to charge_automatically if needed
// await convertToChargeAutomatically({ ctx, invoicePaidContext });
// 3b. Create/update Autumn invoice
// await upsertAutumnInvoice({ ctx, invoicePaidContext });
// 3c. Trigger checkout rewards
// await triggerCheckoutRewards({ ctx, invoicePaidContext });
} else {
// 3. Handle one-off invoice
// await handleOneOffInvoicePaid({ ctx, invoicePaidContext });
}
};

View File

@@ -0,0 +1,34 @@
import type Stripe from "stripe";
import {
getFullStripeInvoice,
invoiceToSubId,
} from "@/external/stripe/stripeInvoiceUtils.js";
import type { StripeWebhookContext } from "../../webhookMiddlewares/stripeWebhookContext.js";
export interface StripeInvoicePaidContext {
stripeInvoice: Stripe.Invoice;
stripeSubscriptionId: string | null;
}
export const setupStripeInvoicePaidContext = async ({
ctx,
}: {
ctx: StripeWebhookContext;
}): Promise<StripeInvoicePaidContext | null> => {
const { stripeEvent, stripeCli } = ctx;
const invoiceData = stripeEvent.data.object as Stripe.Invoice;
const stripeInvoice = await getFullStripeInvoice({
stripeCli,
stripeId: invoiceData.id!,
expand: ["payments"],
});
const stripeSubscriptionId = invoiceToSubId({ invoice: stripeInvoice }) ?? null;
return {
stripeInvoice,
stripeSubscriptionId,
};
};

View File

@@ -14,7 +14,7 @@ import { getStripeSubscriptionLock } from "@/external/stripe/subscriptions/utils
import { addProductsUpdatedWebhookTask } from "@/internal/analytics/handlers/handleProductsUpdated.js";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
import { notNullish } from "@/utils/genUtils.js";
import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js";
import type { AutumnContext } from "../../../../../honoUtils/HonoEnv.js";
export const handleSchedulePhaseCompleted = async ({
ctx,

View File

@@ -13,11 +13,11 @@ import { CusService } from "@/internal/customers/CusService.js";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
import { ProductService } from "@/internal/products/ProductService.js";
import { formatUnixToDateTime, nullish } from "@/utils/genUtils.js";
import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js";
import type { AutumnContext } from "../../../../../honoUtils/HonoEnv.js";
import {
getLatestPeriodEnd,
subToPeriodStartEnd,
} from "../../stripeSubUtils/convertSubUtils.js";
} from "../../../stripeSubUtils/convertSubUtils.js";
export const isSubCanceled = ({
previousAttributes,

View File

@@ -5,7 +5,7 @@ import {
} from "@autumn/shared";
import type Stripe from "stripe";
import { addProductsUpdatedWebhookTask } from "@/internal/analytics/handlers/handleProductsUpdated.js";
import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js";
import type { AutumnContext } from "../../../../../honoUtils/HonoEnv.js";
export const isSubPastDue = ({
previousAttributes,

View File

@@ -5,7 +5,7 @@ import { addProductsUpdatedWebhookTask } from "@/internal/analytics/handlers/han
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
import { getExistingCusProducts } from "@/internal/customers/cusProducts/cusProductUtils/getExistingCusProducts.js";
import { notNullish, nullish } from "@/utils/genUtils.js";
import type { AutumnContext } from "../../../../honoUtils/HonoEnv";
import type { AutumnContext } from "../../../../../honoUtils/HonoEnv";
const isSubRenewed = ({
previousAttributes,

View File

@@ -15,11 +15,11 @@ import {
RELEVANT_STATUSES,
} from "@/internal/customers/cusProducts/CusProductService.js";
import { SubService } from "@/internal/subscriptions/SubService.js";
import type { AutumnContext } from "../../../honoUtils/HonoEnv.js";
import { handleSchedulePhaseCompleted } from "./handleSubUpdated/handleSchedulePhaseCompleted.js";
import { handleSubCanceled } from "./handleSubUpdated/handleSubCanceled.js";
import { handleSubPastDue } from "./handleSubUpdated/handleSubPastDue.js";
import { handleSubRenewed } from "./handleSubUpdated/handleSubRenewed.js";
import type { AutumnContext } from "../../../../../honoUtils/HonoEnv.js";
import { handleSchedulePhaseCompleted } from "./handleSchedulePhaseCompleted.js";
import { handleSubCanceled } from "./handleSubCanceled.js";
import { handleSubPastDue } from "./handleSubPastDue.js";
import { handleSubRenewed } from "./handleSubRenewed.js";
export const handleSubscriptionUpdated = async ({
ctx,

View File

@@ -1,11 +1,7 @@
import {
cusProductToLineItems,
type FullCusProduct,
type LineItem,
secondsToMs,
} from "@autumn/shared";
import type { FullCusProduct, LineItem } from "@autumn/shared";
import type { BillingContext } from "@/internal/billing/v2/billingContext";
import type { AutumnContext } from "../../../../../honoUtils/HonoEnv";
import { customerProductToLineItems } from "../../utils/lineItems/customerProductToLineItems";
import { logBuildAutumnLineItems } from "./logBuildAutumnLineItems";
export const buildAutumnLineItems = ({
@@ -19,11 +15,7 @@ export const buildAutumnLineItems = ({
deletedCustomerProduct?: FullCusProduct;
billingContext: BillingContext;
}) => {
// billingCycleAnchor = billingCycleAnchor ?? now;
const { billingCycleAnchorMs, currentEpochMs, stripeSubscription } =
billingContext;
const { org, logger } = ctx;
const { logger } = ctx;
// For now, update subscription doesn't charge for existing usage.
const arrearLineItems: LineItem[] = [];
@@ -35,28 +27,22 @@ export const buildAutumnLineItems = ({
// })
// Get line items for ongoing cus product
const originalBillingCycleAnchorMs = stripeSubscription?.billing_cycle_anchor
? secondsToMs(stripeSubscription.billing_cycle_anchor)
: "now";
const deletedLineItems = deletedCustomerProduct
? cusProductToLineItems({
cusProduct: deletedCustomerProduct,
nowMs: currentEpochMs,
billingCycleAnchorMs: originalBillingCycleAnchorMs,
? customerProductToLineItems({
ctx,
customerProduct: deletedCustomerProduct,
billingContext,
direction: "refund",
org,
logger,
priceFilters: { excludeOneOffPrices: true },
})
: [];
const newLineItems = newCustomerProducts.flatMap((newCustomerProduct) =>
cusProductToLineItems({
cusProduct: newCustomerProduct,
nowMs: currentEpochMs,
billingCycleAnchorMs,
customerProductToLineItems({
ctx,
customerProduct: newCustomerProduct,
billingContext,
direction: "charge",
org,
logger,
}),
);

View File

@@ -1,16 +1,11 @@
import {
cp,
cusProductToLineItems,
type FullCusProduct,
type LineItem,
secondsToMs,
} from "@autumn/shared";
import { cp, type FullCusProduct, type LineItem } from "@autumn/shared";
import chalk from "chalk";
import type { Logger } from "@/external/logtail/logtailUtils";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import type { UpdateSubscriptionBillingContext } from "@/internal/billing/v2/billingContext";
import type { AutumnBillingPlan } from "@/internal/billing/v2/types/autumnBillingPlan";
import { getTrialStateTransition } from "@/internal/billing/v2/utils/billingContext/getTrialStateTransition";
import { customerProductToLineItems } from "@/internal/billing/v2/utils/lineItems/customerProductToLineItems";
const formatLineItem = (item: LineItem) => ({
description: item.description,
@@ -130,25 +125,15 @@ export const buildSharedSubscriptionTrialLineItems = ({
if (siblingCustomerProducts.length === 0) return [];
const originalBillingCycleAnchorMs = stripeSubscription.billing_cycle_anchor
? secondsToMs(stripeSubscription.billing_cycle_anchor)
: currentEpochMs;
const billingCycleAnchorMs =
direction === "charge"
? billingContext.billingCycleAnchorMs
: originalBillingCycleAnchorMs;
const lineItems: LineItem[] = [];
for (const customerProduct of siblingCustomerProducts) {
lineItems.push(
...cusProductToLineItems({
cusProduct: customerProduct,
nowMs: currentEpochMs,
billingCycleAnchorMs,
...customerProductToLineItems({
ctx,
customerProduct: customerProduct,
billingContext,
direction,
org,
logger,
priceFilters: { excludeOneOffPrices: true },
}),
);
}

View File

@@ -1,4 +1,4 @@
import type { LineItem } from "@autumn/shared";
import { isOneOffPrice, type LineItem } from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import type { BillingContext } from "@/internal/billing/v2/billingContext";
import { getTrialStateTransition } from "@/internal/billing/v2/utils/billingContext/getTrialStateTransition";
@@ -42,8 +42,9 @@ export const filterLineItemsForTrialTransition = ({
}
return lineItems.filter((lineItem) => {
const { billingTiming, direction } = lineItem.context;
const { billingTiming, direction, price } = lineItem.context;
const isPositive = lineItem.amount > 0;
const isRecurringPrice = !isOneOffPrice(price);
// Ending trial (isTrialing → !willBeTrialing):
// Filter out refunds and in_arrear positive items (no refund for trial period, no arrear charges)
@@ -55,7 +56,8 @@ export const filterLineItemsForTrialTransition = ({
// Starting trial (!isTrialing → willBeTrialing):
// Filter out in_advance positive items (no charge for upcoming trial period)
if (willBeTrialing) {
if (billingTiming === "in_advance" && isPositive) return false;
if (billingTiming === "in_advance" && isPositive && isRecurringPrice)
return false;
}
return true;

View File

@@ -1,83 +0,0 @@
import type { AutumnContext } from "../../../../honoUtils/HonoEnv";
import type { AttachContext, AttachPlan } from "../typesOld";
import { applyCusProductActions } from "./executeAutumnActions/applyCusProductActions";
import { executeStripeCheckoutAction } from "./executeStripeCheckoutAction";
import { executeStripeInvoiceAction } from "./executeStripeInvoiceAction";
import { executeStripeSubAction } from "./executeStripeSubAction";
export const executeAttachActions = async ({
ctx,
attachPlan,
attachContext,
}: {
ctx: AutumnContext;
attachPlan: AttachPlan;
attachContext: AttachContext;
}) => {
const { org, env, logger } = ctx;
const {
stripeSubAction,
stripeInvoiceAction,
ongoingCusProductAction,
scheduledCusProductAction,
stripeCheckoutAction,
} = attachPlan;
logger.info(`executing attach actions: `, {
checkoutInfo: {
shouldCreate: stripeCheckoutAction?.shouldCreate,
reason: stripeCheckoutAction?.reason,
},
ongoingCusProductAction: {
action: ongoingCusProductAction?.action,
cusProduct: ongoingCusProductAction?.cusProduct.product.id,
},
scheduledCusProductAction: scheduledCusProductAction
? {
action: scheduledCusProductAction.action,
cusProduct: scheduledCusProductAction.cusProduct.product.id,
}
: undefined,
newFullCusProducts: attachPlan.newCusProducts.map((cp) => cp.product.id),
stripeSubAction,
stripeInvoiceAction: stripeInvoiceAction ?? "none",
});
// throw new RecaseError({
// message: `test`,
// });
if (stripeCheckoutAction.shouldCreate) {
return await executeStripeCheckoutAction({
ctx,
stripeCheckoutAction,
});
}
// 1. Create invoice if necessary
if (stripeInvoiceAction) {
await executeStripeInvoiceAction({
ctx,
attachContext,
stripeCheckoutAction,
stripeInvoiceAction,
});
}
// 2. Execute stripe sub action
if (stripeSubAction) {
await executeStripeSubAction({
ctx,
stripeSubAction,
});
}
// 3. Apply cus product actions
await applyCusProductActions({
ctx,
ongoingCusProductAction,
scheduledCusProductAction,
newCusProducts: attachPlan.newCusProducts,
});
};

View File

@@ -1,34 +0,0 @@
import type {
FullCusProduct,
OngoingCusProductAction,
ScheduledCusProductAction,
} from "@autumn/shared";
import type { AutumnContext } from "../../../../../honoUtils/HonoEnv";
import { applyOngoingCusProductAction } from "./applyOngoingCusProductAction";
import { insertNewCusProducts } from "./insertNewCusProducts";
export const applyCusProductActions = async ({
ctx,
ongoingCusProductAction,
scheduledCusProductAction,
newCusProducts,
}: {
ctx: AutumnContext;
ongoingCusProductAction?: OngoingCusProductAction;
scheduledCusProductAction?: ScheduledCusProductAction;
newCusProducts: FullCusProduct[];
}) => {
// 1. Insert new cus products
await insertNewCusProducts({
ctx,
newCusProducts,
});
// 2. Apply ongoing cus product action
if (ongoingCusProductAction) {
await applyOngoingCusProductAction({
ctx,
ongoingCusProductAction,
});
}
};

View File

@@ -1,51 +0,0 @@
import { CusProductStatus, type OngoingCusProductAction } from "@autumn/shared";
import type { AutumnContext } from "../../../../../honoUtils/HonoEnv";
import { CusProductService } from "../../../../customers/cusProducts/CusProductService";
export const applyOngoingCusProductAction = async ({
ctx,
ongoingCusProductAction,
}: {
ctx: AutumnContext;
ongoingCusProductAction: OngoingCusProductAction;
}) => {
const { action, cusProduct } = ongoingCusProductAction;
if (action === "update") {
return;
}
if (action === "expire") {
return await CusProductService.update({
db: ctx.db,
cusProductId: cusProduct.id,
updates: {
status: CusProductStatus.Expired,
},
});
}
if (action === "cancel") {
return await CusProductService.update({
db: ctx.db,
cusProductId: cusProduct.id,
updates: {
canceled: true,
canceled_at: Date.now(),
// TODO: add ended_at
},
});
}
if (action === "uncancel") {
return await CusProductService.update({
db: ctx.db,
cusProductId: cusProduct.id,
updates: {
canceled: false,
canceled_at: null,
ended_at: null,
},
});
}
};

View File

@@ -1,62 +0,0 @@
import type {
FeatureOptions,
FullCusProduct,
OngoingCusProductAction,
} from "@autumn/shared";
import type { AutumnContext } from "../../../../../honoUtils/HonoEnv";
import type { QuantityUpdateDetails } from "../../typesOld";
import { applyOngoingCusProductAction } from "./applyOngoingCusProductAction";
import { insertNewCusProducts } from "./insertNewCusProducts";
import { updateCustomerEntitlements } from "./updateCustomerEntitlements";
import { updateCustomerProductOptions } from "./updateCustomerProductOptions";
export const executeCusProductActions = async ({
ctx,
ongoingCusProductAction,
newCusProducts,
quantityUpdateDetails,
updatedFeatureOptions,
}: {
ctx: AutumnContext;
ongoingCusProductAction?: OngoingCusProductAction;
newCusProducts: FullCusProduct[];
quantityUpdateDetails?: QuantityUpdateDetails[];
updatedFeatureOptions?: FeatureOptions[];
}) => {
const { logger } = ctx;
logger.info("Inserting new customer products");
await insertNewCusProducts({
ctx,
newCusProducts,
});
if (ongoingCusProductAction) {
logger.info(
`Applying ongoing customer product action: ${ongoingCusProductAction.action}`,
);
await applyOngoingCusProductAction({
ctx,
ongoingCusProductAction,
});
}
if (updatedFeatureOptions && ongoingCusProductAction?.cusProduct) {
logger.info("Updating customer product options");
await updateCustomerProductOptions({
ctx,
customerProductId: ongoingCusProductAction.cusProduct.id,
updatedFeatureOptions,
});
}
if (quantityUpdateDetails && quantityUpdateDetails.length > 0) {
logger.info("Updating customer entitlements");
await updateCustomerEntitlements({
ctx,
quantityUpdateDetails,
});
}
logger.info("Successfully executed all customer product actions");
};

View File

@@ -3,6 +3,7 @@ import type { BillingContext } from "@/internal/billing/v2/billingContext";
import { executeAutumnBillingPlan } from "@/internal/billing/v2/execute/executeAutumnBillingPlan";
import { executeStripeBillingPlan } from "@/internal/billing/v2/providers/stripe/execute/executeStripeBillingPlan";
import type { BillingPlan } from "@/internal/billing/v2/types/billingPlan";
import type { BillingResult } from "@/internal/billing/v2/types/billingResult";
export const executeBillingPlan = async ({
ctx,
@@ -12,19 +13,22 @@ export const executeBillingPlan = async ({
ctx: AutumnContext;
billingContext: BillingContext;
billingPlan: BillingPlan;
}) => {
const result = await executeStripeBillingPlan({
}): Promise<BillingResult> => {
const stripeBillingResult = await executeStripeBillingPlan({
ctx,
billingPlan,
billingContext,
});
if (result.deferred) return result;
if (stripeBillingResult.deferred)
return {
stripe: stripeBillingResult,
};
await executeAutumnBillingPlan({
ctx,
autumnBillingPlan: billingPlan.autumn,
});
return { billingPlan };
return { stripe: stripeBillingResult };
};

View File

@@ -5,7 +5,7 @@ import { executeStripeBillingPlan } from "@/internal/billing/v2/providers/stripe
import type { DeferredAutumnBillingPlanData } from "@/internal/billing/v2/types/billingPlan";
import { MetadataService } from "@/internal/metadata/MetadataService";
export const handleDeferredAutumnBillingPlan = async ({
export const executeDeferredBillingPlan = async ({
ctx,
metadata,
}: {
@@ -20,14 +20,14 @@ export const handleDeferredAutumnBillingPlan = async ({
return;
}
const { billingPlan, billingContext } = data;
const { billingPlan, billingContext, resumeAfter } = data;
// Execute stripe billing plan
await executeStripeBillingPlan({
ctx,
billingPlan,
billingContext,
resumeFromDeferred: true,
resumeAfter,
});
await executeAutumnBillingPlan({

View File

@@ -1,56 +0,0 @@
import { createStripeCli } from "@/external/connect/createStripeCli";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import type { StripeSubAction } from "../../typesOld";
/**
* Execute Stripe subscription item updates.
* Handles creating, updating, and deleting subscription items.
*/
export const executeStripeSubscriptionUpdate = async ({
ctx,
stripeSubscriptionAction,
}: {
ctx: AutumnContext;
stripeSubscriptionAction: StripeSubAction;
}) => {
const { org, env, logger } = ctx;
const stripeClient = createStripeCli({ org, env });
if (
!stripeSubscriptionAction.items ||
stripeSubscriptionAction.items.length === 0
) {
logger.info("No subscription items to update");
return;
}
logger.info(
`Updating ${stripeSubscriptionAction.items.length} subscription items`,
);
for (const subscriptionItem of stripeSubscriptionAction.items) {
if (subscriptionItem.deleted) {
logger.info(`Deleting subscription item ${subscriptionItem.id}`);
await stripeClient.subscriptionItems.del(subscriptionItem.id!);
} else if (subscriptionItem.id) {
logger.info(
`Updating subscription item ${subscriptionItem.id} to quantity ${subscriptionItem.quantity}`,
);
await stripeClient.subscriptionItems.update(subscriptionItem.id, {
quantity: subscriptionItem.quantity,
proration_behavior: "none",
});
} else {
logger.info(
`Creating new subscription item for price ${subscriptionItem.price} with quantity ${subscriptionItem.quantity}`,
);
await stripeClient.subscriptionItems.create({
subscription: stripeSubscriptionAction.subId!,
price: subscriptionItem.price!,
quantity: subscriptionItem.quantity,
proration_behavior: "none",
});
}
}
logger.info("Successfully updated all subscription items");
};

View File

@@ -4,7 +4,9 @@ import type { BillingContext } from "@server/internal/billing/v2/billingContext"
import { buildStripeSubscriptionItemsUpdate } from "@server/internal/billing/v2/providers/stripe/utils/subscriptionItems/buildStripeSubscriptionItemsUpdate";
import { buildStripeSubscriptionCreateAction } from "@server/internal/billing/v2/providers/stripe/utils/subscriptions/buildStripeSubscriptionCreateAction";
import { buildStripeSubscriptionUpdateAction } from "@server/internal/billing/v2/providers/stripe/utils/subscriptions/buildStripeSubscriptionUpdateAction";
import { billingPlanToOneOffStripeItemSpecs } from "@/internal/billing/v2/providers/stripe/utils/stripeItemSpec/billingPlanToOneOffStripeItemSpecs";
import type {
AutumnBillingPlan,
StripeSubscriptionAction,
StripeSubscriptionScheduleAction,
} from "@/internal/billing/v2/types/billingPlan";
@@ -12,11 +14,13 @@ import type {
export const buildStripeSubscriptionAction = ({
ctx,
billingContext,
autumnBillingPlan,
finalCustomerProducts,
stripeSubscriptionScheduleAction,
}: {
ctx: AutumnContext;
billingContext: BillingContext;
autumnBillingPlan: AutumnBillingPlan;
finalCustomerProducts: FullCusProduct[];
stripeSubscriptionScheduleAction?: StripeSubscriptionScheduleAction;
}): StripeSubscriptionAction | undefined => {
@@ -28,6 +32,11 @@ export const buildStripeSubscriptionAction = ({
finalCustomerProducts,
});
const oneOffItemSpecs = billingPlanToOneOffStripeItemSpecs({
ctx,
autumnBillingPlan,
});
// Case 1: No subscription and sub items update is empty -> no action
if (!stripeSubscription && subItemsUpdate.length === 0) {
return undefined;
@@ -39,7 +48,10 @@ export const buildStripeSubscriptionAction = ({
ctx,
billingContext,
subItemsUpdate,
addInvoiceItems: [],
addInvoiceItems: oneOffItemSpecs.map((item) => ({
price: item.stripePriceId,
quantity: item.quantity,
})),
});
}

View File

@@ -37,6 +37,7 @@ export const evaluateStripeBillingPlan = async ({
const stripeSubscriptionAction = buildStripeSubscriptionAction({
ctx,
billingContext,
autumnBillingPlan,
finalCustomerProducts: finalFullCustomer.customer_products,
});

View File

@@ -1,4 +1,3 @@
import type Stripe from "stripe";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import type { BillingContext } from "@/internal/billing/v2/billingContext";
import { addStripeSubscriptionScheduleIdToBillingPlan } from "@/internal/billing/v2/execute/addStripeSubscriptionScheduleIdToBillingPlan";
@@ -6,19 +5,20 @@ import { executeStripeInvoiceAction } from "@/internal/billing/v2/providers/stri
import { executeStripeSubscriptionAction } from "@/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionAction";
import { executeStripeSubscriptionScheduleAction } from "@/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionScheduleAction";
import { createStripeInvoiceItems } from "@/internal/billing/v2/providers/stripe/utils/invoices/stripeInvoiceOps";
import { StripeBillingStage } from "@/internal/billing/v2/types/autumnBillingPlan";
import type { BillingPlan } from "@/internal/billing/v2/types/billingPlan";
import type { StripeBillingPlanResult } from "@/internal/billing/v2/types/stripeBillingPlanResult";
import type { StripeBillingPlanResult } from "@/internal/billing/v2/types/billingResult";
export const executeStripeBillingPlan = async ({
ctx,
billingPlan,
billingContext,
resumeFromDeferred = false,
resumeAfter,
}: {
ctx: AutumnContext;
billingPlan: BillingPlan;
billingContext: BillingContext;
resumeFromDeferred?: boolean;
resumeAfter?: StripeBillingStage;
}): Promise<StripeBillingPlanResult> => {
const { logger } = ctx;
const {
@@ -28,39 +28,46 @@ export const executeStripeBillingPlan = async ({
subscriptionScheduleAction: stripeSubscriptionScheduleAction,
} = billingPlan.stripe;
if (stripeInvoiceAction && !resumeFromDeferred) {
const result = await executeStripeInvoiceAction({
// Collect results from each stage
let invoiceResult: StripeBillingPlanResult | undefined;
let subscriptionResult: StripeBillingPlanResult | undefined;
let stripeSubscription = billingContext.stripeSubscription;
const resumeAfterInvoiceAction =
resumeAfter === StripeBillingStage.InvoiceAction;
const resumeAfterSubscriptionAction =
resumeAfter === StripeBillingStage.SubscriptionAction;
if (stripeInvoiceAction && resumeAfterInvoiceAction) {
invoiceResult = await executeStripeInvoiceAction({
ctx,
billingPlan,
billingContext,
});
if (result.deferred) return result;
if (invoiceResult.deferred) return invoiceResult;
}
if (stripeInvoiceItemsAction?.createInvoiceItems) {
logger.info(
"[executeStripeBillingPlan] Creating invoice items for next cycle",
);
if (
stripeInvoiceItemsAction?.createInvoiceItems &&
!resumeAfterSubscriptionAction
) {
logger.info("[execStripePlan] Creating invoice items for next cycle");
await createStripeInvoiceItems({
ctx,
invoiceItems: stripeInvoiceItemsAction.createInvoiceItems,
});
}
let stripeSubscription: Stripe.Subscription | undefined =
billingContext.stripeSubscription;
if (stripeSubscriptionAction) {
const result = await executeStripeSubscriptionAction({
if (stripeSubscriptionAction && !resumeAfterSubscriptionAction) {
subscriptionResult = await executeStripeSubscriptionAction({
ctx,
billingPlan,
billingContext,
});
if (result?.deferred) return result;
stripeSubscription = result.stripeSubscription;
if (subscriptionResult?.deferred) return subscriptionResult;
stripeSubscription = subscriptionResult.stripeSubscription;
}
if (stripeSubscriptionScheduleAction) {
@@ -80,5 +87,12 @@ export const executeStripeBillingPlan = async ({
}
}
return { stripeInvoice: undefined };
return {
stripeSubscription: subscriptionResult?.stripeSubscription,
stripeInvoice:
subscriptionResult?.stripeInvoice ?? invoiceResult?.stripeInvoice,
actionRequired: invoiceResult?.actionRequired,
};
};

View File

@@ -1,11 +1,14 @@
import { ms } from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import type { BillingContext } from "@/internal/billing/v2/billingContext";
import { createInvoiceForBilling } from "@/internal/billing/v2/providers/stripe/utils/invoices/createInvoiceForBilling";
import { StripeBillingStage } from "@/internal/billing/v2/types/autumnBillingPlan";
import type {
BillingPlan,
StripeInvoiceMetadata,
} from "@/internal/billing/v2/types/billingPlan";
import type { StripeBillingPlanResult } from "@/internal/billing/v2/types/stripeBillingPlanResult";
import type { StripeBillingPlanResult } from "@/internal/billing/v2/types/billingResult";
import { isDeferredInvoiceMode } from "@/internal/billing/v2/utils/billingContext/isDeferredInvoiceMode";
import { upsertInvoiceFromBilling } from "@/internal/billing/v2/utils/upsertFromStripe/upsertInvoiceFromBilling";
import { insertMetadataFromBillingPlan } from "@/internal/metadata/utils/insertMetadataFromBillingPlan";
@@ -30,30 +33,36 @@ export const executeStripeInvoiceAction = async ({
logger.debug("[executeStripeInvoiceAction] Creating invoice for billing");
const { invoice } = await createInvoiceForBilling({
const { invoice, actionRequired } = await createInvoiceForBilling({
ctx,
billingContext,
stripeInvoiceAction,
invoiceMetadata,
});
const enableProductAfterInvoice =
billingContext.invoiceMode?.enableProductImmediately === false;
const invoiceActionRequired = invoice.status === "open";
// Insert metadata into DB
const deferBillingPlan = enableProductAfterInvoice || invoiceActionRequired;
const deferredInvoiceMode = isDeferredInvoiceMode({
billingContext,
});
// const actionRequired = invoice.status === "open" && !isInvoiceMode;
const deferBillingPlan =
invoice.status === "open" && (deferredInvoiceMode || actionRequired);
if (deferBillingPlan) {
logger.debug(
`Deferring billing plan, enableProductAfterInvoice: ${enableProductAfterInvoice}, invoiceActionRequired: ${invoiceActionRequired}`,
);
logger.debug(`Deferring billing plan`);
await insertMetadataFromBillingPlan({
ctx,
billingPlan,
billingContext,
enableProductAfterInvoice,
invoiceActionRequired,
stripeInvoice: invoice,
expiresAt: deferredInvoiceMode
? Date.now() + ms.days(10)
: Date.now() + ms.days(30),
resumeAfter: StripeBillingStage.InvoiceAction,
});
await upsertInvoiceFromBilling({
@@ -66,6 +75,7 @@ export const executeStripeInvoiceAction = async ({
return {
stripeInvoice: invoice,
deferred: true,
actionRequired,
};
}

View File

@@ -1,85 +1,22 @@
import { InternalError } from "@autumn/shared";
import type Stripe from "stripe";
import { ms } from "@autumn/shared";
import { createStripeCli } from "@/external/connect/createStripeCli";
import { logStripeInvoice } from "@/external/stripe/invoices/utils/logStripeInvoice";
import { isStripeSubscriptionCanceled } from "@/external/stripe/subscriptions/utils/classifyStripeSubscriptionUtils";
import { setStripeSubscriptionLock } from "@/external/stripe/subscriptions/utils/lockStripeSubscriptionUtils";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import type { BillingContext } from "@/internal/billing/v2/billingContext";
import { addStripeSubscriptionIdToBillingPlan } from "@/internal/billing/v2/execute/addStripeSubscriptionIdToBillingPlan";
import { removeStripeSubscriptionIdFromBillingPlan } from "@/internal/billing/v2/execute/removeStripeSubscriptionIdFromBillingPlan";
import type {
BillingPlan,
StripeSubscriptionAction,
} from "@/internal/billing/v2/types/billingPlan";
import type { StripeBillingPlanResult } from "@/internal/billing/v2/types/stripeBillingPlanResult";
import { finalizeStripeInvoice } from "@/internal/billing/v2/providers/stripe/utils/invoices/stripeInvoiceOps";
import { executeStripeSubscriptionOperation } from "@/internal/billing/v2/providers/stripe/utils/subscriptions/executeStripeSubscriptionOperation";
import { getLatestInvoiceFromSubscriptionAction } from "@/internal/billing/v2/providers/stripe/utils/subscriptions/getLatestInvoiceFromSubscriptionAction";
import { StripeBillingStage } from "@/internal/billing/v2/types/autumnBillingPlan";
import type { BillingPlan } from "@/internal/billing/v2/types/billingPlan";
import type { StripeBillingPlanResult } from "@/internal/billing/v2/types/billingResult";
import { upsertInvoiceFromBilling } from "@/internal/billing/v2/utils/upsertFromStripe/upsertInvoiceFromBilling";
import { upsertSubscriptionFromBilling } from "@/internal/billing/v2/utils/upsertFromStripe/upsertSubscriptionFromBilling";
import { insertMetadataFromBillingPlan } from "@/internal/metadata/utils/insertMetadataFromBillingPlan";
type InvoiceModeParams = {
collection_method?: "send_invoice";
days_until_due?: number;
};
const executeSubscriptionOperation = async ({
ctx,
billingContext,
subscriptionAction,
invoiceModeParams,
}: {
ctx: AutumnContext;
billingContext: BillingContext;
subscriptionAction: StripeSubscriptionAction;
invoiceModeParams: InvoiceModeParams;
}) => {
const { org, env } = ctx;
const stripeClient = createStripeCli({ org, env });
switch (subscriptionAction.type) {
case "update": {
let stripeSubscription = billingContext.stripeSubscription;
if (
stripeSubscription &&
stripeSubscription.billing_mode.type !== "flexible"
) {
stripeSubscription = await stripeClient.subscriptions.migrate(
stripeSubscription?.id,
{
billing_mode: { type: "flexible" },
},
);
}
return await stripeClient.subscriptions.update(
subscriptionAction.stripeSubscriptionId,
{
...subscriptionAction.params,
...invoiceModeParams,
expand: ["latest_invoice"],
},
);
}
case "create":
return await stripeClient.subscriptions.create({
...subscriptionAction.params,
...invoiceModeParams,
expand: ["latest_invoice"],
});
case "cancel":
return await stripeClient.subscriptions.cancel(
subscriptionAction.stripeSubscriptionId,
{
expand: ["latest_invoice"],
},
);
default:
throw new InternalError({
message: "Invalid subscription action type",
});
}
};
export const executeStripeSubscriptionAction = async ({
ctx,
billingPlan,
@@ -95,6 +32,7 @@ export const executeStripeSubscriptionAction = async ({
if (!subscriptionAction) return {};
let { invoiceMode, stripeSubscription, currentEpochMs } = billingContext;
const { logger } = ctx;
// 2. Lock stripe subscription
if (stripeSubscription) {
@@ -104,43 +42,36 @@ export const executeStripeSubscriptionAction = async ({
});
}
// Invoice mode:
const invoiceModeParams = invoiceMode
? {
collection_method: "send_invoice" as const,
days_until_due: 30,
}
: {};
ctx.logger.debug(
`[executeStripeSubscriptionAction] Executing subscription operation: ${subscriptionAction.type}`,
);
stripeSubscription = await executeSubscriptionOperation({
logger.debug(`[execSubAction] Executing subscription operation`);
stripeSubscription = await executeStripeSubscriptionOperation({
ctx,
billingContext,
subscriptionAction,
invoiceModeParams,
});
const latestStripeInvoice =
subscriptionAction.type === "create"
? (stripeSubscription.latest_invoice as Stripe.Invoice)
: undefined;
let latestStripeInvoice = getLatestInvoiceFromSubscriptionAction({
stripeSubscription,
subscriptionAction,
billingContext,
});
// Defer billing plan
const enableProductAfterInvoice =
invoiceMode?.enableProductImmediately === false;
if (latestStripeInvoice && invoiceMode?.finalizeInvoice) {
logger.debug(`[execSubAction] Finalizing invoice`);
latestStripeInvoice = await finalizeStripeInvoice({
stripeCli: createStripeCli({ org: ctx.org, env: ctx.env }),
invoiceId: latestStripeInvoice.id,
});
const invoiceActionRequired =
subscriptionAction.type === "create" &&
latestStripeInvoice?.status === "open";
logStripeInvoice({
logger,
stripeInvoice: latestStripeInvoice,
});
}
const deferBillingPlan = enableProductAfterInvoice || invoiceActionRequired;
const deferBillingPlan = latestStripeInvoice?.status === "open";
if (latestStripeInvoice) {
ctx.logger.debug(
`[executeStripeSubscriptionAction] Upserting invoice from billing: ${latestStripeInvoice.id}`,
);
logger.debug(`[execSubAction] Upserting invoice from billing`);
await upsertInvoiceFromBilling({
ctx,
stripeInvoice: latestStripeInvoice,
@@ -150,16 +81,27 @@ export const executeStripeSubscriptionAction = async ({
}
if (deferBillingPlan) {
ctx.logger.debug(
`[executeStripeSubscriptionAction] Inserting metadata from billing plan`,
);
if (!latestStripeInvoice) {
logger.error(
"Attempted to defer billing plan with no latest stripe invoice",
);
}
logger.debug(`[execSubAction] Inserting metadata from billing plan`);
// Required if we resume after and carry out subscription schedule action
const deferredBillingContext = {
...billingContext,
stripeSubscription,
};
await insertMetadataFromBillingPlan({
ctx,
billingPlan,
billingContext,
enableProductAfterInvoice,
invoiceActionRequired,
billingContext: deferredBillingContext,
stripeInvoice: latestStripeInvoice,
expiresAt: Date.now() + ms.days(30),
resumeAfter: StripeBillingStage.SubscriptionAction,
});
return {
@@ -175,9 +117,7 @@ export const executeStripeSubscriptionAction = async ({
});
// Add subscription to DB
ctx.logger.debug(
`[executeStripeSubscriptionAction] Upserting subscription from billing: ${stripeSubscription.id}`,
);
logger.debug(`[execSubAction] Upserting subscription from billing`);
await upsertSubscriptionFromBilling({
ctx,
stripeSubscription,
@@ -185,9 +125,7 @@ export const executeStripeSubscriptionAction = async ({
// If the stripe subscription is canceled, remove the subscription from the billing plan
if (isStripeSubscriptionCanceled(stripeSubscription)) {
ctx.logger.debug(
`[executeStripeSubscriptionAction] Subscription canceled, removing subscription from billing plan: ${stripeSubscription.id}`,
);
logger.debug(`[execSubAction] removing subscription from billing plan`);
removeStripeSubscriptionIdFromBillingPlan({
autumnBillingPlan: billingPlan.autumn,
stripeSubscriptionId: stripeSubscription.id,

View File

@@ -33,11 +33,12 @@ export const createInvoiceForBilling = async ({
const shouldFinalizeInvoice = invoiceMode
? invoiceMode.finalizeInvoice
: true;
const shouldPayImmediately = invoiceMode
? invoiceMode.enableProductImmediately
: true;
const collectionMethod = shouldPayImmediately ? "charge_automatically" : "send_invoice";
const isInvoiceMode = Boolean(invoiceMode);
const collectionMethod = isInvoiceMode
? "send_invoice"
: "charge_automatically";
const draftInvoice = await createStripeInvoice({
stripeCli,
@@ -46,26 +47,26 @@ export const createInvoiceForBilling = async ({
collectionMethod,
});
await addStripeInvoiceLines({
const invoiceWithLines = await addStripeInvoiceLines({
stripeCli,
invoiceId: draftInvoice.id,
lines: addLineParams.lines,
});
if (!shouldFinalizeInvoice) {
return { paid: false, invoice: draftInvoice };
return { paid: false, invoice: invoiceWithLines };
}
const finalizedInvoice = await finalizeStripeInvoice({
stripeCli,
invoiceId: draftInvoice.id,
invoiceId: invoiceWithLines.id,
});
if (finalizedInvoice.status === "paid") {
return { paid: true, invoice: finalizedInvoice };
}
if (!shouldPayImmediately) {
if (isInvoiceMode) {
return { paid: false, invoice: finalizedInvoice };
}
@@ -73,6 +74,5 @@ export const createInvoiceForBilling = async ({
stripeCli,
invoiceId: finalizedInvoice.id,
paymentMethod: billingContext.paymentMethod,
onFailure: "return_url",
});
};

View File

@@ -0,0 +1,69 @@
import type Stripe from "stripe";
import type { PayInvoiceResult } from "./payStripeInvoice";
// ============================================
// Helpers
// ============================================
/**
* Maps Stripe error codes to payment failure codes.
* Returns "3ds_required" for authentication errors, "payment_failed" for all others.
*/
const getFailureCodeFromStripeError = ({
stripeError,
}: {
stripeError: Stripe.errors.StripeError;
}): "3ds_required" | "payment_failed" => {
const authCodes = ["authentication_required", "authentication_not_handled"];
if (authCodes.includes(stripeError.code ?? "")) {
return "3ds_required";
}
return "payment_failed";
};
// ============================================
// Handle Payment Failure
// ============================================
/**
* Builds a failed PayInvoiceResult from an invoice and error.
* Handles "no_payment_method" and Stripe errors - throws non-Stripe errors.
*/
export const handleInvoicePaymentFailure = ({
invoice,
error,
}: {
invoice: Stripe.Invoice;
error: Error | "no_payment_method";
}): PayInvoiceResult => {
// 1. No payment method case
if (error === "no_payment_method") {
return {
paid: false,
invoice,
actionRequired: {
code: "payment_method_required",
reason: "No payment method found",
},
};
}
// 2. Check if it's a Stripe error
const stripeError = error as Stripe.errors.StripeError;
const isStripeError = stripeError.type !== undefined;
if (!isStripeError) throw error;
// 3. Handle Stripe errors
return {
paid: false,
invoice,
actionRequired: {
code: getFailureCodeFromStripeError({ stripeError }),
reason: stripeError.message ?? "Failed to pay invoice",
},
stripeError,
};
};

View File

@@ -1,30 +1,25 @@
import { ErrCode } from "@autumn/shared";
import { type PaymentFailureCode, tryCatch } from "@autumn/shared";
import type Stripe from "stripe";
import RecaseError from "@/utils/errorUtils.js";
import { handleInvoicePaymentFailure } from "./handleInvoicePaymentFailure";
// ============================================
// Types
// ============================================
export type PaymentFailureMode =
| "return_url"
| "checkout_session"
| "throw"
| "void";
export type PayInvoiceResult = {
paid: boolean;
invoice: Stripe.Invoice;
hostedUrl?: string;
error?: Error;
createCheckoutSession?: boolean;
actionRequired?: {
code: PaymentFailureCode;
reason: string;
};
stripeError?: Stripe.errors.StripeError;
};
export type PayStripeInvoiceParams = {
stripeCli: Stripe;
invoiceId: string;
paymentMethod?: Stripe.PaymentMethod | null;
onFailure?: PaymentFailureMode;
};
// ============================================
@@ -35,10 +30,9 @@ export const payStripeInvoice = async ({
stripeCli,
invoiceId,
paymentMethod,
onFailure = "return_url",
}: PayStripeInvoiceParams): Promise<PayInvoiceResult> => {
// 1. Retrieve invoice to check status
let invoice = await stripeCli.invoices.retrieve(invoiceId);
const invoice = await stripeCli.invoices.retrieve(invoiceId);
// 2. Already paid - return success
if (invoice.status === "paid") {
@@ -48,93 +42,27 @@ export const payStripeInvoice = async ({
};
}
// 3. No payment method - handle based on failure mode
// 3. No payment method - return failure
if (!paymentMethod) {
return handlePaymentFailure({
stripeCli,
return handleInvoicePaymentFailure({
invoice,
onFailure,
error: new RecaseError({
message: "No payment method found",
code: ErrCode.CustomerHasNoPaymentMethod,
statusCode: 400,
}),
error: "no_payment_method",
});
}
// 4. Attempt payment
try {
invoice = await stripeCli.invoices.pay(invoiceId, {
const { data: paidInvoice, error } = await tryCatch(
stripeCli.invoices.pay(invoiceId, {
payment_method: paymentMethod.id,
});
}),
);
return {
paid: true,
invoice,
};
} catch (err) {
const errMessage =
err instanceof Error ? err.message : "Failed to pay invoice";
return handlePaymentFailure({
stripeCli,
invoice,
onFailure,
error: new RecaseError({
message: errMessage,
code: ErrCode.PayInvoiceFailed,
statusCode: 400,
}),
});
}
};
// ============================================
// Handle Payment Failure
// ============================================
const handlePaymentFailure = async ({
stripeCli,
invoice,
onFailure,
error,
}: {
stripeCli: Stripe;
invoice: Stripe.Invoice;
onFailure: PaymentFailureMode;
error: Error;
}): Promise<PayInvoiceResult> => {
switch (onFailure) {
case "throw":
throw error;
case "checkout_session":
return {
paid: false,
invoice,
hostedUrl: undefined,
error,
createCheckoutSession: true,
};
case "void":
try {
await stripeCli.invoices.voidInvoice(invoice.id!);
} catch (_voidError) {
// Silently fail void attempt
}
return {
paid: false,
invoice,
error,
};
default:
return {
paid: false,
invoice,
hostedUrl: invoice.hosted_invoice_url || undefined,
error,
};
if (error) {
return handleInvoicePaymentFailure({ invoice, error });
}
return {
paid: true,
invoice: paidInvoice,
};
};

View File

@@ -1,5 +1,5 @@
import { isStripeSubscriptionTrialing } from "@/external/stripe/subscriptions/utils/classifyStripeSubscriptionUtils";
import type { BillingContext } from "@/internal/billing/v2/billingContext";
import { willStripeSubscriptionUpdateCreateInvoice } from "@/internal/billing/v2/providers/stripe/utils/subscriptions/willStripeSubscriptionUpdateCreateInvoice";
import type { StripeSubscriptionAction } from "@/internal/billing/v2/types/billingPlan";
export const shouldCreateManualStripeInvoice = ({
@@ -12,15 +12,15 @@ export const shouldCreateManualStripeInvoice = ({
const isCreateAction = stripeSubscriptionAction?.type === "create";
if (isCreateAction) return false;
const { stripeSubscription, trialContext } = billingContext;
const { stripeSubscription } = billingContext;
if (!stripeSubscription) return false;
const isTrialing = isStripeSubscriptionTrialing(stripeSubscription);
const endingTrial = trialContext?.trialEndsAt === null;
const updateWillCharge =
stripeSubscriptionAction?.type === "update" && isTrialing && endingTrial;
const updateWillCreateInvoice = willStripeSubscriptionUpdateCreateInvoice({
billingContext,
stripeSubscriptionAction,
});
if (updateWillCharge) return false;
if (updateWillCreateInvoice) return false;
return true;
};

View File

@@ -0,0 +1,23 @@
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { customerProductToStripeItemSpecs } from "@/internal/billing/v2/providers/stripe/utils/subscriptionItems/customerProductToStripeItemSpecs";
import type { AutumnBillingPlan } from "@/internal/billing/v2/types/billingPlan";
export const billingPlanToOneOffStripeItemSpecs = ({
ctx,
autumnBillingPlan,
}: {
ctx: AutumnContext;
autumnBillingPlan: AutumnBillingPlan;
}) => {
const newCustomerProducts = autumnBillingPlan.insertCustomerProducts;
const oneOffItems = newCustomerProducts.flatMap((customerProduct) => {
const { oneOffItems } = customerProductToStripeItemSpecs({
ctx,
customerProduct,
});
return oneOffItems;
});
return oneOffItems;
};

View File

@@ -37,10 +37,6 @@ export const buildStripeSubscriptionUpdateAction = ({
trialEndsAt &&
msToSeconds(trialEndsAt) !== stripeSubscription?.trial_end;
console.log("shouldSetTrialEnd", shouldSetTrialEnd);
console.log("trialEndsAt", trialEndsAt);
console.log("stripeSubscription?.trial_end", stripeSubscription?.trial_end);
const shouldUnsetTrialEnd =
!scheduleManagesSubscription && trialEndsAt === null;

View File

@@ -0,0 +1,74 @@
import { InternalError } from "@autumn/shared";
import { createStripeCli } from "@/external/connect/createStripeCli";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import type { BillingContext } from "@/internal/billing/v2/billingContext";
import type { StripeSubscriptionAction } from "@/internal/billing/v2/types/billingPlan";
type InvoiceModeParams = {
collection_method?: "send_invoice";
days_until_due?: number;
};
export const executeStripeSubscriptionOperation = async ({
ctx,
billingContext,
subscriptionAction,
}: {
ctx: AutumnContext;
billingContext: BillingContext;
subscriptionAction: StripeSubscriptionAction;
}) => {
const { org, env } = ctx;
const stripeClient = createStripeCli({ org, env });
const invoiceModeParams = billingContext.invoiceMode
? {
collection_method: "send_invoice" as const,
days_until_due: 30,
}
: {};
switch (subscriptionAction.type) {
case "update": {
let stripeSubscription = billingContext.stripeSubscription;
if (
stripeSubscription &&
stripeSubscription.billing_mode.type !== "flexible"
) {
stripeSubscription = await stripeClient.subscriptions.migrate(
stripeSubscription?.id,
{
billing_mode: { type: "flexible" },
},
);
}
return await stripeClient.subscriptions.update(
subscriptionAction.stripeSubscriptionId,
{
...subscriptionAction.params,
...invoiceModeParams,
expand: ["latest_invoice"],
},
);
}
case "create":
return await stripeClient.subscriptions.create({
...subscriptionAction.params,
...invoiceModeParams,
expand: ["latest_invoice"],
});
case "cancel":
return await stripeClient.subscriptions.cancel(
subscriptionAction.stripeSubscriptionId,
{
expand: ["latest_invoice"],
},
);
default:
throw new InternalError({
message: "Invalid subscription action type",
});
}
};

View File

@@ -0,0 +1,30 @@
import type Stripe from "stripe";
import type { BillingContext } from "@/internal/billing/v2/billingContext";
import { willStripeSubscriptionUpdateCreateInvoice } from "@/internal/billing/v2/providers/stripe/utils/subscriptions/willStripeSubscriptionUpdateCreateInvoice";
import type { StripeSubscriptionAction } from "@/internal/billing/v2/types/billingPlan";
/**
* Returns the latest invoice from a subscription action if one was created.
* Only create actions and updates that trigger proration generate an invoice.
*/
export const getLatestInvoiceFromSubscriptionAction = ({
stripeSubscription,
subscriptionAction,
billingContext,
}: {
stripeSubscription: Stripe.Subscription;
subscriptionAction: StripeSubscriptionAction;
billingContext: BillingContext;
}): Stripe.Invoice | undefined => {
const isCreateAction = subscriptionAction.type === "create";
const updateWillCreateInvoice = willStripeSubscriptionUpdateCreateInvoice({
billingContext,
stripeSubscriptionAction: subscriptionAction,
});
if (isCreateAction || updateWillCreateInvoice) {
return stripeSubscription.latest_invoice as Stripe.Invoice;
}
return undefined;
};

View File

@@ -0,0 +1,22 @@
import type { BillingContext } from "@/internal/billing/v2/billingContext";
import type { StripeSubscriptionAction } from "@/internal/billing/v2/types/billingPlan";
import { getTrialStateTransition } from "@/internal/billing/v2/utils/billingContext/getTrialStateTransition";
export const willStripeSubscriptionUpdateCreateInvoice = ({
billingContext,
stripeSubscriptionAction,
}: {
billingContext: BillingContext;
stripeSubscriptionAction?: StripeSubscriptionAction;
}): boolean => {
const actionType = stripeSubscriptionAction?.type;
if (actionType !== "update") return false;
const { isTrialing, willBeTrialing } = getTrialStateTransition({
billingContext,
});
if (isTrialing && !willBeTrialing) return true;
return false;
};

View File

@@ -34,9 +34,15 @@ export const AutumnBillingPlanSchema = z.object({
export type AutumnBillingPlan = z.infer<typeof AutumnBillingPlanSchema>;
export enum StripeBillingStage {
InvoiceAction = "invoice_action",
SubscriptionAction = "subscription_action",
}
export type DeferredAutumnBillingPlanData = {
orgId: string;
env: AppEnv;
billingPlan: BillingPlan;
billingContext: BillingContext;
resumeAfter: StripeBillingStage;
};

View File

@@ -3,8 +3,6 @@ import {
type AutumnBillingPlan,
AutumnBillingPlanSchema,
type DeferredAutumnBillingPlanData,
type InvoiceMode,
InvoiceModeSchema,
} from "./autumnBillingPlan";
import {
type StripeBillingPlan,
@@ -22,7 +20,6 @@ import {
export {
AutumnBillingPlanSchema,
InvoiceModeSchema,
StripeBillingPlanSchema,
StripeInvoiceActionSchema,
StripeInvoiceItemsActionSchema,
@@ -30,7 +27,6 @@ export {
StripeSubscriptionScheduleActionSchema,
type AutumnBillingPlan,
type DeferredAutumnBillingPlanData,
type InvoiceMode,
type StripeBillingPlan,
type StripeInvoiceAction,
type StripeInvoiceItemsAction,

View File

@@ -0,0 +1,16 @@
import type { PaymentFailureCode } from "@autumn/shared";
import type Stripe from "stripe";
export interface StripeBillingPlanResult {
deferred?: boolean;
stripeInvoice?: Stripe.Invoice;
stripeSubscription?: Stripe.Subscription;
actionRequired?: {
code: PaymentFailureCode;
reason: string;
};
}
export interface BillingResult {
stripe: StripeBillingPlanResult;
}

View File

@@ -1,7 +0,0 @@
import type Stripe from "stripe";
export interface StripeBillingPlanResult {
deferred?: boolean;
stripeInvoice?: Stripe.Invoice;
stripeSubscription?: Stripe.Subscription;
}

View File

@@ -0,0 +1,34 @@
import { cp, isCustomerProductFree } from "@autumn/shared";
import type { UpdateSubscriptionBillingContext } from "@/internal/billing/v2/billingContext";
import type { AutumnBillingPlan } from "@/internal/billing/v2/types/autumnBillingPlan";
export const computeOneOffLineItems = ({
billingContext,
autumnBillingPlan,
}: {
billingContext: UpdateSubscriptionBillingContext;
autumnBillingPlan: AutumnBillingPlan;
}) => {
const { insertCustomerProducts } = autumnBillingPlan;
const newCustomerProduct = insertCustomerProducts?.[0];
if (!newCustomerProduct) return [];
// Only allow one off items if going from free -> paid
const currentIsFree = isCustomerProductFree(billingContext.customerProduct);
const { valid: newIsPaidRecurring } = cp(newCustomerProduct)
.paid()
.recurring();
const includeOneOffItems = currentIsFree && newIsPaidRecurring;
if (!includeOneOffItems) return [];
// const newOneOffItems = cusProductToLineItems({
// cusProduct: newCustomerProduct,
// nowMs: billingContext.currentEpochMs,
// billingCycleAnchorMs: billingContext.billingCycleAnchorMs,
// direction: "charge",
// org: billingContext.org,
// logger: billingContext.logger,
// });
};

View File

@@ -5,6 +5,8 @@ import {
findFeatureByInternalId,
findFeatureOptionsByFeature,
InternalError,
isOneOffPrice,
RecaseError,
} from "@autumn/shared";
import { getLineItemBillingPeriod } from "@shared/utils/billingUtils/cycleUtils/getLineItemBillingPeriod";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
@@ -49,12 +51,6 @@ export const computeUpdateQuantityDetails = ({
});
}
if (!billingCycleAnchorMs) {
throw new InternalError({
message: `[Quantity Update] billingCycleAnchorMs is required (no active subscription)`,
});
}
const feature = findFeatureByInternalId({
features,
internalId: internalFeatureId,
@@ -77,6 +73,12 @@ export const computeUpdateQuantityDetails = ({
errorOnNotFound: true,
});
if (isOneOffPrice(customerPrice.price)) {
throw new RecaseError({
message: `Not allowed to update feature quantity for one off items.`,
});
}
const customerEntitlement = customerPriceToCustomerEntitlement({
customerPrice,
customerEntitlements: customerProduct.customer_entitlements,
@@ -91,6 +93,12 @@ export const computeUpdateQuantityDetails = ({
customerEntitlement,
});
if (!billingCycleAnchorMs) {
throw new InternalError({
message: `[Quantity Update] billingCycleAnchorMs is required (no active subscription)`,
});
}
const billingPeriod = getLineItemBillingPeriod({
anchorMs: billingCycleAnchorMs,
price: customerPrice.price,

View File

@@ -1,4 +1,3 @@
import { InternalError } from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import type { UpdateSubscriptionBillingContext } from "@/internal/billing/v2/billingContext";
import type { AutumnBillingPlan } from "@/internal/billing/v2/types/billingPlan";
@@ -11,14 +10,7 @@ export const computeUpdateQuantityPlan = ({
ctx: AutumnContext;
updateSubscriptionContext: UpdateSubscriptionBillingContext;
}): AutumnBillingPlan => {
const { customerProduct, stripeSubscription, featureQuantities } =
updateSubscriptionContext;
if (!stripeSubscription) {
throw new InternalError({
message: `[Subscription Update] Stripe subscription not found`,
});
}
const { customerProduct, featureQuantities } = updateSubscriptionContext;
const newOptions = featureQuantities;

View File

@@ -0,0 +1,54 @@
import {
cusProductToProduct,
productsAreSame,
RecaseError,
type UpdateSubscriptionV0Params,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import type { UpdateSubscriptionBillingContext } from "@/internal/billing/v2/billingContext";
import type { AutumnBillingPlan } from "@/internal/billing/v2/types/autumnBillingPlan";
export const handleCustomPlanErrors = ({
ctx,
billingContext,
autumnBillingPlan,
params,
}: {
ctx: AutumnContext;
billingContext: UpdateSubscriptionBillingContext;
autumnBillingPlan: AutumnBillingPlan;
params: UpdateSubscriptionV0Params;
}) => {
if (!params.items) return;
const newCustomerProduct = autumnBillingPlan.insertCustomerProducts?.[0];
const currentCustomerProduct = billingContext.customerProduct;
const currentFullProduct = cusProductToProduct({
cusProduct: currentCustomerProduct,
});
const newFullProduct = cusProductToProduct({
cusProduct: newCustomerProduct,
});
const { itemsSame, onlyEntsChanged } = productsAreSame({
curProductV1: currentFullProduct,
newProductV1: newFullProduct,
features: ctx.features,
});
if (itemsSame) {
throw new RecaseError({
message:
"Cannot update to custom plan because the configuration (features and prices) are the same as the existing product",
});
}
if (onlyEntsChanged && billingContext.invoiceMode) {
throw new RecaseError({
message:
"Cannot create an invoice for this subscription update because there are no billing changes.",
});
}
};

View File

@@ -17,9 +17,7 @@ export const handleFeatureQuantityErrors = ({
autumnBillingPlan: AutumnBillingPlan;
}) => {
const newCustomerProduct = autumnBillingPlan.insertCustomerProducts?.[0];
if (!newCustomerProduct) {
return;
}
if (!newCustomerProduct) return;
const newPrices = cusProductToPrices({ cusProduct: newCustomerProduct });
const prepaidPrices = newPrices.filter(isPrepaidPrice);

View File

@@ -0,0 +1,86 @@
import {
cusProductToProduct,
isCustomerProductOneOff,
isCustomerProductPaidRecurring,
isOneOffPrice,
productsAreSame,
RecaseError,
} from "@autumn/shared";
import { cusProductToPrices } from "@shared/utils/cusProductUtils/convertCusProduct";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import type { UpdateSubscriptionBillingContext } from "@/internal/billing/v2/billingContext";
import type { AutumnBillingPlan } from "@/internal/billing/v2/types/autumnBillingPlan";
import { getTrialStateTransition } from "@/internal/billing/v2/utils/billingContext/getTrialStateTransition";
export const handleOneOffErrors = ({
ctx,
billingContext,
autumnBillingPlan,
}: {
ctx: AutumnContext;
billingContext: UpdateSubscriptionBillingContext;
autumnBillingPlan: AutumnBillingPlan;
}) => {
const { customerProduct } = billingContext;
// Only apply these checks to one-off products
if (!isCustomerProductOneOff(customerProduct)) return;
const newCustomerProduct = autumnBillingPlan.insertCustomerProducts?.[0];
if (!newCustomerProduct) return;
const currentFullProduct = cusProductToProduct({
cusProduct: customerProduct,
});
const newFullProduct = cusProductToProduct({
cusProduct: newCustomerProduct,
});
const { onlyEntsChanged } = productsAreSame({
curProductV1: currentFullProduct,
newProductV1: newFullProduct,
features: ctx.features,
});
if (!onlyEntsChanged) {
throw new RecaseError({
message:
"When updating a one-off plan, price / billing changes are not allowed.",
});
}
};
/** Don't allow removing a trial from a paid recurring product when adding one-off items */
export const checkTrialRemovalWithOneOffItems = ({
billingContext,
autumnBillingPlan,
}: {
billingContext: UpdateSubscriptionBillingContext;
autumnBillingPlan: AutumnBillingPlan;
}) => {
const isPaidRecurring = isCustomerProductPaidRecurring(
billingContext.customerProduct,
);
if (!isPaidRecurring) return;
const { isTrialing, willBeTrialing } = getTrialStateTransition({
billingContext,
});
if (!isTrialing || willBeTrialing) return;
const newCustomerProducts = autumnBillingPlan.insertCustomerProducts;
const newPrices = newCustomerProducts.flatMap((customerProduct) =>
cusProductToPrices({ cusProduct: customerProduct }),
);
const newHasOneOffPrices = newPrices.some(isOneOffPrice);
if (newHasOneOffPrices) {
throw new RecaseError({
message:
"Cannot remove trial from a paid recurring subscription when adding one-off items.",
});
}
};

View File

@@ -1,18 +1,30 @@
import { ProcessorType, RecaseError } from "@autumn/shared";
import {
ProcessorType,
RecaseError,
type UpdateSubscriptionV0Params,
} from "@autumn/shared";
import { cusProductToProcessorType } from "@shared/utils/cusProductUtils/convertCusProduct";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import type { UpdateSubscriptionBillingContext } from "@/internal/billing/v2/billingContext";
import type { AutumnBillingPlan } from "@/internal/billing/v2/types/autumnBillingPlan";
import { handleCustomPlanErrors } from "./handleCustomPlanErrors";
import { handleFeatureQuantityErrors } from "./handleFeatureQuantityErrors";
import {
checkTrialRemovalWithOneOffItems,
handleOneOffErrors,
} from "./handleOneOffErrors";
import { handleProductTypeTransitionErrors } from "./handleProductTypeTransitionErrors";
export const handleUpdateSubscriptionErrors = async ({
ctx,
billingContext,
autumnBillingPlan,
params,
}: {
ctx: AutumnContext;
billingContext: UpdateSubscriptionBillingContext;
autumnBillingPlan: AutumnBillingPlan;
params: UpdateSubscriptionV0Params;
}) => {
const { customerProduct } = billingContext;
@@ -28,4 +40,13 @@ export const handleUpdateSubscriptionErrors = async ({
// 3. Feature quantity errors (prepaid prices must have options)
handleFeatureQuantityErrors({ billingContext, autumnBillingPlan });
// 4. Custom plan errors
handleCustomPlanErrors({ ctx, billingContext, autumnBillingPlan, params });
// 5. One-off errors
handleOneOffErrors({ ctx, billingContext, autumnBillingPlan });
// 6. Trial removal with one-off items
checkTrialRemovalWithOneOffItems({ billingContext, autumnBillingPlan });
};

View File

@@ -1,6 +1,7 @@
import { UpdateSubscriptionV0ParamsSchema } from "@autumn/shared";
import { computeUpdateSubscriptionPlan } from "@/internal/billing/v2/updateSubscription/compute/computeUpdateSubscriptionPlan";
import { handleUpdateSubscriptionErrors } from "@/internal/billing/v2/updateSubscription/errors/handleUpdateSubscriptionErrors";
import { billingResultToResponse } from "@/internal/billing/v2/utils/billingResult/billingResultToResponse";
import { createRoute } from "../../../../honoMiddlewares/routeHandler";
import { executeBillingPlan } from "../execute/executeBillingPlan";
import { evaluateStripeBillingPlan } from "../providers/stripe/actionBuilders/evaluateStripeBillingPlan";
@@ -30,6 +31,7 @@ export const handleUpdateSubscription = createRoute({
ctx,
billingContext,
autumnBillingPlan,
params: body,
});
const stripeBillingPlan = await evaluateStripeBillingPlan({
@@ -38,7 +40,7 @@ export const handleUpdateSubscription = createRoute({
autumnBillingPlan,
});
await executeBillingPlan({
const billingResult = await executeBillingPlan({
ctx,
billingContext,
billingPlan: {
@@ -47,6 +49,13 @@ export const handleUpdateSubscription = createRoute({
},
});
return c.json({ success: true }, 200);
const response = billingResultToResponse({
billingContext,
billingResult,
});
ctx.logger.info("[handleUpdateSubscription] Completed", { response });
return c.json(response, 200);
},
});

View File

@@ -0,0 +1,14 @@
import { secondsToMs } from "@autumn/shared";
import type { BillingContext } from "../../billingContext";
export const getCurrentBillingCycleAnchorMs = ({
billingContext,
}: {
billingContext: BillingContext;
}) => {
const { stripeSubscription } = billingContext;
return stripeSubscription?.billing_cycle_anchor
? secondsToMs(stripeSubscription.billing_cycle_anchor)
: "now";
};

View File

@@ -0,0 +1,13 @@
import type { BillingContext } from "@/internal/billing/v2/billingContext";
export const isDeferredInvoiceMode = ({
billingContext,
}: {
billingContext: BillingContext;
}): boolean => {
const isInvoiceMode = Boolean(billingContext.invoiceMode);
const shouldDefer =
billingContext.invoiceMode?.enableProductImmediately === false;
return isInvoiceMode && shouldDefer;
};

View File

@@ -2,7 +2,6 @@ import {
type BillingPreviewResponse,
cp,
cusProductsToPrices,
cusProductToLineItems,
getCycleEnd,
getSmallestInterval,
sumValues,
@@ -10,6 +9,7 @@ import {
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import type { BillingContext } from "@/internal/billing/v2/billingContext";
import type { BillingPlan } from "@/internal/billing/v2/types/billingPlan";
import { customerProductToLineItems } from "../lineItems/customerProductToLineItems";
export const billingPlanToNextCyclePreview = ({
ctx,
@@ -23,10 +23,6 @@ export const billingPlanToNextCyclePreview = ({
// 1. Return undefined if billing cycle anchor is now
const { billingCycleAnchorMs } = billingContext;
ctx.logger.info(`billingCycleAnchorMs: ${billingCycleAnchorMs}`);
ctx.logger.info(`billingCycleAnchorMs: ${billingCycleAnchorMs}`);
if (billingCycleAnchorMs === "now") return undefined;
const { insertCustomerProducts, updateCustomerProduct } = billingPlan.autumn;
@@ -40,7 +36,10 @@ export const billingPlanToNextCyclePreview = ({
cp(customerProduct).paid().recurring().hasActiveStatus().valid,
);
const prices = cusProductsToPrices({ cusProducts: customerProducts });
const prices = cusProductsToPrices({
cusProducts: customerProducts,
filters: { excludeOneOffPrices: true },
});
const smallestInterval = getSmallestInterval({ prices });
@@ -54,13 +53,14 @@ export const billingPlanToNextCyclePreview = ({
});
const autumnLineItems = customerProducts.flatMap((customerProduct) =>
cusProductToLineItems({
cusProduct: customerProduct,
nowMs: nextCycleStart,
billingCycleAnchorMs,
customerProductToLineItems({
ctx,
customerProduct: customerProduct,
billingContext: {
...billingContext,
currentEpochMs: nextCycleStart,
},
direction: "charge",
org: ctx.org,
logger: ctx.logger,
}),
);

View File

@@ -0,0 +1,38 @@
import { type BillingResponse, stripeToAtmnAmount } from "@autumn/shared";
import type { BillingContext } from "@/internal/billing/v2/billingContext";
import type { BillingResult } from "@/internal/billing/v2/types/billingResult";
export const billingResultToResponse = ({
billingContext,
billingResult,
}: {
billingContext: BillingContext;
billingResult: BillingResult;
}): BillingResponse => {
const { fullCustomer } = billingContext;
const customerId = fullCustomer.id ?? fullCustomer.internal_id;
const stripeInvoice = billingResult.stripe.stripeInvoice;
return {
customer_id: customerId,
entity_id: fullCustomer.entity?.id,
invoice: stripeInvoice
? {
status: stripeInvoice.status,
stripe_id: stripeInvoice.id,
total: stripeToAtmnAmount({
amount: stripeInvoice.total,
currency: stripeInvoice.currency,
}),
currency: stripeInvoice.currency,
hosted_invoice_url: stripeInvoice.hosted_invoice_url ?? null,
}
: undefined,
payment_url:
stripeInvoice?.status === "open" && stripeInvoice.hosted_invoice_url
? stripeInvoice.hosted_invoice_url
: null,
} satisfies BillingResponse;
};

View File

@@ -0,0 +1,132 @@
// TODO: import these once implemented
// import { prepaidPriceToLineItem } from "./lineItemBuilders/prepaidPriceToLineItem";
// import { allocatedPriceToLineItem } from "./lineItemBuilders/allocatedPriceToLineItem";
import {
addCusProductToCusEnt,
cusPriceToCusEnt,
type FullCusProduct,
fixedPriceToLineItem,
getLineItemBillingPeriod,
isConsumablePrice,
isFixedPrice,
isOneOffPrice,
type LineItem,
type LineItemContext,
orgToCurrency,
usagePriceToLineItem,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import type { BillingContext } from "../../billingContext";
import { getCurrentBillingCycleAnchorMs } from "../billingContext/getCurrentBillingCycleAnchorMs";
export type LineItemDirection = "charge" | "refund";
/**
* Generates line items for a customer product.
* - "charge" direction: positive amounts (for NEW product)
* - "credit" direction: negative amounts with "Unused" prefix (for OLD product)
*
* NOTE: Consumable (UsageInArrear) prices are NOT included - they're always
* positive charges for past usage and handled separately.
*/
export const customerProductToLineItems = ({
ctx,
customerProduct,
billingContext,
direction,
priceFilters,
}: {
ctx: AutumnContext;
customerProduct: FullCusProduct;
billingContext: BillingContext;
direction: "charge" | "refund";
priceFilters?: {
excludeOneOffPrices?: boolean;
};
}): LineItem[] => {
const { billingCycleAnchorMs, currentEpochMs } = billingContext;
const originalBillingCycleAnchorMs = getCurrentBillingCycleAnchorMs({
billingContext,
});
const anchorMs =
direction === "refund"
? originalBillingCycleAnchorMs
: billingCycleAnchorMs;
let lineItems: LineItem[] = [];
let filteredCustomerPrices = customerProduct.customer_prices;
if (priceFilters?.excludeOneOffPrices) {
filteredCustomerPrices = filteredCustomerPrices.filter(
(cp) => !isOneOffPrice(cp.price),
);
}
for (const cusPrice of filteredCustomerPrices) {
const price = cusPrice.price;
// Calculate billing period
const billingPeriod = getLineItemBillingPeriod({
anchorMs,
price,
nowMs: currentEpochMs,
});
// Build line item context
const context: LineItemContext = {
price,
product: customerProduct.product,
feature: undefined,
billingPeriod,
direction,
billingTiming: "in_advance",
now: currentEpochMs,
currency: orgToCurrency({ org: ctx.org }),
};
if (isFixedPrice(price)) {
lineItems.push(
fixedPriceToLineItem({
context,
quantity: customerProduct.quantity ?? 1,
}),
);
continue;
}
if (isConsumablePrice(price)) continue;
const cusEnt = cusPriceToCusEnt({
cusPrice,
cusEnts: customerProduct.customer_entitlements,
});
context.feature = cusEnt?.entitlement.feature;
if (!cusEnt) {
throw new Error(
`[cusProductToLineItems] No cusEnt found for cusPrice: ${cusPrice.id}`,
);
}
const cusEntWithCusProduct = addCusProductToCusEnt({
cusEnt,
cusProduct: customerProduct,
});
lineItems.push(
usagePriceToLineItem({
cusEnt: cusEntWithCusProduct,
context,
}),
);
}
lineItems = lineItems.filter((item) => item.amount !== 0);
return lineItems;
};

View File

@@ -1,8 +1,10 @@
import { generateId, InternalError, MetadataType } from "@autumn/shared";
import { addDays } from "date-fns";
import type Stripe from "stripe";
import { createStripeCli } from "@/external/connect/createStripeCli";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import type { BillingContext } from "@/internal/billing/v2/billingContext";
import type { StripeBillingStage } from "@/internal/billing/v2/types/autumnBillingPlan";
import type {
BillingPlan,
DeferredAutumnBillingPlanData,
@@ -16,30 +18,27 @@ export const insertMetadataFromBillingPlan = async ({
ctx,
billingPlan,
billingContext,
enableProductAfterInvoice,
invoiceActionRequired,
stripeInvoice,
expiresAt,
resumeAfter,
}: {
ctx: AutumnContext;
billingPlan: BillingPlan;
billingContext: BillingContext;
enableProductAfterInvoice?: boolean;
invoiceActionRequired?: boolean;
stripeInvoice?: Stripe.Invoice;
resumeAfter: StripeBillingStage;
expiresAt: number;
}) => {
const id = generateId("meta");
const type = enableProductAfterInvoice
? MetadataType.InvoiceCheckoutV2
: invoiceActionRequired
? MetadataType.InvoiceActionRequiredV2
: undefined;
const type = stripeInvoice ? MetadataType.DeferredInvoice : undefined;
const data = {
orgId: ctx.org.id,
env: ctx.env,
billingPlan,
billingContext,
resumeAfter,
} satisfies DeferredAutumnBillingPlanData;
const metadata = await MetadataService.insert({
@@ -49,6 +48,8 @@ export const insertMetadataFromBillingPlan = async ({
type,
stripe_invoice_id: stripeInvoice?.id,
data,
created_at: Date.now(),
expires_at: expiresAt ?? addDays(Date.now(), 10).getTime(),
},
});

View File

@@ -340,12 +340,21 @@ export const initHatchetWorker = async () => {
return;
}
console.log("Starting hatchet worker");
try {
console.log("Starting hatchet worker");
const worker = await hatchet.worker("hatchet-worker", {
workflows: [verifyCacheConsistencyWorkflow!],
});
const worker = await hatchet.worker("hatchet-worker", {
workflows: [verifyCacheConsistencyWorkflow!],
});
// Don't await - start() runs indefinitely and would block the rest of the code
worker.start();
// Don't await - start() runs indefinitely and would block the rest of the code
// But catch errors to prevent unhandled promise rejections from crashing
worker.start().catch((error) => {
console.error("Hatchet worker error (non-fatal):", error.message);
Sentry.captureException(error);
});
} catch (error) {
console.error("Failed to start hatchet worker", error);
Sentry.captureException(error);
}
};

View File

@@ -0,0 +1,434 @@
import { expect, test } from "bun:test";
import type { ApiCustomerV3 } from "@autumn/shared";
import { expectCustomerFeatureCorrect } from "@tests/billing/utils/expectCustomerFeatureCorrect";
import { expectCustomerInvoiceCorrect } from "@tests/billing/utils/expectCustomerInvoiceCorrect";
import { TestFeature } from "@tests/setup/v2Features";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
import { advanceTestClock } from "@tests/utils/stripeUtils";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import { FreeTrialDuration } from "autumn-js";
import chalk from "chalk";
// ═══════════════════════════════════════════════════════════════════════════════
// MIXED ONE-OFF AND RECURRING PRODUCT UPDATES
//
// These tests cover updates that involve both recurring and one-off components.
// Tests the interaction between recurring base prices and one-off prepaid items.
//
// Test scenarios:
// - Free product → Recurring product with one-off prepaid item
// - Recurring product → Same product + one-off prepaid item
// - Product with one-off prepaid → Updated product retaining one-off prepaid
// ═══════════════════════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 1: FREE → RECURRING + ONE-OFF
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("mixed: free product → recurring product with one-off prepaid item")}`, async () => {
const freeMessagesItem = items.monthlyMessages({ includedUsage: 100 });
const freeProduct = products.base({
items: [freeMessagesItem],
id: "free",
isDefault: true,
});
const { customerId, autumnV1 } = await initScenario({
customerId: "free-to-recurring-with-oneoff",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [freeProduct] }),
],
actions: [s.attach({ productId: freeProduct.id })],
});
// Update to recurring product with base price + one-off prepaid messages
const monthlyBasePrice = items.monthlyPrice({ price: 20 });
const dashboardItem = items.dashboard();
const oneOffMessagesItem = items.oneOffMessages({
includedUsage: 0,
billingUnits: 100,
price: 10,
});
const quantity = 200; // 2 packs of one-off messages
const updateParams = {
customer_id: customerId,
product_id: freeProduct.id,
items: [monthlyBasePrice, dashboardItem, oneOffMessagesItem],
options: [{ feature_id: TestFeature.Messages, quantity }],
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
// Base price ($20) + one-off messages (2 packs * $10 = $20) = $40
const expectedTotal = 20 + (quantity / 100) * 10;
expect(preview.total).toBe(expectedTotal);
await autumnV1.subscriptions.update(updateParams);
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Verify dashboard feature enabled
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Dashboard,
});
// Verify one-off messages quantity
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: quantity,
balance: quantity,
usage: 0,
});
// Should have 2 invoices: initial free attach + update with charges
await expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: preview.total,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 2: RECURRING → SAME RECURRING + ONE-OFF
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("mixed: recurring product → same product + one-off prepaid item")}`, async () => {
const dashboardItem = items.dashboard();
const monthlyBasePrice = items.monthlyPrice({ price: 20 });
const proProduct = products.base({
items: [dashboardItem, monthlyBasePrice],
id: "pro",
});
const { customerId, autumnV1 } = await initScenario({
customerId: "recurring-add-oneoff",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [proProduct] }),
],
actions: [s.attach({ productId: proProduct.id, timeout: 4000 })],
});
// Update to add one-off prepaid messages
const oneOffMessagesItem = items.oneOffMessages({
includedUsage: 0,
billingUnits: 100,
price: 10,
});
const quantity = 300; // 3 packs
const updateParams = {
customer_id: customerId,
product_id: proProduct.id,
items: [dashboardItem, monthlyBasePrice, oneOffMessagesItem],
options: [{ feature_id: TestFeature.Messages, quantity }],
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
// Should charge for one-off messages only
const expectedCharge = (quantity / 100) * 10; // 3 packs * $10
expect(preview.total).toBe(expectedCharge);
await autumnV1.subscriptions.update(updateParams);
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Dashboard should still be enabled
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Dashboard,
});
// Total messages = free monthly (100) + one-off quantity (300)
// Usage preserved from free messages
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: quantity,
balance: quantity,
usage: 0,
});
// Should have 2 invoices: initial pro attach + one-off purchase
await expectCustomerInvoiceCorrect({
customer,
count: 2,
latestTotal: preview.total,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 2B: RECURRING → SAME RECURRING + ONE-OFF (MID-CYCLE)
// One-off prices should NOT be prorated, so full price even mid-cycle
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("mixed: recurring + one-off mid-cycle (no proration on one-off)")}`, async () => {
const dashboardItem = items.dashboard();
const monthlyBasePrice = items.monthlyPrice({ price: 20 });
const proProduct = products.base({
items: [dashboardItem, monthlyBasePrice],
id: "pro",
});
const { customerId, autumnV1, ctx, testClockId } = await initScenario({
customerId: "recurring-oneoff-midcycle",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [proProduct] }),
],
actions: [s.attach({ productId: proProduct.id })],
});
// Advance 15 days (mid-cycle)
await advanceTestClock({
stripeCli: ctx.stripeCli,
testClockId: testClockId!,
numberOfDays: 15,
});
// Update to add one-off prepaid messages mid-cycle
const oneOffMessagesItem = items.oneOffMessages({
includedUsage: 0,
billingUnits: 100,
price: 10,
});
const quantity = 300; // 3 packs
const updateParams = {
customer_id: customerId,
product_id: proProduct.id,
items: [dashboardItem, monthlyBasePrice, oneOffMessagesItem],
options: [{ feature_id: TestFeature.Messages, quantity }],
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
// One-off prices should NOT be prorated - full price even mid-cycle
const expectedCharge = (quantity / 100) * 10; // 3 packs * $10 = $30
expect(preview.total).toBe(expectedCharge);
await autumnV1.subscriptions.update(updateParams);
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Dashboard should still be enabled
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Dashboard,
});
// One-off messages should be available
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: quantity,
balance: quantity,
usage: 0,
});
// Should have 2 invoices: initial pro attach + one-off purchase
await expectCustomerInvoiceCorrect({
customer,
count: 2,
latestTotal: preview.total,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 3: RECURRING WITH ONE-OFF → UPDATED RECURRING WITH SAME ONE-OFF
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("mixed: recurring with one-off → updated recurring retaining one-off")}`, async () => {
const dashboardItem = items.dashboard();
const oneOffMessagesItem = items.oneOffMessages({
includedUsage: 0,
billingUnits: 100,
price: 10,
});
const proProduct = products.pro({
items: [dashboardItem, oneOffMessagesItem],
id: "pro-v1",
});
const initialQuantity = 200; // 2 packs
const { customerId, autumnV1 } = await initScenario({
customerId: "recurring-with-oneoff-update",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [proProduct] }),
],
actions: [
s.attach({
productId: proProduct.id,
options: [
{ feature_id: TestFeature.Messages, quantity: initialQuantity },
],
}),
],
});
// Track some usage
const messagesUsed = 100;
await autumnV1.track(
{
customer_id: customerId,
feature_id: TestFeature.Messages,
value: messagesUsed,
},
{ timeout: 2000 },
);
// Update to new product version with:
// - Increased base price
// - Added feature (monthly words)
// - Same one-off prepaid messages (same quantity)
const monthlyBasePrice = items.monthlyPrice({ price: 30 }); // Increased from $20
const wordsItem = items.monthlyWords({ includedUsage: 50 }); // New feature
const updateParams = {
customer_id: customerId,
product_id: proProduct.id,
items: [monthlyBasePrice, dashboardItem, wordsItem, oneOffMessagesItem],
options: [{ feature_id: TestFeature.Messages, quantity: initialQuantity }],
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
// One-off items are charged again on update
// One-off messages: (200 / 100) * $10 = $20
// Base price increase: $30 - $20 = $10 (full cycle remaining)
const oneOffCharge = (initialQuantity / 100) * 10;
const basePriceDiff = 30 - 20;
const expectedTotal = oneOffCharge + basePriceDiff;
expect(preview.total).toBe(expectedTotal);
await autumnV1.subscriptions.update(updateParams);
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Dashboard should still be enabled
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Dashboard,
});
// New words feature should be available
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Words,
includedUsage: 50,
balance: 50,
usage: 0,
});
// One-off messages should retain usage and quantity
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: initialQuantity,
balance: initialQuantity - messagesUsed,
usage: messagesUsed,
});
// Should have 2 invoices: initial attach with one-off + update with price increase
await expectCustomerInvoiceCorrect({
customer,
count: 2,
latestTotal: preview.total,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 4: FREE → TRIAL WITH ONE-OFF ITEM
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("mixed: free → trial product with one-off prepaid item")}`, async () => {
const dashboardItem = items.dashboard();
const monthlyBasePrice = items.monthlyPrice({ price: 20 });
const oneOffMessagesItem = items.oneOffMessages({
includedUsage: 0,
billingUnits: 100,
price: 10,
});
const freeProduct = products.base({
id: "free-starter",
items: [dashboardItem],
isDefault: false,
});
// const proTrial = products.base({
// items: [dashboardItem, monthlyBasePrice, oneOffMessagesItem],
// id: "pro-trial-oneoff",
// trialDays: 14,
// });
const quantity = 200; // 2 packs
const { customerId, autumnV1 } = await initScenario({
customerId: "free-to-trial-oneoff",
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [freeProduct] }),
],
actions: [s.attach({ productId: freeProduct.id })],
});
// Update from free → trial product with one-off item
const updateParams = {
customer_id: customerId,
product_id: freeProduct.id,
items: [dashboardItem, monthlyBasePrice, oneOffMessagesItem],
options: [{ feature_id: TestFeature.Messages, quantity }],
free_trial: {
length: 7,
duration: FreeTrialDuration.Day,
unique_fingerprint: false,
card_required: true,
},
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
// One-off should be charged immediately, base price deferred until trial ends
const oneOffCharge = (quantity / 100) * 10;
expect(preview.total).toBe(oneOffCharge); // $20
await autumnV1.subscriptions.update(updateParams);
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Dashboard should be enabled
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Dashboard,
});
// One-off messages should be available
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: quantity,
balance: quantity,
usage: 0,
});
// Should have 1 invoice for the one-off charge
await expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: preview.total,
});
});

View File

@@ -0,0 +1,94 @@
import { test } from "bun:test";
import { ErrCode } from "@autumn/shared";
import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils.js";
import { items } from "@tests/utils/fixtures/items.js";
import { products } from "@tests/utils/fixtures/products.js";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
import chalk from "chalk";
// ═══════════════════════════════════════════════════════════════════════════════
// CUSTOM PLAN ERRORS - Same Configuration
// ═══════════════════════════════════════════════════════════════════════════════
// 1. Cannot update with same items (free product)
test.concurrent(`${chalk.yellowBright("error: custom plan same config (free)")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const freeProd = products.base({ items: [messagesItem] });
const { customerId, autumnV1 } = await initScenario({
customerId: "err-custom-same-free",
setup: [s.customer({}), s.products({ list: [freeProd] })],
actions: [s.attach({ productId: "base" })],
});
// Try to update with identical items - should fail
const updateParams = {
customer_id: customerId,
product_id: freeProd.id,
items: [messagesItem],
};
await expectAutumnError({
func: async () => {
await autumnV1.subscriptions.update(updateParams);
},
});
});
// 2. Cannot update with same items (paid product)
test.concurrent(`${chalk.yellowBright("error: custom plan same config (paid)")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const priceItem = items.monthlyPrice({ price: 20 });
const pro = products.base({ items: [messagesItem, priceItem] });
const { customerId, autumnV1 } = await initScenario({
customerId: "err-custom-same-paid",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [s.attach({ productId: "pro" })],
});
// Try to update with identical items - should fail
const updateParams = {
customer_id: customerId,
product_id: pro.id,
items: [messagesItem, priceItem],
};
await expectAutumnError({
func: async () => {
await autumnV1.subscriptions.update(updateParams);
},
});
});
// 3. Cannot update with same items (multiple features)
test.concurrent(`${chalk.yellowBright("error: custom plan same config (multi-feature)")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const creditsItem = items.monthlyCredits({ includedUsage: 50 });
const prod = products.base({
items: [messagesItem, creditsItem],
id: "multi-feature",
});
const { customerId, autumnV1 } = await initScenario({
customerId: "err-custom-same-multi",
setup: [s.customer({}), s.products({ list: [prod] })],
actions: [s.attach({ productId: "multi-feature" })],
});
// Try to update with identical items - should fail
const updateParams = {
customer_id: customerId,
product_id: prod.id,
items: [messagesItem, creditsItem],
};
await expectAutumnError({
func: async () => {
await autumnV1.subscriptions.update(updateParams);
},
});
});

View File

@@ -169,3 +169,45 @@ test.concurrent(`${chalk.yellowBright("error: one-off to paid recurring transiti
},
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// INVOICE MODE ERRORS
// ═══════════════════════════════════════════════════════════════════════════════
// 5. Cannot use invoice mode when there's no billing change
test.concurrent(`${chalk.yellowBright("error: invoice mode with no billing change")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const priceItem = items.monthlyPrice({ price: 20 });
const pro = products.base({
id: "pro",
items: [messagesItem, priceItem],
});
const { customerId, autumnV1 } = await initScenario({
customerId: "err-inv-no-billing",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [s.attach({ productId: pro.id })],
});
// Add boolean feature - no price change
const adminRightsItem = items.adminRights();
const updateParams = {
customer_id: customerId,
product_id: pro.id,
items: [messagesItem, priceItem, adminRightsItem],
invoice: true,
enable_product_immediately: true,
finalize_invoice: false,
};
await expectAutumnError({
errCode: ErrCode.InvalidRequest,
func: async () => {
await autumnV1.subscriptions.update(updateParams);
},
});
});

View File

@@ -0,0 +1,253 @@
import { test } from "bun:test";
import { TestFeature } from "@tests/setup/v2Features.js";
import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils.js";
import { items } from "@tests/utils/fixtures/items.js";
import { products } from "@tests/utils/fixtures/products.js";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
import chalk from "chalk";
// ═══════════════════════════════════════════════════════════════════════════════
// ONE-OFF ERRORS - Price/Billing Changes Not Allowed
// ═══════════════════════════════════════════════════════════════════════════════
// 1. Error: changing base price on one-off product
test.concurrent(`${chalk.yellowBright("error: one-off changing base price")}`, async () => {
const oneOffPriceItem = items.oneOffPrice({ price: 50 });
const oneOffProd = products.base({ items: [oneOffPriceItem], id: "oneoff" });
const { customerId, autumnV1 } = await initScenario({
customerId: "err-oneoff-base-price",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [oneOffProd] }),
],
actions: [s.attach({ productId: "oneoff" })],
});
// Try to change base price - should fail
const newPriceItem = items.oneOffPrice({ price: 100 });
const updateParams = {
customer_id: customerId,
product_id: oneOffProd.id,
items: [newPriceItem],
};
await expectAutumnError({
func: async () => {
await autumnV1.subscriptions.update(updateParams);
},
});
});
// 2. Error: changing feature price on one-off prepaid product
test.concurrent(`${chalk.yellowBright("error: one-off changing feature price")}`, async () => {
const oneOffMessagesItem = items.oneOffMessages({
price: 10,
billingUnits: 100,
});
const oneOffProd = products.base({
items: [oneOffMessagesItem],
id: "oneoff-feature",
});
const { customerId, autumnV1 } = await initScenario({
customerId: "err-oneoff-feature-price",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [oneOffProd] }),
],
actions: [
s.attach({
productId: "oneoff-feature",
options: [{ feature_id: TestFeature.Messages, quantity: 1 }],
}),
],
});
// Try to change feature price - should fail
const newMessagesItem = items.oneOffMessages({
price: 20,
billingUnits: 100,
});
const updateParams = {
customer_id: customerId,
product_id: oneOffProd.id,
items: [newMessagesItem],
};
await expectAutumnError({
func: async () => {
await autumnV1.subscriptions.update(updateParams);
},
});
});
// 3. Error: changing billing units on one-off prepaid product
test.concurrent(`${chalk.yellowBright("error: one-off changing billing units")}`, async () => {
const oneOffMessagesItem = items.oneOffMessages({
price: 10,
billingUnits: 100,
});
const oneOffProd = products.base({
items: [oneOffMessagesItem],
id: "oneoff-billing",
});
const { customerId, autumnV1 } = await initScenario({
customerId: "err-oneoff-billing-units",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [oneOffProd] }),
],
actions: [
s.attach({
productId: "oneoff-billing",
options: [{ feature_id: TestFeature.Messages, quantity: 1 }],
}),
],
});
// Try to change billing units - should fail
const newMessagesItem = items.oneOffMessages({
price: 10,
billingUnits: 200,
});
const updateParams = {
customer_id: customerId,
product_id: oneOffProd.id,
items: [newMessagesItem],
};
await expectAutumnError({
func: async () => {
await autumnV1.subscriptions.update(updateParams);
},
});
});
// 4. Error: changing both price and billing units on one-off
test.concurrent(`${chalk.yellowBright("error: one-off changing price and billing units")}`, async () => {
const oneOffMessagesItem = items.oneOffMessages({
price: 10,
billingUnits: 100,
});
const oneOffProd = products.base({
items: [oneOffMessagesItem],
id: "oneoff-both",
});
const { customerId, autumnV1 } = await initScenario({
customerId: "err-oneoff-both",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [oneOffProd] }),
],
actions: [
s.attach({
productId: "oneoff-both",
options: [{ feature_id: TestFeature.Messages, quantity: 1 }],
}),
],
});
// Try to change both price and billing units - should fail
const newMessagesItem = items.oneOffMessages({
price: 20,
billingUnits: 200,
});
const updateParams = {
customer_id: customerId,
product_id: oneOffProd.id,
items: [newMessagesItem],
};
await expectAutumnError({
func: async () => {
await autumnV1.subscriptions.update(updateParams);
},
});
});
// 5. Error: updating quantity of one-off item on recurring product
test.concurrent(`${chalk.yellowBright("error: one-off item quantity update on recurring product")}`, async () => {
const billingUnits = 100;
const oneOffMessagesItem = items.oneOffMessages({
price: 10,
billingUnits,
});
const recurringProduct = products.base({
items: [items.monthlyPrice({ price: 20 }), oneOffMessagesItem],
id: "recurring-with-oneoff",
});
const { customerId, autumnV1 } = await initScenario({
customerId: "err-oneoff-qty-recurring",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [recurringProduct] }),
],
actions: [
s.attach({
productId: recurringProduct.id,
options: [
{ feature_id: TestFeature.Messages, quantity: 1 * billingUnits },
],
}),
],
});
// Try to update quantity of one-off messages - should fail
await expectAutumnError({
func: async () => {
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: recurringProduct.id,
options: [
{ feature_id: TestFeature.Messages, quantity: 2 * billingUnits },
],
});
},
});
});
// 6. Error: removing trial and adding one-off item
test.concurrent(`${chalk.yellowBright("error: remove trial and add one-off item")}`, async () => {
const dashboardItem = items.dashboard();
const monthlyBasePrice = items.monthlyPrice({ price: 20 });
const proTrial = products.base({
items: [dashboardItem, monthlyBasePrice],
id: "pro-trial",
trialDays: 7,
});
const { customerId, autumnV1 } = await initScenario({
customerId: "err-trial-remove-add-oneoff",
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [proTrial] }),
],
actions: [s.attach({ productId: proTrial.id })],
});
// Try to remove trial and add one-off prepaid messages - should fail
const oneOffMessagesItem = items.oneOffMessages({
includedUsage: 0,
billingUnits: 100,
price: 10,
});
const quantity = 300;
await expectAutumnError({
func: async () => {
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: proTrial.id,
free_trial: null,
items: [dashboardItem, monthlyBasePrice, oneOffMessagesItem],
options: [{ feature_id: TestFeature.Messages, quantity }],
});
},
});
});

View File

@@ -48,7 +48,6 @@ test.concurrent(`${chalk.yellowBright("p2p-trial: remove trial while running")}`
const updateParams = {
customer_id: customerId,
product_id: proTrial.id,
items: [messagesItem, items.monthlyPrice()],
free_trial: null,
};

View File

@@ -0,0 +1,585 @@
import { expect, test } from "bun:test";
import { type ApiCustomerV3, atmnToStripeAmount } from "@autumn/shared";
import { expectCustomerFeatureCorrect } from "@tests/billing/utils/expectCustomerFeatureCorrect";
import { expectCustomerInvoiceCorrect } from "@tests/billing/utils/expectCustomerInvoiceCorrect";
import { expectProductActive } from "@tests/billing/utils/expectCustomerProductCorrect";
import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect";
import { TestFeature } from "@tests/setup/v2Features.js";
import { items } from "@tests/utils/fixtures/items.js";
import { products } from "@tests/utils/fixtures/products.js";
import { completeInvoiceCheckout } from "@tests/utils/stripeUtils/completeInvoiceCheckout";
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
import chalk from "chalk";
/**
* Invoice Mode Tests - Deferred Plan Activation
*
* These tests verify invoice mode with deferred plan activation:
* - invoice: true
* - enable_product_immediately: false (plan activates after payment)
* - finalize_invoice: true (invoice is finalized and sent)
*
* Cases:
* - Case 1: Increase price (finalized, deferred)
* - Case 2: Decrease price (finalized, deferred)
* - Case 3: Free → paid (finalized, deferred)
* - Case 4: Trial removal (finalized, deferred)
* - Case 5: Increase quantity (finalized, deferred)
* - Case 6: Increase price (draft, deferred) - finalize_invoice: false
* - Case 7: Free → paid (draft, deferred) - finalize_invoice: false
*/
// ═══════════════════════════════════════════════════════════════════════════════
// CASE 1: INCREASE PRICE - FINALIZED INVOICE, DEFERRED ACTIVATION
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("invoice-deferred: increase price (finalized, deferred)")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const priceItem = items.monthlyPrice({ price: 20 });
const pro = products.base({
id: "pro",
items: [messagesItem, priceItem],
});
const { customerId, autumnV1 } = await initScenario({
customerId: "inv-def-increase-fin",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [s.attach({ productId: pro.id })],
});
// Increase price from $20 to $30 AND increase messages from 100 to 200
const newMessagesItem = items.monthlyMessages({ includedUsage: 200 });
const newPriceItem = items.monthlyPrice({ price: 30 });
const updateParams = {
customer_id: customerId,
product_id: pro.id,
items: [newMessagesItem, newPriceItem],
invoice: true,
enable_product_immediately: false,
finalize_invoice: true,
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
// Should charge $10 difference ($30 - $20)
expect(preview.total).toBe(10);
const result = await autumnV1.subscriptions.update(updateParams);
const clonedResult = structuredClone(result);
expect(clonedResult).toMatchObject({
payment_url: expect.any(String),
invoice: {
status: "open",
stripe_id: expect.any(String),
total: preview.total,
hosted_invoice_url: expect.any(String),
},
action_required: undefined,
});
const stripeInvoice = await ctx.stripeCli.invoices.retrieve(
result.invoice.stripe_id,
);
expect(stripeInvoice.status).toBe("open");
expect(stripeInvoice.total).toBe(
atmnToStripeAmount({ amount: preview.total, currency: "usd" }),
);
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerInvoiceCorrect({
customer,
count: 2, // Initial attach + update
latestTotal: preview.total,
latestStatus: "open",
});
// Before payment - balance should still be 100 (original)
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
balance: 100,
});
await completeInvoiceCheckout({
url: result.payment_url,
});
const customerAfterPayment =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
expectProductActive({
customer: customerAfterPayment,
productId: pro.id,
});
// After payment - balance should be 200 (new plan)
expectCustomerFeatureCorrect({
customer: customerAfterPayment,
featureId: TestFeature.Messages,
balance: 200,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// CASE 2: DECREASE PRICE - FINALIZED INVOICE, DEFERRED ACTIVATION
// Note: Invoice is auto-paid by Stripe because the total is negative (credit)
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("invoice-deferred: decrease price (finalized, deferred)")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const priceItem = items.monthlyPrice({ price: 30 });
const pro = products.base({
id: "pro",
items: [messagesItem, priceItem],
});
const { customerId, autumnV1 } = await initScenario({
customerId: "inv-def-decrease-fin",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [s.attach({ productId: pro.id })],
});
// Decrease price from $30 to $20 AND increase messages from 100 to 200
const newMessagesItem = items.monthlyMessages({ includedUsage: 200 });
const newPriceItem = items.monthlyPrice({ price: 20 });
const updateParams = {
customer_id: customerId,
product_id: pro.id,
items: [newMessagesItem, newPriceItem],
invoice: true,
enable_product_immediately: false,
finalize_invoice: true,
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
// Should be -$10 credit (price decrease)
expect(preview.total).toEqual(-10);
const result = await autumnV1.subscriptions.update(updateParams);
const clonedResult = structuredClone(result);
expect(clonedResult).toMatchObject({
payment_url: null,
invoice: expect.objectContaining({
status: "paid",
stripe_id: expect.any(String),
total: preview.total,
hosted_invoice_url: expect.any(String),
}),
});
expect(clonedResult.action_required).toBeUndefined();
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerInvoiceCorrect({
customer,
count: 2, // Initial attach + update
latestTotal: preview.total,
latestStatus: "paid",
});
// After auto-paid invoice - balance should be 200 (new plan)
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
balance: 200,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// CASE 3: FREE → PAID - FINALIZED INVOICE, DEFERRED ACTIVATION
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("invoice-deferred: free → paid (finalized, deferred)")}`, async () => {
const dashboardItem = items.dashboard();
const freeProduct = products.base({
id: "free",
items: [dashboardItem],
});
const { customerId, autumnV1 } = await initScenario({
customerId: "inv-def-free-paid-fin",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [freeProduct] }),
],
actions: [s.attach({ productId: freeProduct.id })],
});
// Update from free to paid with monthly price
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const priceItem = items.monthlyPrice({ price: 20 });
const updateParams = {
customer_id: customerId,
product_id: freeProduct.id,
items: [dashboardItem, messagesItem, priceItem],
invoice: true,
enable_product_immediately: false,
finalize_invoice: true,
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
// Should charge $20 (full price for new paid plan)
expect(preview.total).toBe(20);
const result = await autumnV1.subscriptions.update(updateParams);
const clonedResult = structuredClone(result);
expect(clonedResult).toMatchObject({
payment_url: expect.any(String),
invoice: expect.objectContaining({
status: "open",
stripe_id: expect.any(String),
total: preview.total,
hosted_invoice_url: expect.any(String),
}),
});
const stripeInvoice = await ctx.stripeCli.invoices.retrieve(
result.invoice.stripe_id,
);
expect(stripeInvoice.status).toBe("open");
expect(stripeInvoice.total).toBe(
atmnToStripeAmount({ amount: preview.total, currency: "usd" }),
);
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerInvoiceCorrect({
customer,
count: 1, // Only the update invoice (free attach has no invoice)
latestTotal: preview.total,
latestStatus: "open",
});
// Before payment - messages feature should not exist (free product)
expect(customer.features?.[TestFeature.Messages]).toBeUndefined();
await completeInvoiceCheckout({
url: result.payment_url,
});
const customerAfterPayment =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
expectProductActive({
customer: customerAfterPayment,
productId: freeProduct.id,
});
// After payment - balance should be 100 (new paid plan)
expectCustomerFeatureCorrect({
customer: customerAfterPayment,
featureId: TestFeature.Messages,
balance: 100,
});
expectCustomerInvoiceCorrect({
customer: customerAfterPayment,
count: 1, // Only the update invoice (free attach has no invoice)
latestTotal: preview.total,
latestStatus: "paid",
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// CASE 4: TRIAL REMOVAL - FINALIZED INVOICE, DEFERRED ACTIVATION
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("invoice-deferred: remove trial (finalized, deferred)")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const priceItem = items.monthlyPrice({ price: 20 });
const proTrial = products.base({
id: "pro-trial",
items: [messagesItem, priceItem],
trialDays: 7,
});
const { customerId, autumnV1 } = await initScenario({
customerId: "inv-def-trial-fin",
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [proTrial] }),
],
actions: [s.attach({ productId: proTrial.id })],
});
// Remove trial by passing free_trial: null
const updateParams = {
customer_id: customerId,
product_id: proTrial.id,
free_trial: null,
invoice: true,
enable_product_immediately: false,
finalize_invoice: true,
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
// Should charge full price ($20) since trial is being removed
expect(preview.total).toBe(20);
const result = await autumnV1.subscriptions.update(updateParams);
const clonedResult = structuredClone(result);
expect(clonedResult.invoice).toBeDefined();
expect(clonedResult.invoice).toMatchObject({
status: "open",
stripe_id: expect.any(String),
total: preview.total,
hosted_invoice_url: expect.any(String),
});
const stripeInvoice = await ctx.stripeCli.invoices.retrieve(
result.invoice.stripe_id,
);
expect(stripeInvoice.status).toBe("open");
expect(stripeInvoice.total).toBe(
atmnToStripeAmount({ amount: preview.total, currency: "usd" }),
);
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerInvoiceCorrect({
customer,
count: 2, // Initial trial attach ($0) + update
latestTotal: preview.total,
latestStatus: "open",
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// CASE 5: INCREASE QUANTITY - FINALIZED INVOICE, DEFERRED ACTIVATION
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("invoice-deferred: increase quantity (finalized, deferred)")}`, async () => {
const billingUnits = 12;
const pricePerUnit = 8;
const prepaidItem = items.prepaid({
featureId: TestFeature.Messages,
billingUnits,
price: pricePerUnit,
});
const product = products.base({
id: "prepaid",
items: [prepaidItem],
});
const { customerId, autumnV1 } = await initScenario({
customerId: "inv-def-qty-fin",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [product] }),
],
actions: [
s.attach({
productId: product.id,
options: [
{ feature_id: TestFeature.Messages, quantity: 10 * billingUnits },
],
}),
],
});
const updateParams = {
customer_id: customerId,
product_id: product.id,
options: [
{ feature_id: TestFeature.Messages, quantity: 20 * billingUnits },
],
invoice: true,
enable_product_immediately: false,
finalize_invoice: true,
};
// Preview the upgrade
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
// Should charge for +10 units (10 * $8 = $80)
expect(preview.total).toBe(10 * pricePerUnit);
const result = await autumnV1.subscriptions.update(updateParams);
const clonedResult = structuredClone(result);
expect(clonedResult.invoice).toBeDefined();
expect(clonedResult.invoice).toMatchObject({
status: "open",
stripe_id: expect.any(String),
total: preview.total,
hosted_invoice_url: expect.any(String),
});
const stripeInvoice = await ctx.stripeCli.invoices.retrieve(
result.invoice.stripe_id,
);
expect(stripeInvoice.status).toBe("open");
expect(stripeInvoice.total).toBe(
atmnToStripeAmount({ amount: preview.total, currency: "usd" }),
);
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerInvoiceCorrect({
customer,
count: 2, // Initial attach + update
latestTotal: preview.total,
latestStatus: "open",
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// CASE 6: INCREASE PRICE - DRAFT INVOICE, DEFERRED ACTIVATION
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("invoice-deferred: increase price (draft, deferred)")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const priceItem = items.monthlyPrice({ price: 20 });
const pro = products.base({
id: "pro",
items: [messagesItem, priceItem],
});
const { customerId, autumnV1 } = await initScenario({
customerId: "inv-def-increase-draft",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [s.attach({ productId: pro.id })],
});
// Increase price from $20 to $30
const newPriceItem = items.monthlyPrice({ price: 30 });
const updateParams = {
customer_id: customerId,
product_id: pro.id,
items: [messagesItem, newPriceItem],
invoice: true,
enable_product_immediately: false,
finalize_invoice: false,
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
// Should charge $10 difference ($30 - $20)
expect(preview.total).toBe(10);
const result = await autumnV1.subscriptions.update(updateParams);
const clonedResult = structuredClone(result);
expect(clonedResult.invoice).toBeDefined();
expect(clonedResult.invoice).toMatchObject({
status: "draft",
stripe_id: expect.any(String),
total: preview.total,
hosted_invoice_url: null,
});
const stripeInvoice = await ctx.stripeCli.invoices.retrieve(
result.invoice.stripe_id,
);
expect(stripeInvoice.status).toBe("draft");
expect(stripeInvoice.total).toBe(
atmnToStripeAmount({ amount: preview.total, currency: "usd" }),
);
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerInvoiceCorrect({
customer,
count: 2, // Initial attach + update
latestTotal: preview.total,
latestStatus: "draft",
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// CASE 7: FREE → PAID - DRAFT INVOICE, DEFERRED ACTIVATION
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("invoice-deferred: free → paid (draft, deferred)")}`, async () => {
const dashboardItem = items.dashboard();
const freeProduct = products.base({
id: "free",
items: [dashboardItem],
});
const { customerId, autumnV1 } = await initScenario({
customerId: "inv-def-free-paid-draft",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [freeProduct] }),
],
actions: [s.attach({ productId: freeProduct.id })],
});
// Update from free to paid with monthly price
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const priceItem = items.monthlyPrice({ price: 20 });
const updateParams = {
customer_id: customerId,
product_id: freeProduct.id,
items: [dashboardItem, messagesItem, priceItem],
invoice: true,
enable_product_immediately: false,
finalize_invoice: false,
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
// Should charge $20 (full price for new paid plan)
expect(preview.total).toBe(20);
const result = await autumnV1.subscriptions.update(updateParams);
const clonedResult = structuredClone(result);
expect(clonedResult.invoice).toBeDefined();
expect(clonedResult.invoice).toMatchObject({
status: "draft",
stripe_id: expect.any(String),
total: preview.total,
hosted_invoice_url: null,
});
const stripeInvoice = await ctx.stripeCli.invoices.retrieve(
result.invoice.stripe_id,
);
expect(stripeInvoice.status).toBe("draft");
expect(stripeInvoice.total).toBe(
atmnToStripeAmount({ amount: preview.total, currency: "usd" }),
);
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerInvoiceCorrect({
customer,
count: 1, // Only the update invoice (free attach has no invoice)
latestTotal: preview.total,
latestStatus: "draft",
});
});

View File

@@ -0,0 +1,354 @@
import { expect, test } from "bun:test";
import { type ApiCustomerV3, atmnToStripeAmount } from "@autumn/shared";
import { expectCustomerInvoiceCorrect } from "@tests/billing/utils/expectCustomerInvoiceCorrect";
import { items } from "@tests/utils/fixtures/items.js";
import { products } from "@tests/utils/fixtures/products.js";
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
import chalk from "chalk";
/**
* Invoice Mode Tests for Price Updates
*
* These tests verify invoice mode configurations when updating subscription prices:
* - Case 1: Increase price with draft invoice, immediate entitlements
* - Case 2: Decrease price with draft invoice, immediate entitlements
* - Case 2B: Free → paid with draft invoice, immediate entitlements
* - Case 3: Trial removal with draft invoice, immediate entitlements
* - Case 4: Increase price with finalized invoice, immediate entitlements
*/
// ═══════════════════════════════════════════════════════════════════════════════
// CASE 1: INCREASE PRICE - DRAFT INVOICE, IMMEDIATE ENTITLEMENTS
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("invoice-mode: increase price (draft, immediate)")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const priceItem = items.monthlyPrice({ price: 20 });
const pro = products.base({
id: "pro",
items: [messagesItem, priceItem],
});
const { customerId, autumnV1 } = await initScenario({
customerId: "inv-increase-draft",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [s.attach({ productId: pro.id })],
});
// Increase price from $20 to $30
const newPriceItem = items.monthlyPrice({ price: 30 });
const updateParams = {
customer_id: customerId,
product_id: pro.id,
items: [messagesItem, newPriceItem],
invoice: true,
enable_product_immediately: true,
finalize_invoice: false,
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
// Should charge $10 difference ($30 - $20)
expect(preview.total).toBe(10);
const result = await autumnV1.subscriptions.update(updateParams);
const clonedResult = structuredClone(result);
expect(clonedResult.invoice).toBeDefined();
expect(clonedResult.invoice).toMatchObject({
status: "draft",
stripe_id: expect.any(String),
total: preview.total,
hosted_invoice_url: null,
});
const stripeInvoice = await ctx.stripeCli.invoices.retrieve(
result.invoice.stripe_id,
);
expect(stripeInvoice.status).toBe("draft");
expect(stripeInvoice.total).toBe(
atmnToStripeAmount({ amount: preview.total, currency: "usd" }),
);
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerInvoiceCorrect({
customer,
count: 2, // Initial attach + update
latestTotal: preview.total,
latestStatus: "draft",
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// CASE 2: DECREASE PRICE - DRAFT INVOICE, IMMEDIATE ENTITLEMENTS
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("invoice-mode: decrease price (draft, immediate)")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const priceItem = items.monthlyPrice({ price: 30 });
const pro = products.base({
id: "pro",
items: [messagesItem, priceItem],
});
const { customerId, autumnV1 } = await initScenario({
customerId: "inv-decrease-draft",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [s.attach({ productId: pro.id })],
});
// Decrease price from $30 to $20
const newPriceItem = items.monthlyPrice({ price: 20 });
const updateParams = {
customer_id: customerId,
product_id: pro.id,
items: [messagesItem, newPriceItem],
invoice: true,
enable_product_immediately: true,
finalize_invoice: false,
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
// Should be $0 or credit (price decrease)
expect(preview.total).toEqual(-10);
const result = await autumnV1.subscriptions.update(updateParams);
const clonedResult = structuredClone(result);
expect(clonedResult.invoice).toBeDefined();
expect(clonedResult.invoice).toMatchObject({
status: "draft",
stripe_id: expect.any(String),
total: preview.total,
hosted_invoice_url: null,
});
const stripeInvoice = await ctx.stripeCli.invoices.retrieve(
result.invoice.stripe_id,
);
expect(stripeInvoice.status).toBe("draft");
expect(stripeInvoice.total).toBe(
atmnToStripeAmount({ amount: preview.total, currency: "usd" }),
);
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerInvoiceCorrect({
customer,
count: 2, // Initial attach + update
latestTotal: preview.total,
latestStatus: "draft",
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// CASE 2B: FREE → PAID - DRAFT INVOICE, IMMEDIATE ENTITLEMENTS
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("invoice-mode: free → paid (draft, immediate)")}`, async () => {
const dashboardItem = items.dashboard();
const freeProduct = products.base({
id: "free",
items: [dashboardItem],
});
const { customerId, autumnV1 } = await initScenario({
customerId: "inv-free-to-paid-draft",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [freeProduct] }),
],
actions: [s.attach({ productId: freeProduct.id })],
});
// Update from free to paid with monthly price
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const priceItem = items.monthlyPrice({ price: 20 });
const updateParams = {
customer_id: customerId,
product_id: freeProduct.id,
items: [dashboardItem, messagesItem, priceItem],
invoice: true,
enable_product_immediately: true,
finalize_invoice: false,
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
// Should charge $20 (full price for new paid plan)
expect(preview.total).toBe(20);
const result = await autumnV1.subscriptions.update(updateParams);
const clonedResult = structuredClone(result);
expect(clonedResult.invoice).toBeDefined();
expect(clonedResult.invoice).toMatchObject({
status: "draft",
stripe_id: expect.any(String),
total: preview.total,
hosted_invoice_url: null,
});
const stripeInvoice = await ctx.stripeCli.invoices.retrieve(
result.invoice.stripe_id,
);
expect(stripeInvoice.status).toBe("draft");
expect(stripeInvoice.total).toBe(
atmnToStripeAmount({ amount: preview.total, currency: "usd" }),
);
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerInvoiceCorrect({
customer,
count: 1, // Only the update invoice (free attach has no invoice)
latestTotal: preview.total,
latestStatus: "draft",
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// CASE 3: TRIAL REMOVAL - DRAFT INVOICE, IMMEDIATE ENTITLEMENTS
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("invoice-mode: remove trial (draft, immediate)")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const priceItem = items.monthlyPrice({ price: 20 });
const proTrial = products.base({
id: "pro-trial",
items: [messagesItem, priceItem],
trialDays: 7,
});
const { customerId, autumnV1 } = await initScenario({
customerId: "inv-remove-trial-draft",
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [proTrial] }),
],
actions: [s.attach({ productId: proTrial.id })],
});
// Remove trial by passing free_trial: null
const updateParams = {
customer_id: customerId,
product_id: proTrial.id,
free_trial: null,
invoice: true,
enable_product_immediately: true,
finalize_invoice: false,
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
// Should charge full price ($20) since trial is being removed
expect(preview.total).toBe(20);
const result = await autumnV1.subscriptions.update(updateParams);
const clonedResult = structuredClone(result);
expect(clonedResult.invoice).toBeDefined();
expect(clonedResult.invoice).toMatchObject({
status: "draft",
stripe_id: expect.any(String),
total: preview.total,
hosted_invoice_url: null,
});
const stripeInvoice = await ctx.stripeCli.invoices.retrieve(
result.invoice.stripe_id,
);
expect(stripeInvoice.status).toBe("draft");
expect(stripeInvoice.total).toBe(
atmnToStripeAmount({ amount: preview.total, currency: "usd" }),
);
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerInvoiceCorrect({
customer,
count: 2, // Initial trial attach ($0) + update
latestTotal: preview.total,
latestStatus: "draft",
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// CASE 4: INCREASE PRICE - FINALIZED INVOICE, IMMEDIATE ENTITLEMENTS
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("invoice-mode: increase price (finalized, immediate)")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const priceItem = items.monthlyPrice({ price: 20 });
const pro = products.base({
id: "pro",
items: [messagesItem, priceItem],
});
const { customerId, autumnV1 } = await initScenario({
customerId: "inv-increase-finalized",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [s.attach({ productId: pro.id })],
});
// Increase price from $20 to $30
const newPriceItem = items.monthlyPrice({ price: 30 });
const updateParams = {
customer_id: customerId,
product_id: pro.id,
items: [messagesItem, newPriceItem],
invoice: true,
enable_product_immediately: true,
finalize_invoice: true,
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
// Should charge $10 difference ($30 - $20)
expect(preview.total).toBe(10);
const result = await autumnV1.subscriptions.update(updateParams);
const clonedResult = structuredClone(result);
expect(clonedResult.invoice).toBeDefined();
expect(clonedResult.invoice).toMatchObject({
status: "open",
stripe_id: expect.any(String),
total: preview.total,
hosted_invoice_url: expect.any(String),
});
const stripeInvoice = await ctx.stripeCli.invoices.retrieve(
result.invoice.stripe_id,
);
expect(stripeInvoice.status).toBe("open");
expect(stripeInvoice.total).toBe(
atmnToStripeAmount({ amount: preview.total, currency: "usd" }),
);
expect(stripeInvoice.hosted_invoice_url).toBeTruthy();
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerInvoiceCorrect({
customer,
count: 2, // Initial attach + update
latestTotal: preview.total,
latestStatus: "open",
});
});

View File

@@ -26,6 +26,16 @@ const dashboard = () =>
isBoolean: true,
});
/**
* Boolean feature - admin rights access
* @returns AdminRights feature item
*/
const adminRights = () =>
constructFeatureItem({
featureId: TestFeature.AdminRights,
isBoolean: true,
});
// ═══════════════════════════════════════════════════════════════════
// FREE METERED (included usage, resets monthly)
// ═══════════════════════════════════════════════════════════════════
@@ -270,6 +280,16 @@ const annualPrice = ({ price = 200 }: { price?: number } = {}) =>
interval: BillingInterval.Year,
});
/**
* One-off base price item (no recurring charges)
* @param price - One-time price (default: 50)
*/
const oneOffPrice = ({ price = 50 }: { price?: number } = {}) =>
constructPriceItem({
price,
interval: null,
});
// ═══════════════════════════════════════════════════════════════════
// EXPORT
// ═══════════════════════════════════════════════════════════════════
@@ -277,6 +297,7 @@ const annualPrice = ({ price = 200 }: { price?: number } = {}) =>
export const items = {
// Boolean
dashboard,
adminRights,
// Free metered
monthlyMessages,
@@ -303,4 +324,5 @@ export const items = {
// Base prices
monthlyPrice,
annualPrice,
oneOffPrice,
} as const;

View File

@@ -13,20 +13,31 @@ import {
* @param items - Product items (features)
* @param id - Product ID (default: "base")
* @param isDefault - Whether this is a default product (default: false)
* @param trialDays - Optional number of trial days
*/
const base = ({
items,
id = "base",
isDefault = false,
isAddOn = false,
trialDays,
}: {
items: ProductItem[];
id?: string;
isDefault?: boolean;
isAddOn?: boolean;
trialDays?: number;
}): ProductV2 => ({
...constructRawProduct({ id, items, isAddOn }),
is_default: isDefault,
...(trialDays && {
free_trial: {
length: trialDays,
duration: FreeTrialDuration.Day,
unique_fingerprint: false,
card_required: true,
},
}),
});
/**

View File

@@ -14,6 +14,8 @@ export const completeInvoiceCheckout = async ({
url: string;
isLocal?: boolean;
}) => {
console.log("[completeInvoiceCheckout] Starting invoice checkout...");
let browser: Browser;
browser = await puppeteer.launch({
@@ -183,6 +185,7 @@ export const completeInvoiceCheckout = async ({
}
}
await timeout(20000);
console.log("[completeInvoiceCheckout] Invoice checkout completed");
} finally {
// always close browser
await browser.close();

View File

@@ -0,0 +1,35 @@
import { z } from "zod/v4";
export const PaymentFailureCodeEnum = z.enum([
"3ds_required",
"payment_method_required",
"payment_failed",
]);
export type PaymentFailureCode = z.infer<typeof PaymentFailureCodeEnum>;
export const BillingResponseSchema = z.object({
customer_id: z.string(),
entity_id: z.string().optional(),
invoice: z
.object({
status: z.string().nullable(),
stripe_id: z.string(),
total: z.number(),
currency: z.string(),
hosted_invoice_url: z.string().nullable(),
})
.optional(),
payment_url: z.string().nullable(),
required_action: z
.object({
code: PaymentFailureCodeEnum,
reason: z.string(),
})
.optional(),
});
export type BillingResponse = z.infer<typeof BillingResponseSchema>;

View File

@@ -10,6 +10,7 @@ export * from "./checkout/prevVersions/checkoutResponseV0.js";
// Common
export * from "./common/billingPreviewResponse.js";
export * from "./common/billingResponse.js";
export * from "./updateSubscription/previewUpdateSubscriptionResponse.js";
// Update Subscription

View File

@@ -33,21 +33,35 @@ export const ExtUpdateSubscriptionV0ParamsSchema = z.object({
export const UpdateSubscriptionV0ParamsSchema =
ExtUpdateSubscriptionV0ParamsSchema.extend({
customer_product_id: z.string().optional(),
}).check((ctx) => {
if (ctx.value.options && ctx.value.options.length > 0) {
const invalidFeatures = ctx.value.options
.filter((opt) => nullish(opt.quantity) || opt.quantity < 0)
.map((opt) => opt.feature_id);
})
.refine(
(data) => {
if (data.items && data.items.length === 0) {
return false;
}
if (invalidFeatures.length > 0) {
ctx.issues.push({
code: "custom",
message: `Options quantity must be >= 0 for features: ${invalidFeatures.join(", ")}`,
input: ctx.value,
});
return true;
},
{
message:
"Must provide at least one item when updating to a custom plan",
},
)
.check((ctx) => {
if (ctx.value.options && ctx.value.options.length > 0) {
const invalidFeatures = ctx.value.options
.filter((opt) => nullish(opt.quantity) || opt.quantity < 0)
.map((opt) => opt.feature_id);
if (invalidFeatures.length > 0) {
ctx.issues.push({
code: "custom",
message: `Options quantity must be >= 0 for features: ${invalidFeatures.join(", ")}`,
input: ctx.value,
});
}
}
}
});
});
export type ExtUpdateSubscriptionV0Params = z.infer<
typeof ExtUpdateSubscriptionV0ParamsSchema

View File

@@ -6,10 +6,11 @@ export enum MetadataType {
InvoiceActionRequired = "invoice_action_required",
InvoiceCheckout = "invoice_checkout",
CheckoutSessionCompleted = "checkout_session_completed",
DeferredAutumnBillingPlan = "deferred_autumn_billing_plan",
InvoiceActionRequiredV2 = "invoice_action_required_v2",
InvoiceCheckoutV2 = "invoice_checkout_v2",
DeferredInvoice = "deferred_invoice",
// InvoiceActionRequiredV2 = "invoice_action_required_v2",
// InvoiceCheckoutV2 = "invoice_checkout_v2",
}
export const metadata = pgTable("metadata", {

View File

@@ -1,13 +1,13 @@
export * from "./cycleUtils/getCycleEnd.js";
export * from "./cycleUtils/getCycleStart.js";
export * from "./cycleUtils/getLineItemBillingPeriod.js";
// Interval utils
export * from "./intervalUtils/addDuration.js";
export * from "./intervalUtils/intervalArithmetic.js";
// Invoicing utils
export * from "./invoicingUtils/cusProductToArrearLineItems.js";
export * from "./invoicingUtils/cusProductToLineItems.js";
export * from "./invoicingUtils/filterUnchangedPricesFromLineItems.js";
export * from "./invoicingUtils/lineItemBuilders/buildLineItem.js";
export * from "./invoicingUtils/lineItemBuilders/fixedPriceToLineItem.js";

View File

@@ -1,123 +0,0 @@
import type { LineItem } from "../../../models/billingModels/invoicingModels/lineItem";
import type { LineItemContext } from "../../../models/billingModels/invoicingModels/lineItemContext";
import type { FullCusProduct } from "../../../models/cusProductModels/cusProductModels";
import type { Organization } from "../../../models/orgModels/orgTable";
import { addCusProductToCusEnt } from "../../cusEntUtils/cusEntUtils";
import { cusPriceToCusEnt } from "../../cusPriceUtils/convertCusPriceUtils";
import { orgToCurrency } from "../../orgUtils/convertOrgUtils";
import {
isConsumablePrice,
isFixedPrice,
} from "../../productUtils/priceUtils/classifyPriceUtils";
import { getLineItemBillingPeriod } from "../cycleUtils/getLineItemBillingPeriod";
import { fixedPriceToLineItem } from "./lineItemBuilders/fixedPriceToLineItem";
import { usagePriceToLineItem } from "./lineItemBuilders/usagePriceToLineItem";
// TODO: import these once implemented
// import { prepaidPriceToLineItem } from "./lineItemBuilders/prepaidPriceToLineItem";
// import { allocatedPriceToLineItem } from "./lineItemBuilders/allocatedPriceToLineItem";
export type LineItemDirection = "charge" | "refund";
/**
* Generates line items for a customer product.
* - "charge" direction: positive amounts (for NEW product)
* - "credit" direction: negative amounts with "Unused" prefix (for OLD product)
*
* NOTE: Consumable (UsageInArrear) prices are NOT included - they're always
* positive charges for past usage and handled separately.
*/
export const cusProductToLineItems = ({
cusProduct,
nowMs,
billingCycleAnchorMs,
direction,
org,
logger,
}: {
cusProduct: FullCusProduct;
nowMs: number;
billingCycleAnchorMs: number | "now";
direction: "charge" | "refund";
org: Organization;
// biome-ignore lint/suspicious/noExplicitAny: Logger type defined in server package
logger: any;
}): LineItem[] => {
let lineItems: LineItem[] = [];
// logger.debug(
// `Building line items for customer product: ${cusProduct.product.id} (${direction})`,
// );
// logger.debug(
// `Billing cycle anchor: ${formatMs(billingCycleAnchorMs)}, now: ${formatMs(nowMs)}`,
// );
for (const cusPrice of cusProduct.customer_prices) {
const price = cusPrice.price;
// Calculate billing period
const billingPeriod = getLineItemBillingPeriod({
anchorMs: billingCycleAnchorMs,
price,
nowMs,
});
// logger.debug(
// `Billing period: ${formatMs(billingPeriod?.start)} - ${formatMs(billingPeriod?.end)}`,
// );
// Build line item context
const context: LineItemContext = {
price,
product: cusProduct.product,
feature: undefined,
billingPeriod,
direction,
billingTiming: "in_advance",
now: nowMs,
currency: orgToCurrency({ org }),
};
if (isFixedPrice(price)) {
lineItems.push(
fixedPriceToLineItem({
context,
quantity: cusProduct.quantity ?? 1,
}),
);
continue;
}
if (isConsumablePrice(price)) continue;
const cusEnt = cusPriceToCusEnt({
cusPrice,
cusEnts: cusProduct.customer_entitlements,
});
context.feature = cusEnt?.entitlement.feature;
if (!cusEnt) {
throw new Error(
`[cusProductToLineItems] No cusEnt found for cusPrice: ${cusPrice.id}`,
);
}
const cusEntWithCusProduct = addCusProductToCusEnt({
cusEnt,
cusProduct,
});
lineItems.push(
usagePriceToLineItem({
cusEnt: cusEntWithCusProduct,
context,
}),
);
}
lineItems = lineItems.filter((item) => item.amount !== 0);
return lineItems;
};

View File

@@ -51,6 +51,15 @@ export const isCustomerProductPaid = (customerProduct?: FullCusProduct) => {
return !isFreeProduct({ prices });
};
/** Customer product is both paid AND recurring (not free, not one-off) */
export const isCustomerProductPaidRecurring = (
customerProduct?: FullCusProduct,
) => {
if (!customerProduct) return false;
const prices = cusProductToPrices({ cusProduct: customerProduct });
return !isFreeProduct({ prices }) && !isOneOffProduct({ prices });
};
// ============================================================================
// STATUS CHECKS
// ============================================================================

View File

@@ -13,6 +13,7 @@ import {
isCustomerProductOnStripeSubscription,
isCustomerProductOnStripeSubscriptionSchedule,
isCustomerProductPaid,
isCustomerProductPaidRecurring,
isCustomerProductRecurring,
isCustomerProductScheduled,
isCustomerProductTrialing,
@@ -82,6 +83,12 @@ class CustomerProductChecker {
return this;
}
/** Product is paid AND recurring (not free, not one-off) */
paidRecurring() {
this.predicates.push(isCustomerProductPaidRecurring);
return this;
}
/** Product has canceled_at set */
canceling() {
this.predicates.push(isCustomerProductCanceling);

View File

@@ -1,3 +1,4 @@
import { isOneOffPrice } from "@utils/productUtils/priceUtils/classifyPriceUtils.js";
import type { Entity } from "../../models/cusModels/entityModels/entityModels.js";
import type { CustomerEntitlementFilters } from "../../models/cusProductModels/cusEntModels/cusEntModels.js";
import type { FullCusEntWithFullCusProduct } from "../../models/cusProductModels/cusEntModels/cusEntWithProduct.js";
@@ -17,10 +18,20 @@ import { notNullish } from "../utils.js";
export const cusProductsToPrices = ({
cusProducts,
filters,
}: {
cusProducts: FullCusProduct[];
filters?: {
excludeOneOffPrices?: boolean;
};
}) => {
return cusProducts.flatMap((cp) => cusProductToPrices({ cusProduct: cp }));
let prices = cusProducts.flatMap((cp) =>
cusProductToPrices({ cusProduct: cp }),
);
if (filters?.excludeOneOffPrices) {
prices = prices.filter((p) => !isOneOffPrice(p));
}
return prices;
};
export const cusProductsToCusPrices = ({
@@ -121,7 +132,10 @@ export const cusProductsToCusEnts = ({
// customerEntitlementFilters,
});
if (customerEntitlementFilters?.cusEntIds && customerEntitlementFilters.cusEntIds.length > 0) {
if (
customerEntitlementFilters?.cusEntIds &&
customerEntitlementFilters.cusEntIds.length > 0
) {
cusEnts = cusEnts.filter((cusEnt) =>
customerEntitlementFilters.cusEntIds?.includes(cusEnt.id),
);
@@ -129,7 +143,8 @@ export const cusProductsToCusEnts = ({
if (notNullish(customerEntitlementFilters?.interval)) {
cusEnts = cusEnts.filter(
(cusEnt) => cusEnt.entitlement.interval === customerEntitlementFilters.interval,
(cusEnt) =>
cusEnt.entitlement.interval === customerEntitlementFilters.interval,
);
}