wip
This commit is contained in:
@@ -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" })`
|
||||
|
||||
29
server/src/external/stripe/invoices/utils/logStripeInvoice.ts
vendored
Normal file
29
server/src/external/stripe/invoices/utils/logStripeInvoice.ts
vendored
Normal 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,
|
||||
})),
|
||||
});
|
||||
};
|
||||
0
server/src/external/stripe/invoices/utils/operations/getStripeInvoiceWithDiscounts.ts
vendored
Normal file
0
server/src/external/stripe/invoices/utils/operations/getStripeInvoiceWithDiscounts.ts
vendored
Normal 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;
|
||||
}
|
||||
|
||||
|
||||
40
server/src/external/stripe/webhookHandlers/handleStripeInvoicePaid/handleStripeInvoicePaid.ts
vendored
Normal file
40
server/src/external/stripe/webhookHandlers/handleStripeInvoicePaid/handleStripeInvoicePaid.ts
vendored
Normal 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 });
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
@@ -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,
|
||||
@@ -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,
|
||||
@@ -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,
|
||||
@@ -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,
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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 },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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");
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
@@ -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");
|
||||
};
|
||||
@@ -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,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ export const evaluateStripeBillingPlan = async ({
|
||||
const stripeSubscriptionAction = buildStripeSubscriptionAction({
|
||||
ctx,
|
||||
billingContext,
|
||||
autumnBillingPlan,
|
||||
finalCustomerProducts: finalFullCustomer.customer_products,
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
16
server/src/internal/billing/v2/types/billingResult.ts
Normal file
16
server/src/internal/billing/v2/types/billingResult.ts
Normal 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;
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import type Stripe from "stripe";
|
||||
|
||||
export interface StripeBillingPlanResult {
|
||||
deferred?: boolean;
|
||||
stripeInvoice?: Stripe.Invoice;
|
||||
stripeSubscription?: Stripe.Subscription;
|
||||
}
|
||||
@@ -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,
|
||||
// });
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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.",
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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.",
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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(),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }],
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
|
||||
35
shared/api/billing/common/billingResponse.ts
Normal file
35
shared/api/billing/common/billingResponse.ts
Normal 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>;
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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
|
||||
// ============================================================================
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user