skeleton complete

This commit is contained in:
John Yeo
2026-01-06 14:18:30 +00:00
parent 3a9c95b935
commit c43f4db280
83 changed files with 1522 additions and 895 deletions

View File

@@ -3,20 +3,24 @@
source "$(dirname "$0")/config.sh"
BUN_PARALLEL_COMPACT \
'server/tests/attach/basic' \
'server/tests/attach/upgrade' \
'server/tests/attach/downgrade' \
'server/tests/attach/free' \
'server/tests/attach/addOn' \
'server/tests/attach/checkout' \
'server/tests/attach/misc' \
'server/tests/billing/invoice-action-required' \
'server/tests/billing/cancel' \
'server/tests/billing/cancel/add-ons' \
'server/tests/renew' \
'server/tests/billing/subscription-update' \
--max=6
BUN_PARALLEL_COMPACT \
'server/tests/attach/entities' \
--max=6
# 'server/tests/external-psps/revenuecat' \
# BUN_PARALLEL_COMPACT \
# 'server/tests/attach/basic' \
# 'server/tests/attach/upgrade' \
# 'server/tests/attach/downgrade' \
# 'server/tests/attach/free' \
# 'server/tests/attach/addOn' \
# 'server/tests/attach/checkout' \
# 'server/tests/attach/misc' \
# 'server/tests/billing/invoice-action-required' \
# 'server/tests/billing/cancel' \
# 'server/tests/billing/cancel/add-ons' \
# 'server/tests/renew' \
# --max=6
# BUN_PARALLEL_COMPACT \
# 'server/tests/attach/entities' \
# --max=6
# # 'server/tests/external-psps/revenuecat' \

View File

@@ -17,8 +17,8 @@ import {
type LegacyVersion,
type OrgConfig,
type RewardRedemption,
type SubscriptionUpdateV0Params,
type TrackParams,
type UpdateSubscriptionV0Params,
} from "@autumn/shared";
import { defaultApiVersion } from "@tests/constants.js";
import type {
@@ -683,7 +683,7 @@ export class AutumnInt {
},
};
subscriptionUpdate = async (params: SubscriptionUpdateV0Params) => {
subscriptionUpdate = async (params: UpdateSubscriptionV0Params) => {
const data = await this.post(`/subscriptions/update`, params);
return data;
};

View File

@@ -1,6 +1,7 @@
import type { Metadata } from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { executeAutumnBillingPlan } from "@/internal/billing/v2/execute/executeAutumnBillingPlan";
import { executeStripeBillingPlan } from "@/internal/billing/v2/providers/stripe/execute/executeStripeBillingPlan";
import type { DeferredAutumnBillingPlanData } from "@/internal/billing/v2/types/billingPlan";
import { MetadataService } from "@/internal/metadata/MetadataService";
@@ -19,9 +20,20 @@ export const handleDeferredAutumnBillingPlan = async ({
return;
}
const { billingPlan, billingContext } = data;
// Execute stripe billing plan
await executeStripeBillingPlan({
ctx,
stripeBillingPlan: billingPlan.stripe,
autumnBillingPlan: billingPlan.autumn,
billingContext,
resumeFromDeferred: true,
});
await executeAutumnBillingPlan({
ctx,
autumnBillingPlan: data.autumnBillingPlan,
autumnBillingPlan: billingPlan.autumn,
});
await MetadataService.delete({ db, id: metadata.id });

View File

@@ -26,7 +26,10 @@ export const handleInvoicePaidMetadata = async ({
if (!metadata) return;
// Handle deferred billing plan (v2 flow)
if (metadata.type === MetadataType.DeferredAutumnBillingPlan) {
if (
metadata.type === MetadataType.InvoiceCheckoutV2 ||
metadata.type === MetadataType.InvoiceActionRequiredV2
) {
await handleDeferredAutumnBillingPlan({ ctx, metadata });
return;
}

View File

@@ -51,6 +51,10 @@ const coreUrls = [
method: "POST",
url: "/cancel",
},
{
method: "POST",
url: "/subscriptions/update",
},
];
/**

View File

@@ -1,11 +1,11 @@
import { Hono } from "hono";
import { handleSubscriptionUpdatePreview } from "@/internal/billing/v2/subscriptionUpdate/handleSubscriptionUpdatePreview.js";
import { handleUpdateSubscriptionPreview } from "@/internal/billing/v2/subscriptionUpdate/handleUpdateSubscriptionPreview.js";
import type { HonoEnv } from "../../honoUtils/HonoEnv.js";
import { handleAttach } from "./attach/handleAttach.js";
import { handleCheckoutV2 } from "./checkout/handleCheckoutV2.js";
import { handleSetupPayment } from "./handlers/handleSetupPayment.js";
import { handleApiSubscriptionUpdate } from "./v2/handlers/handleApiSubscriptionUpdate.js";
import { handleAttachV2 } from "./v2/handlers/handleAttachV2.js";
import { handleUpdateSubscription } from "./v2/handlers/handleUpdateSubscription.js";
export const billingRouter = new Hono<HonoEnv>();
@@ -14,8 +14,8 @@ billingRouter.post("/checkout", ...handleCheckoutV2);
billingRouter.post("/attach", ...handleAttach);
billingRouter.post("/attach_v2", ...handleAttachV2);
billingRouter.post("/subscriptions/update", ...handleApiSubscriptionUpdate);
billingRouter.post("/subscriptions/update", ...handleUpdateSubscription);
billingRouter.post(
"/subscriptions/preview/update",
...handleSubscriptionUpdatePreview,
...handleUpdateSubscriptionPreview,
);

View File

@@ -1,6 +1,14 @@
import type { FullProduct } from "@autumn/shared";
import type { FullCusProduct, FullProduct } from "@autumn/shared";
import type { FullCustomer } from "@shared/models/cusModels/fullCusModel";
import type Stripe from "stripe";
import { z } from "zod/v4";
export const InvoiceModeSchema = z.object({
finalizeInvoice: z.boolean().default(false),
enableProductImmediately: z.boolean().default(true),
});
export type InvoiceMode = z.infer<typeof InvoiceModeSchema>;
export interface BillingContext {
fullCustomer: FullCustomer;
@@ -10,5 +18,17 @@ export interface BillingContext {
stripeSubscription?: Stripe.Subscription;
stripeSubscriptionSchedule?: Stripe.SubscriptionSchedule;
paymentMethod?: Stripe.PaymentMethod;
testClockFrozenTime?: number;
// Timestamps...
currentEpochMs: number;
billingCycleAnchorMs?: number;
// Invoice mode
invoiceMode?: InvoiceMode;
}
export interface UpdateSubscriptionBillingContext extends BillingContext {
customerProduct: FullCusProduct; // target customer product
}
// testClockFrozenTime?: number;

View File

@@ -3,30 +3,29 @@ import {
cusProductToLineItems,
type FullCusProduct,
} from "@autumn/shared";
import type { BillingContext } from "@/internal/billing/v2/billingContext";
import type { AutumnContext } from "../../../../../honoUtils/HonoEnv";
export const buildAutumnLineItems = ({
ctx,
newCusProducts,
ongoingCustomerProduct,
billingCycleAnchor,
testClockFrozenTime,
newCustomerProducts,
deletedCustomerProduct,
billingContext,
}: {
ctx: AutumnContext;
newCusProducts: FullCusProduct[];
ongoingCustomerProduct?: FullCusProduct;
billingCycleAnchor?: number;
testClockFrozenTime?: number;
newCustomerProducts: FullCusProduct[];
deletedCustomerProduct?: FullCusProduct;
billingContext: BillingContext;
}) => {
const now = testClockFrozenTime ?? Date.now();
billingCycleAnchor = billingCycleAnchor ?? now;
// billingCycleAnchor = billingCycleAnchor ?? now;
const billingCycleAnchor = billingContext.billingCycleAnchorMs;
const now = billingContext.currentEpochMs;
const { org } = ctx;
const arrearLineItems = ongoingCustomerProduct
const arrearLineItems = deletedCustomerProduct
? cusProductToArrearLineItems({
cusProduct: ongoingCustomerProduct,
cusProduct: deletedCustomerProduct,
billingCycleAnchor: billingCycleAnchor!,
now,
org,
@@ -34,9 +33,9 @@ export const buildAutumnLineItems = ({
: [];
// Get line items for ongoing cus product
const ongoingLineItems = ongoingCustomerProduct
const deletedLineItems = deletedCustomerProduct
? cusProductToLineItems({
cusProduct: ongoingCustomerProduct,
cusProduct: deletedCustomerProduct,
now,
billingCycleAnchor: billingCycleAnchor!,
direction: "refund",
@@ -44,9 +43,9 @@ export const buildAutumnLineItems = ({
})
: [];
const newLineItems = newCusProducts.flatMap((newCusProduct) =>
const newLineItems = newCustomerProducts.flatMap((newCustomerProduct) =>
cusProductToLineItems({
cusProduct: newCusProduct,
cusProduct: newCustomerProduct,
now,
billingCycleAnchor: billingCycleAnchor!,
direction: "charge",
@@ -56,7 +55,7 @@ export const buildAutumnLineItems = ({
// All items
const allLineItems = [
...ongoingLineItems,
...deletedLineItems,
...arrearLineItems,
...newLineItems,
];

View File

@@ -2,7 +2,7 @@ import type {
Feature,
FeatureOptions,
Price,
SubscriptionUpdateV0Params,
UpdateSubscriptionV0Params,
} from "@autumn/shared";
import { roundUsageToNearestBillingUnit } from "@autumn/shared";
import { Decimal } from "decimal.js";
@@ -12,7 +12,7 @@ export const paramsToFeatureOptions = ({
price,
feature,
}: {
params: SubscriptionUpdateV0Params;
params: UpdateSubscriptionV0Params;
price: Price;
feature: Feature;
}): FeatureOptions | undefined => {

View File

@@ -50,7 +50,10 @@ export const executeAutumnBillingPlan = async ({
await CusProductService.update({
db,
cusProductId: updateCustomerProduct.id,
updates: { options: updateCustomerProduct.options },
updates: {
options: updateCustomerProduct.options,
status: updateCustomerProduct.status,
},
});
}

View File

@@ -16,24 +16,18 @@ export const executeBillingPlan = async ({
}) => {
logBillingPlan({ ctx, billingPlan });
const enableProductImmediately =
billingPlan.stripe.invoiceAction?.invoiceMode?.enableProductImmediately !==
false;
await executeStripeBillingPlan({
const result = await executeStripeBillingPlan({
ctx,
stripeBillingPlan: billingPlan.stripe,
autumnBillingPlan: billingPlan.autumn,
billingPlan,
billingContext,
});
// if not enabling product immediately, it will be handled in webhook
if (enableProductImmediately) {
if (result.deferred) return result;
await executeAutumnBillingPlan({
ctx,
autumnBillingPlan: billingPlan.autumn,
});
}
return { billingPlan };
};

View File

@@ -4,7 +4,7 @@ import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { InvoiceService } from "@/internal/invoices/InvoiceService";
import { getInvoiceItems } from "@/internal/invoices/invoiceUtils";
import { createAndFinalizeInvoice } from "@/internal/invoices/invoiceUtils/createAndFinalizeInvoice";
import type { UpdateSubscriptionContext } from "../subscriptionUpdate/fetch/updateSubscriptionContextSchema";
import type { UpdateSubscriptionBillingContext } from "../billingContext";
import type { SubscriptionUpdateInvoiceAction } from "../typesOld";
/**
@@ -22,7 +22,7 @@ export const executeInvoiceAction = async ({
}: {
ctx: AutumnContext;
invoiceAction: SubscriptionUpdateInvoiceAction;
updateSubscriptionContext: UpdateSubscriptionContext;
updateSubscriptionContext: UpdateSubscriptionBillingContext;
}) => {
const { stripeCustomer, stripeSubscription, customerProduct } =
updateSubscriptionContext;

View File

@@ -1,47 +0,0 @@
import type { AutumnContext } from "../../../../honoUtils/HonoEnv";
import type { StripeSubscriptionAction } from "../types/billingPlan";
import { executeStripeSubscriptionUpdate } from "./executeStripeSubscriptionActions/executeStripeSubscriptionUpdate";
export const executeStripeSubAction = async ({
ctx,
stripeSubAction,
}: {
ctx: AutumnContext;
stripeSubAction: StripeSubscriptionAction;
}) => {
const { logger } = ctx;
switch (stripeSubAction.type) {
case "update":
logger.info("Executing Stripe subscription update");
return await executeStripeSubscriptionUpdate({
ctx,
stripeSubscriptionAction: stripeSubAction,
});
case "create":
logger.info("Executing Stripe subscription create");
throw new Error("Stripe subscription create not yet implemented");
case "cancel_immediately":
logger.info("Executing Stripe subscription cancel immediately");
throw new Error(
"Stripe subscription cancel immediately not yet implemented",
);
case "cancel_at_period_end":
logger.info("Executing Stripe subscription cancel at period end");
throw new Error(
"Stripe subscription cancel at period end not yet implemented",
);
case "none":
logger.info("No Stripe subscription action required");
return;
default:
throw new Error(
`Unknown Stripe subscription action type: ${stripeSubAction.type}`,
);
}
};

View File

@@ -1,43 +0,0 @@
import { SubscriptionUpdateV0ParamsSchema } from "@autumn/shared";
import { createRoute } from "../../../../honoMiddlewares/routeHandler";
import { executeBillingPlan } from "../execute/executeBillingPlan";
import { computeSubscriptionUpdatePlan } from "../subscriptionUpdate/compute/computeSubscriptionUpdatePlan";
import { evaluateSubscriptionUpdatePlan } from "../subscriptionUpdate/evaluate/evaluateSubscriptionUpdatePlan";
import { fetchApiSubscriptionUpdateContext } from "../subscriptionUpdate/fetch/fetchApiSubscriptionUpdateContext";
export const handleApiSubscriptionUpdate = createRoute({
body: SubscriptionUpdateV0ParamsSchema,
handler: async (c) => {
const ctx = c.get("ctx");
const body = c.req.valid("json");
const updateSubscriptionContext = await fetchApiSubscriptionUpdateContext({
ctx,
params: body,
});
const autumnBillingPlan = await computeSubscriptionUpdatePlan({
ctx,
updateSubscriptionContext,
params: body,
});
const stripeBillingPlan = evaluateSubscriptionUpdatePlan({
ctx,
updateSubscriptionContext,
params: body,
autumnBillingPlan,
});
await executeBillingPlan({
ctx,
billingContext: updateSubscriptionContext,
billingPlan: {
autumn: autumnBillingPlan,
stripe: stripeBillingPlan,
},
});
return c.json({ success: true }, 200);
},
});

View File

@@ -0,0 +1,43 @@
import { UpdateSubscriptionV0ParamsSchema } from "@autumn/shared";
import { computeSubscriptionUpdatePlan } from "@/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdatePlan";
import { createRoute } from "../../../../honoMiddlewares/routeHandler";
import { executeBillingPlan } from "../execute/executeBillingPlan";
import { evaluateStripeBillingPlan } from "../providers/stripe/actionBuilders/evaluateStripeBillingPlan";
import { fetchUpdateSubscriptionBillingContext } from "../subscriptionUpdate/fetch/fetchUpdateSubscriptionBillingContext";
export const handleUpdateSubscription = createRoute({
body: UpdateSubscriptionV0ParamsSchema,
handler: async (c) => {
const ctx = c.get("ctx");
const body = c.req.valid("json");
const updateSubscriptionBillingContext =
await fetchUpdateSubscriptionBillingContext({
ctx,
params: body,
});
const autumnBillingPlan = await computeSubscriptionUpdatePlan({
ctx,
billingContext: updateSubscriptionBillingContext,
params: body,
});
const stripeBillingPlan = evaluateStripeBillingPlan({
ctx,
billingContext: updateSubscriptionBillingContext,
autumnBillingPlan,
});
await executeBillingPlan({
ctx,
billingContext: updateSubscriptionBillingContext,
billingPlan: {
autumn: autumnBillingPlan,
stripe: stripeBillingPlan,
},
});
return c.json({ success: true }, 200);
},
});

View File

@@ -1,29 +1,34 @@
import type { LineItem } from "@autumn/shared";
import type {
InvoiceMode,
StripeInvoiceAction,
} from "../../../types/billingPlan";
import { lineItemsToStripeLines } from "../utils/invoiceLines/lineItemsToStripeLines";
import type { StripeInvoiceAction } from "../../../types/billingPlan";
import { lineItemsToInvoiceAddLinesParams } from "../utils/invoiceLines/lineItemsToInvoiceAddLinesParams";
/**
* Builds a Stripe invoice action from Autumn line items.
* Returns undefined if no line items are provided.
* Builds a StripeInvoiceAction for immediate charges.
* Filters for line items where chargeImmediately === true.
* Returns undefined if no immediate line items are provided.
*/
export const buildStripeInvoiceAction = ({
autumnLineItems,
invoiceMode,
lineItems,
}: {
autumnLineItems: LineItem[];
invoiceMode?: InvoiceMode;
lineItems: LineItem[];
}): StripeInvoiceAction | undefined => {
if (autumnLineItems.length === 0) {
const immediateLineItems = lineItems.filter(
(line) => line.chargeImmediately === true,
);
if (immediateLineItems.length === 0) {
return undefined;
}
const lines = lineItemsToStripeLines({ lineItems: autumnLineItems });
const lines = lineItemsToInvoiceAddLinesParams({
lineItems: immediateLineItems,
});
if (lines.length === 0) {
return undefined;
}
return {
addLineParams: { lines },
invoiceMode,
};
};

View File

@@ -0,0 +1,42 @@
import type { LineItem } from "@autumn/shared";
import type { BillingContext } from "@/internal/billing/v2/billingContext";
import type { StripeInvoiceItemsAction } from "../../../types/billingPlan";
import { lineItemsToCreateInvoiceItemsParams } from "../utils/invoiceLines/lineItemsToCreateInvoiceItemsParams";
/**
* Builds a StripeInvoiceItemsAction for deferred charges (added to next cycle).
* Filters for line items where chargeImmediately === false.
* Returns undefined if no deferred line items are provided.
*/
export const buildStripeInvoiceItemsAction = ({
lineItems,
billingContext,
}: {
lineItems: LineItem[];
billingContext: BillingContext;
}): StripeInvoiceItemsAction | undefined => {
const deferredLineItems = lineItems.filter(
(line) => line.chargeImmediately === false,
);
if (deferredLineItems.length === 0) {
return undefined;
}
const stripeCustomerId = billingContext.stripeCustomer?.id;
const stripeSubscriptionId = billingContext.stripeSubscription?.id;
const createInvoiceItems = lineItemsToCreateInvoiceItemsParams({
stripeCustomerId,
stripeSubscriptionId,
lineItems: deferredLineItems,
});
if (createInvoiceItems.length === 0) {
return undefined;
}
return {
createInvoiceItems,
};
};

View File

@@ -1,9 +1,9 @@
import type { FullCusProduct } from "@autumn/shared";
import type { AutumnContext } from "@server/honoUtils/HonoEnv";
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 type { UpdateSubscriptionContext } from "@server/internal/billing/v2/subscriptionUpdate/fetch/updateSubscriptionContextSchema";
import type {
FreeTrialPlan,
StripeSubscriptionAction,
@@ -13,13 +13,13 @@ import type {
export const buildStripeSubscriptionAction = ({
ctx,
billingContext,
updatedCustomerProducts,
finalCustomerProducts,
stripeSubscriptionScheduleAction,
freeTrialPlan,
}: {
ctx: AutumnContext;
billingContext: UpdateSubscriptionContext;
updatedCustomerProducts: FullCusProduct[];
billingContext: BillingContext;
finalCustomerProducts: FullCusProduct[];
stripeSubscriptionScheduleAction?: StripeSubscriptionScheduleAction;
freeTrialPlan?: FreeTrialPlan;
}): StripeSubscriptionAction | undefined => {
@@ -28,11 +28,9 @@ export const buildStripeSubscriptionAction = ({
const subItemsUpdate = buildStripeSubscriptionItemsUpdate({
ctx,
billingContext,
updatedCustomerProducts,
finalCustomerProducts,
});
// 1. Compute the action type
// Case 1: No subscription and sub items update is empty -> no action
if (!stripeSubscription && subItemsUpdate.length === 0) {
return undefined;

View File

@@ -1,9 +1,13 @@
import type { FullCusProduct } from "@autumn/shared";
import { msToSeconds } from "@autumn/shared";
import {
isCustomerProductOnStripeSubscription,
isCustomerProductOnStripeSubscriptionSchedule,
msToSeconds,
RELEVANT_STATUSES,
} from "@autumn/shared";
import type { AutumnContext } from "@server/honoUtils/HonoEnv";
import type { BillingContext } from "@server/internal/billing/v2/billingContext";
import { getFinalCustomerProductsState } from "@server/internal/billing/v2/utils/getFinalCustomerProductsState";
import { buildSchedulePhases } from "@server/internal/billing/v2/utils/stripeAdapter/subscriptionSchedules/buildSchedulePhases";
import { buildSchedulePhases } from "@server/internal/billing/v2/providers/stripe/utils/subscriptionSchedules/buildSchedulePhases";
import type Stripe from "stripe";
import type { StripeSubscriptionScheduleAction } from "@/internal/billing/v2/types/billingPlan";
@@ -46,31 +50,42 @@ const filterEmptyPhases = (
export const buildStripeSubscriptionScheduleAction = ({
ctx,
billingContext,
addCustomerProducts = [],
removeCustomerProducts = [],
finalCustomerProducts,
trialEndsAt,
nowMs,
}: {
ctx: AutumnContext;
billingContext: BillingContext;
addCustomerProducts?: FullCusProduct[];
removeCustomerProducts?: FullCusProduct[];
finalCustomerProducts: FullCusProduct[];
trialEndsAt?: number;
nowMs: number;
}): StripeSubscriptionScheduleAction | undefined => {
const { stripeSubscriptionSchedule } = billingContext;
const { stripeSubscriptionSchedule, stripeSubscription } = billingContext;
// 1. Get final customer product state
const customerProducts = getFinalCustomerProductsState({
billingContext,
addCustomerProducts,
removeCustomerProducts,
});
// 1. Filter customer products by stripe subscription id or stripe subscription schedule ID?
let customerProducts = stripeSubscription
? finalCustomerProducts.filter(
(cp) =>
isCustomerProductOnStripeSubscription({
customerProduct: cp,
stripeSubscriptionId: stripeSubscription.id,
}) ||
isCustomerProductOnStripeSubscriptionSchedule({
customerProduct: cp,
stripeSubscriptionScheduleId: stripeSubscriptionSchedule?.id ?? "",
}),
)
: [];
customerProducts = customerProducts.filter((cp) =>
RELEVANT_STATUSES.includes(cp.status),
);
const phases = buildSchedulePhases({
ctx,
billingContext,
customerProducts,
customerProducts: finalCustomerProducts,
trialEndsAt,
nowMs,
});

View File

@@ -0,0 +1,60 @@
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { buildStripeSubscriptionScheduleAction } from "@/internal/billing/v2/providers/stripe/actionBuilders/buildStripeSubscriptionScheduleAction";
import { autumnBillingPlanToFinalFullCustomer } from "@/internal/billing/v2/utils/autumnBillingPlanToFinalFullCustomer";
import type { BillingContext } from "../../../billingContext";
import { buildStripeInvoiceAction } from "../../../providers/stripe/actionBuilders/buildStripeInvoiceAction";
import { buildStripeInvoiceItemsAction } from "../../../providers/stripe/actionBuilders/buildStripeInvoiceItemsAction";
import { buildStripeSubscriptionAction } from "../../../providers/stripe/actionBuilders/buildStripeSubscriptionAction";
import type {
AutumnBillingPlan,
StripeBillingPlan,
} from "../../../types/billingPlan";
export const evaluateStripeBillingPlan = ({
ctx,
billingContext,
autumnBillingPlan,
}: {
ctx: AutumnContext;
billingContext: BillingContext;
autumnBillingPlan: AutumnBillingPlan;
}): StripeBillingPlan => {
const finalFullCustomer = autumnBillingPlanToFinalFullCustomer({
billingContext,
autumnBillingPlan,
});
const stripeSubscriptionAction = buildStripeSubscriptionAction({
ctx,
billingContext,
finalCustomerProducts: finalFullCustomer.customer_products,
});
const { autumnLineItems } = autumnBillingPlan;
const stripeInvoiceAction = buildStripeInvoiceAction({
lineItems: autumnLineItems,
});
const stripeInvoiceItemsAction = buildStripeInvoiceItemsAction({
lineItems: autumnLineItems,
billingContext,
});
// Build stripe subscription schedule action
const stripeSubscriptionScheduleAction =
buildStripeSubscriptionScheduleAction({
ctx,
billingContext,
finalCustomerProducts: finalFullCustomer.customer_products,
trialEndsAt: autumnBillingPlan.freeTrialPlan?.trialEndsAt,
nowMs: billingContext.currentEpochMs,
});
return {
subscriptionAction: stripeSubscriptionAction,
invoiceAction: stripeInvoiceAction,
invoiceItemsAction: stripeInvoiceItemsAction,
subscriptionScheduleAction: stripeSubscriptionScheduleAction,
};
};

View File

@@ -1,124 +1,66 @@
import { MetadataType } from "@autumn/shared";
import type Stripe from "stripe";
import { isStripeSubscriptionCanceled } from "@/external/stripe/subscriptions/utils/classifyStripeSubscriptionUtils";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import type { BillingContext } from "@/internal/billing/v2/billingContext";
import { addStripeSubscriptionIdToBillingPlan } from "@/internal/billing/v2/execute/addStripeSubscriptionIdToBillingPlan";
import { addStripeSubscriptionScheduleIdToBillingPlan } from "@/internal/billing/v2/execute/addStripeSubscriptionScheduleIdToBillingPlan";
import { removeStripeSubscriptionIdFromBillingPlan } from "@/internal/billing/v2/execute/removeStripeSubscriptionIdFromBillingPlan";
import { executeStripeInvoiceAction } from "@/internal/billing/v2/providers/stripe/execute/executeStripeInvoiceAction";
import { executeStripeSubscriptionAction } from "@/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionAction";
import { executeStripeSubscriptionScheduleAction } from "@/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionScheduleAction";
import { createInvoiceForBilling } from "@/internal/billing/v2/providers/stripe/utils/invoices/createInvoiceForBilling";
import type {
AutumnBillingPlan,
StripeBillingPlan,
StripeInvoiceMetadata,
} from "@/internal/billing/v2/types/billingPlan";
import { upsertInvoiceFromBilling } from "@/internal/billing/v2/utils/upsertFromStripe/upsertInvoiceFromBilling";
import { upsertSubscriptionFromBilling } from "@/internal/billing/v2/utils/upsertFromStripe/upsertSubscriptionFromBilling";
import { addSubIdToCache } from "@/internal/customers/cusCache/subCacheUtils";
import { MetadataService } from "@/internal/metadata/MetadataService";
import { generateId } from "@/utils/genUtils";
import { createStripeInvoiceItems } from "@/internal/billing/v2/providers/stripe/utils/invoices/stripeInvoiceOps";
import type { BillingPlan } from "@/internal/billing/v2/types/billingPlan";
import type { StripeBillingPlanResult } from "@/internal/billing/v2/types/stripeBillingPlanResult";
export const executeStripeBillingPlan = async ({
ctx,
stripeBillingPlan,
autumnBillingPlan,
billingPlan,
billingContext,
resumeFromDeferred = false,
}: {
ctx: AutumnContext;
stripeBillingPlan: StripeBillingPlan;
autumnBillingPlan: AutumnBillingPlan;
billingPlan: BillingPlan;
billingContext: BillingContext;
}) => {
resumeFromDeferred?: boolean;
}): Promise<StripeBillingPlanResult> => {
const { logger } = ctx;
const {
subscriptionAction: stripeSubscriptionAction,
invoiceAction: stripeInvoiceAction,
invoiceItemsAction: stripeInvoiceItemsAction,
subscriptionScheduleAction: stripeSubscriptionScheduleAction,
} = stripeBillingPlan;
} = billingPlan.stripe;
const enableProductImmediately =
stripeInvoiceAction?.invoiceMode?.enableProductImmediately !== false;
if (stripeInvoiceAction) {
let invoiceMetadata: StripeInvoiceMetadata | undefined;
if (!enableProductImmediately) {
const metadataId = generateId("meta");
await MetadataService.insert({
db: ctx.db,
data: {
id: metadataId,
type: MetadataType.DeferredAutumnBillingPlan,
data: {
orgId: ctx.org.id,
env: ctx.env,
autumnBillingPlan,
},
},
});
invoiceMetadata = { autumn_metadata_id: metadataId };
}
const { invoice } = await createInvoiceForBilling({
if (stripeInvoiceAction && !resumeFromDeferred) {
const result = await executeStripeInvoiceAction({
ctx,
billingPlan,
billingContext,
stripeInvoiceAction,
invoiceMetadata,
});
if (invoice) {
await upsertInvoiceFromBilling({
ctx,
stripeInvoice: invoice,
fullProducts: billingContext.fullProducts,
fullCustomer: billingContext.fullCustomer,
});
if (result.deferred) return result;
}
if (stripeInvoiceItemsAction?.createInvoiceItems) {
logger.info(
"[executeStripeBillingPlan] Creating invoice items for next cycle",
);
await createStripeInvoiceItems({
ctx,
invoiceItems: stripeInvoiceItemsAction.createInvoiceItems,
});
}
let stripeSubscription: Stripe.Subscription | undefined =
billingContext.stripeSubscription;
if (stripeSubscriptionAction) {
// 1. Insert stripe subscription ID into cache
if (stripeSubscription?.id) {
await addSubIdToCache({
subId: stripeSubscription.id,
scenario: "billing",
});
}
stripeSubscription = await executeStripeSubscriptionAction({
const result = await executeStripeSubscriptionAction({
ctx,
subscriptionAction: stripeSubscriptionAction,
billingPlan,
billingContext,
});
if (stripeSubscription) {
addStripeSubscriptionIdToBillingPlan({
autumnBillingPlan,
stripeSubscriptionId: stripeSubscription.id,
});
if (result?.deferred) return result;
// Add subscription to DB
await upsertSubscriptionFromBilling({
ctx,
stripeSubscription,
});
}
// If the stripe subscription is canceled, remove the subscription from the billing plan
if (
stripeSubscription &&
isStripeSubscriptionCanceled(stripeSubscription)
) {
removeStripeSubscriptionIdFromBillingPlan({
autumnBillingPlan,
stripeSubscriptionId: stripeSubscription.id,
});
stripeSubscription = undefined;
}
stripeSubscription = result.stripeSubscription;
}
if (stripeSubscriptionScheduleAction) {
@@ -132,9 +74,11 @@ export const executeStripeBillingPlan = async ({
if (stripeSubscriptionSchedule) {
addStripeSubscriptionScheduleIdToBillingPlan({
autumnBillingPlan,
autumnBillingPlan: billingPlan.autumn,
stripeSubscriptionScheduleId: stripeSubscriptionSchedule.id,
});
}
}
return { stripeInvoice: undefined };
};

View File

@@ -0,0 +1,80 @@
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 type {
BillingPlan,
StripeInvoiceMetadata,
} from "@/internal/billing/v2/types/billingPlan";
import type { StripeBillingPlanResult } from "@/internal/billing/v2/types/stripeBillingPlanResult";
import { upsertInvoiceFromBilling } from "@/internal/billing/v2/utils/upsertFromStripe/upsertInvoiceFromBilling";
import { insertMetadataFromBillingPlan } from "@/internal/metadata/utils/insertMetadataFromBillingPlan";
export const executeStripeInvoiceAction = async ({
ctx,
billingPlan,
billingContext,
}: {
ctx: AutumnContext;
billingPlan: BillingPlan;
billingContext: BillingContext;
}): Promise<StripeBillingPlanResult> => {
const { logger } = ctx;
let invoiceMetadata: StripeInvoiceMetadata | undefined;
const { invoiceAction: stripeInvoiceAction } = billingPlan.stripe;
if (!stripeInvoiceAction) {
return { stripeInvoice: undefined };
}
logger.info("[executeStripeInvoiceAction] Creating invoice for billing");
const { invoice } = 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;
if (deferBillingPlan) {
logger.info(
`Deferring billing plan, enableProductAfterInvoice: ${enableProductAfterInvoice}, invoiceActionRequired: ${invoiceActionRequired}`,
);
await insertMetadataFromBillingPlan({
ctx,
billingPlan,
billingContext,
enableProductAfterInvoice,
invoiceActionRequired,
stripeInvoice: invoice,
});
return {
stripeInvoice: invoice,
deferred: true,
};
}
if (invoice) {
logger.info("[executeStripeInvoiceAction] Upserting invoice from billing");
await upsertInvoiceFromBilling({
ctx,
stripeInvoice: invoice,
fullProducts: billingContext.fullProducts,
fullCustomer: billingContext.fullCustomer,
});
}
logger.info(
`[executeStripeInvoiceAction] Completed, invoice: ${invoice?.id}`,
);
return { stripeInvoice: invoice };
};

View File

@@ -1,13 +1,32 @@
import { InternalError } from "@autumn/shared";
import type Stripe from "stripe";
import { createStripeCli } from "@/external/connect/createStripeCli";
import { isStripeSubscriptionCanceled } from "@/external/stripe/subscriptions/utils/classifyStripeSubscriptionUtils";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import type { StripeSubscriptionAction } from "@/internal/billing/v2/types/billingPlan";
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 { upsertSubscriptionFromBilling } from "@/internal/billing/v2/utils/upsertFromStripe/upsertSubscriptionFromBilling";
import { insertMetadataFromBillingPlan } from "@/internal/metadata/utils/insertMetadataFromBillingPlan";
export const executeStripeSubscriptionAction = async ({
type InvoiceModeParams = {
collection_method?: "send_invoice";
days_until_due?: number;
};
const executeSubscriptionOperation = async ({
ctx,
subscriptionAction,
invoiceModeParams,
}: {
ctx: AutumnContext;
subscriptionAction: StripeSubscriptionAction;
invoiceModeParams: InvoiceModeParams;
}) => {
const { org, env } = ctx;
const stripeClient = createStripeCli({ org, env });
@@ -16,13 +35,118 @@ export const executeStripeSubscriptionAction = async ({
case "update":
return await stripeClient.subscriptions.update(
subscriptionAction.stripeSubscriptionId,
subscriptionAction.params,
{
...subscriptionAction.params,
...invoiceModeParams,
expand: ["latest_invoice"],
},
);
case "create":
return await stripeClient.subscriptions.create(subscriptionAction.params);
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,
billingContext,
}: {
ctx: AutumnContext;
billingPlan: BillingPlan;
billingContext: BillingContext;
}): Promise<StripeBillingPlanResult> => {
// 1. Perform stripe subscription operation
const { subscriptionAction } = billingPlan.stripe;
if (!subscriptionAction) return {};
// Invoice mode:
const invoiceMode = billingContext.invoiceMode;
const invoiceModeParams = invoiceMode
? {
collection_method: "send_invoice" as const,
days_until_due: 30,
}
: {};
let stripeSubscription: Stripe.Subscription | undefined =
await executeSubscriptionOperation({
ctx,
subscriptionAction,
invoiceModeParams,
});
const latestStripeInvoice =
subscriptionAction.type === "create"
? (stripeSubscription.latest_invoice as Stripe.Invoice)
: undefined;
// Defer billing plan
const enableProductAfterInvoice =
invoiceMode?.enableProductImmediately === false;
const invoiceActionRequired =
subscriptionAction.type === "create" &&
latestStripeInvoice?.status === "open";
const deferBillingPlan = enableProductAfterInvoice || invoiceActionRequired;
if (deferBillingPlan) {
await insertMetadataFromBillingPlan({
ctx,
billingPlan,
billingContext,
enableProductAfterInvoice,
invoiceActionRequired,
stripeInvoice: latestStripeInvoice,
});
return {
stripeInvoice: latestStripeInvoice,
stripeSubscription,
deferred: true,
};
}
addStripeSubscriptionIdToBillingPlan({
autumnBillingPlan: billingPlan.autumn,
stripeSubscriptionId: stripeSubscription.id,
});
// Add subscription to DB
await upsertSubscriptionFromBilling({
ctx,
stripeSubscription,
});
// If the stripe subscription is canceled, remove the subscription from the billing plan
if (isStripeSubscriptionCanceled(stripeSubscription)) {
removeStripeSubscriptionIdFromBillingPlan({
autumnBillingPlan: billingPlan.autumn,
stripeSubscriptionId: stripeSubscription.id,
});
stripeSubscription = undefined;
}
return {
stripeSubscription,
stripeInvoice: latestStripeInvoice,
};
};

View File

@@ -0,0 +1,53 @@
import { atmnToStripeAmount, type LineItem, msToSeconds } from "@autumn/shared";
import type Stripe from "stripe";
/**
* Converts a single LineItem to Stripe.InvoiceItemCreateParams
*/
const toStripeCreateInvoiceItemParams = ({
stripeCustomerId,
stripeSubscriptionId,
lineItem,
}: {
stripeCustomerId: string;
stripeSubscriptionId?: string;
lineItem: LineItem;
}): Stripe.InvoiceItemCreateParams => {
const { finalAmount, description, context } = lineItem;
const { billingPeriod, currency } = context;
return {
customer: stripeCustomerId,
subscription: stripeSubscriptionId,
amount: atmnToStripeAmount({ amount: finalAmount }),
currency,
description,
period: billingPeriod
? {
start: msToSeconds(billingPeriod.start),
end: msToSeconds(billingPeriod.end),
}
: undefined,
};
};
/**
* Converts an array of LineItems to Stripe.InvoiceItemCreateParams[]
*/
export const lineItemsToCreateInvoiceItemsParams = ({
stripeCustomerId,
stripeSubscriptionId,
lineItems,
}: {
stripeCustomerId: string;
stripeSubscriptionId?: string;
lineItems: LineItem[];
}): Stripe.InvoiceItemCreateParams[] => {
return lineItems.map((lineItem) =>
toStripeCreateInvoiceItemParams({
stripeCustomerId,
stripeSubscriptionId,
lineItem,
}),
);
};

View File

@@ -4,7 +4,7 @@ import type Stripe from "stripe";
/**
* Converts a single LineItem to Stripe.InvoiceAddLinesParams.Line
*/
export const lineItemToStripeLine = ({
const toStripeAddLineParams = ({
lineItem,
}: {
lineItem: LineItem;
@@ -27,10 +27,10 @@ export const lineItemToStripeLine = ({
/**
* Converts an array of LineItems to Stripe.InvoiceAddLinesParams.Line[]
*/
export const lineItemsToStripeLines = ({
export const lineItemsToInvoiceAddLinesParams = ({
lineItems,
}: {
lineItems: LineItem[];
}): Stripe.InvoiceAddLinesParams.Line[] => {
return lineItems.map((lineItem) => lineItemToStripeLine({ lineItem }));
return lineItems.map((lineItem) => toStripeAddLineParams({ lineItem }));
};

View File

@@ -27,7 +27,9 @@ export const createInvoiceForBilling = async ({
invoiceMetadata?: StripeInvoiceMetadata;
}): Promise<PayInvoiceResult> => {
const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env });
const { addLineParams, invoiceMode } = stripeInvoiceAction;
const { addLineParams } = stripeInvoiceAction;
const invoiceMode = billingContext.invoiceMode;
const shouldFinalizeInvoice = invoiceMode
? invoiceMode.finalizeInvoice
: true;

View File

@@ -1,4 +1,6 @@
import type Stripe from "stripe";
import { createStripeCli } from "@/external/connect/createStripeCli";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
// ============================================
// Create Invoice
@@ -84,3 +86,22 @@ export const finalizeStripeInvoice = async ({
return invoice;
};
// ============================================
// Create Invoice Items
// ============================================
export type CreateStripeInvoiceItemsParams = {
ctx: AutumnContext;
invoiceItems: Stripe.InvoiceItemCreateParams[];
};
export const createStripeInvoiceItems = async ({
ctx,
invoiceItems,
}: CreateStripeInvoiceItemsParams): Promise<void> => {
const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env });
for (const item of invoiceItems) {
await stripeCli.invoiceItems.create(item);
}
};

View File

@@ -1,10 +1,10 @@
import {
filterCustomerProductsByActiveStatuses,
filterCustomerProductsByStripeSubscriptionId,
} from "@autumn/shared";
import { customerProductToStripeItemSpecs } from "@server/internal/billing/v2/providers/stripe/utils/subscriptionItems/customerProductToStripeItemSpecs";
import type { StripeItemSpec } from "@shared/models/billingModels/stripeAdapterModels/stripeItemSpec";
import type { FullCusProduct } from "@shared/models/cusProductModels/cusProductModels";
import {
ACTIVE_STATUSES,
isCustomerProductOnStripeSubscription,
} from "@shared/utils";
import type Stripe from "stripe";
import { stripeSubscriptionItemToStripePriceId } from "@/external/stripe/subscriptions/subscriptionItems/utils/convertStripeSubscriptionItemUtils";
import { findStripeSubscriptionItemByStripePriceId } from "@/external/stripe/subscriptions/subscriptionItems/utils/findStripeSubscriptionItemUtils";
@@ -12,33 +12,6 @@ import type { AutumnContext } from "@/honoUtils/HonoEnv";
import type { BillingContext } from "@/internal/billing/v2/billingContext";
import { findStripeItemSpecByStripePriceId } from "./findStripeItemSpec";
const getFinalCustomerProductsState = ({
billingContext,
updatedCustomerProducts = [],
}: {
billingContext: BillingContext;
updatedCustomerProducts?: FullCusProduct[];
}) => {
const { fullCustomer, stripeSubscription } = billingContext;
const customerProducts = stripeSubscription
? fullCustomer.customer_products.filter((customerProduct) =>
isCustomerProductOnStripeSubscription({
customerProduct,
stripeSubscriptionId: stripeSubscription.id,
}),
)
: [];
return customerProducts.map((customerProduct) => {
const updated = updatedCustomerProducts.find(
(updatedCustomerProduct) =>
updatedCustomerProduct.id === customerProduct.id,
);
return updated ?? customerProduct;
});
};
const customerProductsToRecurringStripeItemSpecs = ({
ctx,
billingContext,
@@ -121,30 +94,31 @@ const stripeItemSpecsToSubItemsUpdate = ({
export const buildStripeSubscriptionItemsUpdate = ({
ctx,
billingContext,
updatedCustomerProducts = [],
finalCustomerProducts,
}: {
ctx: AutumnContext;
billingContext: BillingContext;
updatedCustomerProducts?: FullCusProduct[];
finalCustomerProducts: FullCusProduct[];
}) => {
// 1. Get final customer product state (with updates applied)
let customerProducts = getFinalCustomerProductsState({
billingContext,
updatedCustomerProducts,
// 1. Filter customer products by stripe subscription id
let customerProducts = filterCustomerProductsByStripeSubscriptionId({
customerProducts: finalCustomerProducts,
stripeSubscriptionId: billingContext.stripeSubscription?.id,
});
customerProducts = customerProducts.filter((customerProduct) =>
ACTIVE_STATUSES.includes(customerProduct.status),
);
// 2. Filter customer products by active statuses
customerProducts = filterCustomerProductsByActiveStatuses({
customerProducts,
});
// 2. Get recurring subscription item array (doesn't include one off items)
// 3. Get recurring subscription item array (doesn't include one off items)
const recurringItems = customerProductsToRecurringStripeItemSpecs({
ctx,
billingContext,
customerProducts,
});
// 3. Diff it with the current subscription items
// 4. Diff it with the current subscription items
return stripeItemSpecsToSubItemsUpdate({
billingContext,
stripeItemSpecs: recurringItems,

View File

@@ -7,8 +7,8 @@ import {
import type Stripe from "stripe";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import type { BillingContext } from "@/internal/billing/v2/billingContext";
import { customerProductToStripeItemSpecs } from "@/internal/billing/v2/utils/stripeAdapter/subscriptionItems/customerProductToStripeItemSpecs";
import { isCustomerProductActiveDuringPeriod } from "@/internal/billing/v2/utils/stripeAdapter/subscriptionSchedules/isCustomerProductActiveAtEpochMs";
import { customerProductToStripeItemSpecs } from "@/internal/billing/v2/providers/stripe/utils/subscriptionItems/customerProductToStripeItemSpecs";
import { isCustomerProductActiveDuringPeriod } from "@/internal/billing/v2/providers/stripe/utils/subscriptionSchedules/isCustomerProductActiveAtEpochMs";
import { buildTransitionPoints } from "./buildTransitionPoints";
/**

View File

@@ -25,14 +25,6 @@ export const buildStripeSubscriptionCreateAction = ({
const isFreeTrialWithCardRequired = Boolean(freeTrial?.card_required);
const isCustomPaymentMethod = paymentMethod?.type === "custom";
// add_invoice_items: invoiceItems,
// collection_method: invoiceOnly ? "send_invoice" : "charge_automatically",
// days_until_due: invoiceOnly ? 30 : undefined,
// billing cycle anchor?
// discounts?
// expand: ["latest_invoice"],
// metadata?
const stripeSubscriptionCreateParams: Stripe.SubscriptionCreateParams = {
customer: stripeCustomer.id,
items: subItemsUpdate.map((item) => ({
@@ -42,6 +34,8 @@ export const buildStripeSubscriptionCreateAction = ({
billing_mode: { type: "flexible" },
collection_method: "charge_automatically",
payment_behavior: isCustomPaymentMethod
? "default_incomplete"
: "error_if_incomplete",

View File

@@ -9,6 +9,8 @@ import {
InternalError,
type LineItemContext,
orgToCurrency,
priceToProrationConfig,
sumValues,
usagePriceToLineItem,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
@@ -52,6 +54,18 @@ export const buildQuantityUpdateLineItems = ({
});
}
// Get proration config based on price and direction (upgrade/downgrade)
const isUpgrade = quantityDifferenceForEntitlements > 0;
const { shouldApplyProration, chargeImmediately, skipLineItems } =
priceToProrationConfig({
price: customerPrice.price,
isUpgrade,
});
if (skipLineItems) {
return [];
}
// Clone entitlement with updated quantity for the charge line item
const newCustomerEntitlement = cloneEntitlementWithUpdatedQuantity({
customerEntitlement: prepaidCustomerEntitlement,
@@ -76,12 +90,23 @@ export const buildQuantityUpdateLineItems = ({
...lineItemContext,
direction: "refund",
},
shouldProrateOverride: shouldApplyProration,
chargeImmediatelyOverride: chargeImmediately,
});
const chargeLineItem = usagePriceToLineItem({
cusEnt: newCustomerEntitlement,
context: lineItemContext,
shouldProrateOverride: shouldApplyProration,
chargeImmediatelyOverride: chargeImmediately,
});
// Don't return line items if they sum to 0
if (
sumValues([refundLineItem.finalAmount, chargeLineItem.finalAmount]) === 0
) {
return [];
}
return [refundLineItem, chargeLineItem];
};

View File

@@ -7,7 +7,7 @@ import {
import { getLineItemBillingPeriod } from "@shared/utils/billingUtils/cycleUtils/getLineItemBillingPeriod";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import type { QuantityUpdateDetails } from "@/internal/billing/v2/typesOld";
import type { UpdateSubscriptionContext } from "../fetch/updateSubscriptionContextSchema";
import type { UpdateSubscriptionBillingContext } from "../../billingContext";
import { buildQuantityUpdateLineItems } from "./buildQuantityUpdateLineItems";
import { calculateCustomerEntitlementChange } from "./quantityUpdateUtils/calculateCustomerEntitlementChange";
import { calculateQuantityDifferences } from "./quantityUpdateUtils/calculateQuantityDifferences";
@@ -33,7 +33,7 @@ export const computeQuantityUpdateDetails = ({
}: {
ctx: AutumnContext;
updatedOptions: FeatureOptions;
updateSubscriptionContext: UpdateSubscriptionContext;
updateSubscriptionContext: UpdateSubscriptionBillingContext;
}): QuantityUpdateDetails => {
const { customerProduct, currentEpochMs, billingCycleAnchorMs } =
updateSubscriptionContext;
@@ -78,7 +78,15 @@ export const computeQuantityUpdateDetails = ({
const priceConfiguration = resolvePriceForQuantityUpdate({
customerProduct,
updatedOptions,
isUpgrade: quantityDifferences.isUpgrade,
});
const { customerEntitlementId, customerEntitlementBalanceChange } =
calculateCustomerEntitlementChange({
quantityDifferenceForEntitlements:
quantityDifferences.quantityDifferenceForEntitlements,
billingUnitsPerQuantity: priceConfiguration.billingUnitsPerQuantity,
customerPrice: priceConfiguration.customerPrice,
customerEntitlements: customerProduct.customer_entitlements,
});
const billingPeriod = getLineItemBillingPeriod({
@@ -103,15 +111,6 @@ export const computeQuantityUpdateDetails = ({
currentEpochMs,
});
const { customerEntitlementId, customerEntitlementBalanceChange } =
calculateCustomerEntitlementChange({
quantityDifferenceForEntitlements:
quantityDifferences.quantityDifferenceForEntitlements,
billingUnitsPerQuantity: priceConfiguration.billingUnitsPerQuantity,
customerPrice: priceConfiguration.customerPrice,
customerEntitlements: customerProduct.customer_entitlements,
});
return {
featureId,
customerEntitlementId,

View File

@@ -5,13 +5,13 @@ import {
isCustomerProductOneOff,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import type { UpdateSubscriptionBillingContext } from "@/internal/billing/v2/billingContext";
import { buildAutumnLineItems } from "@/internal/billing/v2/compute/computeAutumnUtils/buildAutumnLineItems";
import type { UpdateSubscriptionContext } from "@/internal/billing/v2/subscriptionUpdate/fetch/updateSubscriptionContextSchema";
import type {
StripeInvoiceAction,
StripeSubscriptionAction,
} from "@/internal/billing/v2/types/billingPlan";
import { lineItemsToStripeLines } from "../../../providers/stripe/utils/invoiceLines/lineItemsToStripeLines";
import { lineItemsToInvoiceAddLinesParams } from "../../../providers/stripe/utils/invoiceLines/lineItemsToInvoiceAddLinesParams";
export const computeInvoiceAction = ({
ctx,
@@ -21,7 +21,7 @@ export const computeInvoiceAction = ({
billingCycleAnchor,
}: {
ctx: AutumnContext;
billingContext: UpdateSubscriptionContext;
billingContext: UpdateSubscriptionBillingContext;
newCustomerProduct: FullCusProduct;
stripeSubscriptionAction?: StripeSubscriptionAction;
billingCycleAnchor?: number;
@@ -61,7 +61,7 @@ export const computeInvoiceAction = ({
testClockFrozenTime: billingContext.testClockFrozenTime,
});
const addLineParams = lineItemsToStripeLines({
const addLineParams = lineItemsToInvoiceAddLinesParams({
lineItems: autumnLineItems,
});

View File

@@ -1,10 +1,12 @@
import {
CusProductStatus,
cusProductToProduct,
type SubscriptionUpdateV0Params,
secondsToMs,
type UpdateSubscriptionV0Params,
} from "@autumn/shared";
import type { AutumnContext } from "@server/honoUtils/HonoEnv";
import type { UpdateSubscriptionContext } from "@server/internal/billing/v2/subscriptionUpdate/fetch/updateSubscriptionContextSchema";
import type { UpdateSubscriptionBillingContext } from "@server/internal/billing/v2/billingContext";
import { buildAutumnLineItems } from "@/internal/billing/v2/compute/computeAutumnUtils/buildAutumnLineItems";
import { computeSubscriptionUpdateFreeTrialPlan } from "@/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateFreeTrialPlan";
import { computeSubscriptionUpdateNewCustomerProduct } from "@/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateNewCustomerProduct";
import type { AutumnBillingPlan } from "@/internal/billing/v2/types/billingPlan";
@@ -16,8 +18,8 @@ export const computeSubscriptionUpdateCustomPlan = async ({
params,
}: {
ctx: AutumnContext;
updateSubscriptionContext: UpdateSubscriptionContext;
params: SubscriptionUpdateV0Params;
updateSubscriptionContext: UpdateSubscriptionBillingContext;
params: UpdateSubscriptionV0Params;
}) => {
const { customerProduct, stripeSubscription } = updateSubscriptionContext;
@@ -36,7 +38,7 @@ export const computeSubscriptionUpdateCustomPlan = async ({
customItems: params.items,
});
updateSubscriptionContext.fullProducts.push(customFullProduct);
updateSubscriptionContext.fullProducts = [customFullProduct];
// 2. Compute the custom trial details
const { freeTrialPlan, customFreeTrial } =
@@ -46,12 +48,10 @@ export const computeSubscriptionUpdateCustomPlan = async ({
fullProduct: customFullProduct,
});
const billingCycleAnchor =
updateSubscriptionContext.billingCycleAnchorMs =
freeTrialPlan.trialEndsAt ??
secondsToMs(stripeSubscription?.billing_cycle_anchor);
const nowMs = updateSubscriptionContext.testClockFrozenTime ?? Date.now();
// 3. Compute the new customer product
const newFullCustomerProduct = computeSubscriptionUpdateNewCustomerProduct({
ctx,
@@ -59,85 +59,24 @@ export const computeSubscriptionUpdateCustomPlan = async ({
params,
fullProduct: customFullProduct,
freeTrialPlan,
billingCycleAnchor,
});
// Line items?
const autumnLineItems = buildAutumnLineItems({
ctx,
newCustomerProducts: [newFullCustomerProduct],
deletedCustomerProduct: customerProduct,
billingContext: updateSubscriptionContext,
});
return {
insertCustomerProducts: [newFullCustomerProduct],
// updateCustomerProduct: {
// customerProduct: customerProduct,
// updates: {
// status: CusProductStatus.Expired,
// },
// },
updateCustomerProduct: {
...customerProduct,
status: CusProductStatus.Expired,
},
customPrices: customPrices,
customEntitlements: customEnts,
customFreeTrial: customFreeTrial,
autumnLineItems: [],
autumnLineItems,
} satisfies AutumnBillingPlan;
// 4. Create stripe prices
// const fullCustomer = updateSubscriptionContext.fullCustomer;
// await createStripeResourcesForProducts({
// ctx,
// fullCustomer,
// fullProducts: [customFullProduct],
// });
// // 5. Build subscription schedule action
// const scheduleAction = buildStripeSubscriptionScheduleAction({
// ctx,
// billingContext: updateSubscriptionContext,
// addCustomerProducts: [newFullCustomerProduct],
// removeCustomerProducts: [customerProduct],
// trialEndsAt: freeTrialPlan.trialEndsAt,
// nowMs,
// });
// // 6. Compute Stripe subscription action
// const stripeSubscriptionAction = buildStripeSubscriptionAction({
// ctx,
// billingContext: updateSubscriptionContext,
// newCustomerProduct: newFullCustomerProduct,
// stripeSubscriptionScheduleAction: scheduleAction,
// freeTrialPlan,
// nowMs,
// });
// // 6. Compute subscription schedule action
// const stripeInvoiceAction = computeInvoiceAction({
// ctx,
// billingContext: updateSubscriptionContext,
// newCustomerProduct: newFullCustomerProduct,
// stripeSubscriptionAction,
// billingCycleAnchor,
// });
// const billingPlan: BillingPlan = {
// stripe: {
// subscriptionAction: stripeSubscriptionAction,
// invoiceAction: stripeInvoiceAction,
// subscriptionScheduleAction: scheduleAction,
// },
// autumn: {
// freeTrialPlan,
// insertCustomerProducts: [newFullCustomerProduct],
// updateCustomerProduct: {
// customerProduct: customerProduct,
// updates: {
// status: CusProductStatus.Expired,
// },
// },
// customPrices: customPrices,
// customEntitlements: customEnts,
// customFreeTrial: customFreeTrial,
// },
// };
// return billingPlan;
};

View File

@@ -6,7 +6,7 @@ import {
InternalError,
isPrepaidPrice,
priceToFeature,
type SubscriptionUpdateV0Params,
type UpdateSubscriptionV0Params,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { paramsToFeatureOptions } from "@/internal/billing/v2/compute/computeAutumnUtils/paramsToFeatureOptions";
@@ -23,7 +23,7 @@ export const computeSubscriptionUpdateFeatureQuantities = ({
ctx: AutumnContext;
fullProduct: FullProduct;
currentCustomerProduct: FullCusProduct;
params: SubscriptionUpdateV0Params;
params: UpdateSubscriptionV0Params;
}) => {
const newFeatureQuantities: FeatureOptions[] = [];
for (const price of fullProduct.prices) {

View File

@@ -1,7 +1,7 @@
import type {
FreeTrial,
FullProduct,
SubscriptionUpdateV0Params,
UpdateSubscriptionV0Params,
} from "@autumn/shared";
import {
addDuration,
@@ -10,7 +10,7 @@ import {
secondsToMs,
} from "@autumn/shared";
import { isStripeSubscriptionTrialing } from "@/external/stripe/subscriptions/utils/classifyStripeSubscriptionUtils";
import type { UpdateSubscriptionContext } from "@/internal/billing/v2/subscriptionUpdate/fetch/updateSubscriptionContextSchema";
import type { UpdateSubscriptionBillingContext } from "@/internal/billing/v2/billingContext";
interface ComputeSubscriptionUpdateTrialDetailsResult {
freeTrialPlan: {
@@ -25,8 +25,8 @@ export const computeSubscriptionUpdateFreeTrialPlan = ({
params,
fullProduct,
}: {
updateSubscriptionContext: UpdateSubscriptionContext;
params: SubscriptionUpdateV0Params;
updateSubscriptionContext: UpdateSubscriptionBillingContext;
params: UpdateSubscriptionV0Params;
fullProduct: FullProduct;
}): ComputeSubscriptionUpdateTrialDetailsResult => {
const { stripeSubscription, customerProduct, currentEpochMs } =

View File

@@ -1,11 +1,7 @@
import {
type FullProduct,
InternalError,
type SubscriptionUpdateV0Params,
} from "@autumn/shared";
import type { FullProduct, UpdateSubscriptionV0Params } from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import type { UpdateSubscriptionBillingContext } from "@/internal/billing/v2/billingContext";
import { computeSubscriptionUpdateFeatureQuantities } from "@/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateFeatureQuantities";
import type { UpdateSubscriptionContext } from "@/internal/billing/v2/subscriptionUpdate/fetch/updateSubscriptionContextSchema";
import type { FreeTrialPlan } from "@/internal/billing/v2/types/billingPlan";
import { cusProductToExistingRollovers } from "@/internal/billing/v2/utils/handleExistingRollovers/cusProductToExistingRollovers";
import { cusProductToExistingUsages } from "@/internal/billing/v2/utils/handleExistingUsages/cusProductToExistingUsages";
@@ -17,29 +13,22 @@ export const computeSubscriptionUpdateNewCustomerProduct = ({
updateSubscriptionContext,
fullProduct,
freeTrialPlan,
billingCycleAnchor,
}: {
ctx: AutumnContext;
params: SubscriptionUpdateV0Params;
updateSubscriptionContext: UpdateSubscriptionContext;
params: UpdateSubscriptionV0Params;
updateSubscriptionContext: UpdateSubscriptionBillingContext;
fullProduct: FullProduct;
freeTrialPlan: FreeTrialPlan;
billingCycleAnchor?: number;
}) => {
const {
customerProduct,
fullCustomer,
stripeSubscription,
stripeSubscriptionSchedule,
billingCycleAnchorMs,
currentEpochMs,
} = updateSubscriptionContext;
if (!stripeSubscription) {
throw new InternalError({
message: `[Subscription Update] Stripe subscription not found`,
});
}
// 1. Get feature quantities
const existingUsages = cusProductToExistingUsages({
cusProduct: customerProduct,
@@ -67,7 +56,7 @@ export const computeSubscriptionUpdateNewCustomerProduct = ({
featureQuantities,
existingUsages,
existingRollovers,
resetCycleAnchor: billingCycleAnchor ?? "now",
resetCycleAnchor: billingCycleAnchorMs ?? "now",
now: currentEpochMs,
freeTrial: freeTrialPlan.freeTrial ?? null,
@@ -76,7 +65,7 @@ export const computeSubscriptionUpdateNewCustomerProduct = ({
initOptions: {
isCustom: true,
subscriptionId: stripeSubscription.id,
subscriptionId: stripeSubscription?.id,
subscriptionScheduleId: stripeSubscriptionSchedule?.id,
},
});

View File

@@ -1,4 +1,4 @@
import type { SubscriptionUpdateV0Params } from "@shared/index";
import type { UpdateSubscriptionV0Params } from "@shared/index";
import { SubscriptionUpdateIntentEnum } from "./computeSubscriptionUpdateSchema";
/**
@@ -7,7 +7,7 @@ import { SubscriptionUpdateIntentEnum } from "./computeSubscriptionUpdateSchema"
* @returns The intent for the subscription update
*/
export const computeSubscriptionUpdateIntent = (
params: SubscriptionUpdateV0Params,
params: UpdateSubscriptionV0Params,
): SubscriptionUpdateIntentEnum => {
if (params.options?.length && !params.items?.length)
return SubscriptionUpdateIntentEnum.UpdateQuantity;

View File

@@ -1,10 +1,10 @@
import type { SubscriptionUpdateV0Params } from "@shared/index";
import type { UpdateSubscriptionV0Params } from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import type { UpdateSubscriptionBillingContext } from "@/internal/billing/v2/billingContext";
import { computeSubscriptionUpdateCustomPlan } from "@/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateCustomPlan";
import { computeSubscriptionUpdateQuantityPlan } from "@/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateQuantityPlan";
import { SubscriptionUpdateIntentEnum } from "@/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateSchema";
import type { AutumnBillingPlan } from "@/internal/billing/v2/types/billingPlan";
import type { UpdateSubscriptionContext } from "../fetch/updateSubscriptionContextSchema";
import { computeSubscriptionUpdateIntent } from "./computeSubscriptionUpdateIntent";
/**
@@ -15,12 +15,12 @@ import { computeSubscriptionUpdateIntent } from "./computeSubscriptionUpdateInte
*/
export const computeSubscriptionUpdatePlan = async ({
ctx,
updateSubscriptionContext,
billingContext,
params,
}: {
ctx: AutumnContext;
updateSubscriptionContext: UpdateSubscriptionContext;
params: SubscriptionUpdateV0Params;
billingContext: UpdateSubscriptionBillingContext;
params: UpdateSubscriptionV0Params;
}): Promise<AutumnBillingPlan> => {
const intent = computeSubscriptionUpdateIntent(params);
@@ -28,13 +28,13 @@ export const computeSubscriptionUpdatePlan = async ({
case SubscriptionUpdateIntentEnum.UpdateQuantity:
return computeSubscriptionUpdateQuantityPlan({
ctx,
updateSubscriptionContext,
updateSubscriptionContext: billingContext,
params,
});
case SubscriptionUpdateIntentEnum.UpdatePlan:
return await computeSubscriptionUpdateCustomPlan({
ctx,
updateSubscriptionContext,
updateSubscriptionContext: billingContext,
params,
});
}

View File

@@ -1,7 +1,7 @@
import { InternalError, type SubscriptionUpdateV0Params } from "@autumn/shared";
import { InternalError, type UpdateSubscriptionV0Params } from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import type { AutumnBillingPlan } from "../../types/billingPlan";
import type { UpdateSubscriptionContext } from "../fetch/updateSubscriptionContextSchema";
import type { UpdateSubscriptionBillingContext } from "../../billingContext";
import { computeQuantityUpdateDetails } from "./computeQuantityUpdateDetails";
export const computeSubscriptionUpdateQuantityPlan = ({
@@ -10,8 +10,8 @@ export const computeSubscriptionUpdateQuantityPlan = ({
params,
}: {
ctx: AutumnContext;
updateSubscriptionContext: UpdateSubscriptionContext;
params: SubscriptionUpdateV0Params;
updateSubscriptionContext: UpdateSubscriptionBillingContext;
params: UpdateSubscriptionV0Params;
}): AutumnBillingPlan => {
const { customerProduct, stripeSubscription } = updateSubscriptionContext;

View File

@@ -5,7 +5,7 @@ import {
priceToLineAmount,
} from "@autumn/shared";
import { Decimal } from "decimal.js";
import type { UpdateSubscriptionContext } from "../../fetch/updateSubscriptionContextSchema";
import type { UpdateSubscriptionBillingContext } from "../../../billingContext";
import type { calculateQuantityDifferences } from "./calculateQuantityDifferences";
import type { resolvePriceForQuantityUpdate } from "./resolvePriceForQuantityUpdate";
@@ -31,7 +31,7 @@ export const calculateProrationAmount = ({
quantityDifferences,
billingPeriod,
}: {
updateSubscriptionContext: UpdateSubscriptionContext;
updateSubscriptionContext: UpdateSubscriptionBillingContext;
previousOptions: FeatureOptions;
updatedOptions: FeatureOptions;
priceConfiguration: ReturnType<typeof resolvePriceForQuantityUpdate>;

View File

@@ -4,40 +4,31 @@ import {
type FullCustomerPrice,
findCusPriceByFeature,
InternalError,
type OnDecrease,
type OnIncrease,
type Price,
priceToProrationConfig,
type UsagePriceConfig,
} from "@autumn/shared";
/**
* Extracts and validates price configuration for a quantity update.
*
* Determines proration behavior (on_increase/on_decrease) based on quantity change direction.
* Finds the customer price and extracts billing units per quantity.
*
* @param updatedOptions - New feature options with target quantity
* @param customerProduct - Customer product with associated prices
* @param previousOptions - Current feature options
* @returns Price config including proration rules and billing units
* @returns Price config including billing units
* @throws {InternalError} When internal_feature_id is missing or price not found
*/
export const resolvePriceForQuantityUpdate = ({
customerProduct,
updatedOptions,
isUpgrade,
}: {
customerProduct: FullCusProduct;
updatedOptions: FeatureOptions;
isUpgrade: boolean;
}): {
customerPrice: FullCustomerPrice;
price: Price;
priceConfig: UsagePriceConfig;
billingUnitsPerQuantity: number;
prorationBehaviorConfig: OnIncrease | OnDecrease;
shouldApplyProration: boolean;
shouldFinalizeInvoiceImmediately: boolean;
} => {
if (!updatedOptions.internal_feature_id) {
throw new InternalError({
@@ -60,22 +51,10 @@ export const resolvePriceForQuantityUpdate = ({
const priceConfig = price.config as UsagePriceConfig;
const billingUnitsPerQuantity = priceConfig.billing_units ?? 1;
const {
prorationBehaviorConfig,
shouldApplyProration,
shouldFinalizeInvoiceImmediately,
} = priceToProrationConfig({
price,
isUpgrade,
});
return {
customerPrice,
price,
priceConfig,
billingUnitsPerQuantity,
prorationBehaviorConfig,
shouldApplyProration,
shouldFinalizeInvoiceImmediately,
};
};

View File

@@ -1,48 +0,0 @@
import type { SubscriptionUpdateV0Params } from "@shared/index";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { buildStripeInvoiceAction } from "../../providers/stripe/actionBuilders/buildStripeInvoiceAction";
import { buildStripeSubscriptionAction } from "../../providers/stripe/actionBuilders/buildStripeSubscriptionAction";
import type {
AutumnBillingPlan,
StripeBillingPlan,
} from "../../types/billingPlan";
import type { UpdateSubscriptionContext } from "../fetch/updateSubscriptionContextSchema";
export const evaluateSubscriptionUpdatePlan = ({
ctx,
updateSubscriptionContext,
params,
autumnBillingPlan,
}: {
ctx: AutumnContext;
updateSubscriptionContext: UpdateSubscriptionContext;
params: SubscriptionUpdateV0Params;
autumnBillingPlan: AutumnBillingPlan;
}): StripeBillingPlan => {
const updatedCustomerProducts = autumnBillingPlan.updateCustomerProduct
? [autumnBillingPlan.updateCustomerProduct]
: [];
const stripeSubscriptionAction = buildStripeSubscriptionAction({
ctx,
billingContext: updateSubscriptionContext,
updatedCustomerProducts,
});
const invoiceModeEnabled = params.invoice === true;
const stripeInvoiceAction = buildStripeInvoiceAction({
autumnLineItems: autumnBillingPlan.autumnLineItems,
invoiceMode: invoiceModeEnabled
? {
finalizeInvoice: params.finalize_invoice === true,
enableProductImmediately: params.enable_product_immediately !== false,
}
: undefined,
});
return {
subscriptionAction: stripeSubscriptionAction,
invoiceAction: stripeInvoiceAction,
};
};

View File

@@ -1,75 +0,0 @@
import type { SubscriptionUpdateV0Params } from "@shared/index";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import type { SubscriptionUpdatePlan } from "@/internal/billing/v2/typesOld";
import { executeCusProductActions } from "../../execute/executeAutumnActions/executeCusProductActions";
import { executeInvoiceAction } from "../../execute/executeInvoiceAction";
import { executeStripeSubAction } from "../../execute/executeStripeSubAction";
import { executeStripeSubscriptionUncancel } from "../../execute/executeStripeSubscriptionActions/handleStripeSubscriptionUncancel";
import type { UpdateSubscriptionContext } from "../fetch/updateSubscriptionContextSchema";
/**
* Executes the subscription update flow in stripe & autumn using context & plan.
*
* @param ctx - The context object.
* @param params - The parameters for the subscription update.
* @param updateSubscriptionContext - The context object for the subscription update.
* @param subscriptionUpdatePlan - The plan for the subscription update.
* @returns A promise that resolves when the subscription update is complete.
*/
export const executeSubscriptionUpdate = async ({
ctx,
params,
updateSubscriptionContext,
subscriptionUpdatePlan,
}: {
ctx: AutumnContext;
params: SubscriptionUpdateV0Params;
updateSubscriptionContext: UpdateSubscriptionContext;
subscriptionUpdatePlan: SubscriptionUpdatePlan;
}) => {
const { logger } = ctx;
const { customerProduct, stripeSubscription } = updateSubscriptionContext;
const {
ongoingCusProductAction,
stripeSubscriptionAction,
quantityUpdateDetails,
invoiceAction,
shouldUncancelSubscription,
} = subscriptionUpdatePlan;
if (shouldUncancelSubscription) {
await executeStripeSubscriptionUncancel({
ctx,
stripeSubscriptionId: stripeSubscription.id,
customerProduct,
});
}
logger.info("Executing Stripe subscription action");
await executeStripeSubAction({
ctx,
stripeSubAction: stripeSubscriptionAction,
});
if (invoiceAction) {
logger.info("Executing invoice action");
await executeInvoiceAction({
ctx,
invoiceAction,
updateSubscriptionContext,
});
} else {
logger.info("No invoice action required");
}
logger.info("Executing customer product actions");
await executeCusProductActions({
ctx,
ongoingCusProductAction,
newCusProducts: [],
quantityUpdateDetails,
updatedFeatureOptions: params.options || [],
});
logger.info("Successfully completed subscription update");
};

View File

@@ -1,14 +1,14 @@
import {
type FullCustomer,
isCusProductOnEntity,
type SubscriptionUpdateV0Params,
type UpdateSubscriptionV0Params,
} from "@autumn/shared";
export const fetchTargetCusProductForUpdate = ({
params,
fullCustomer,
}: {
params: SubscriptionUpdateV0Params;
params: UpdateSubscriptionV0Params;
fullCustomer: FullCustomer;
}) => {
const cusProducts = fullCustomer.customer_products;

View File

@@ -1,16 +1,17 @@
import {
cusProductToProduct,
InternalError,
type SubscriptionUpdateV0Params,
secondsToMs,
} from "@shared/index";
type UpdateSubscriptionV0Params,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { fetchStripeCustomerForBilling } from "@/internal/billing/v2/providers/stripe/fetch/fetchStripeCustomerForBilling";
import { fetchStripeSubscriptionForBilling } from "@/internal/billing/v2/providers/stripe/fetch/fetchStripeSubscriptionForBilling";
import { fetchStripeSubscriptionScheduleForBilling } from "@/internal/billing/v2/providers/stripe/fetch/fetchStripeSubscriptionScheduleForBilling";
import { CusService } from "../../../../customers/CusService";
import type { UpdateSubscriptionBillingContext } from "../../billingContext";
import { parseFeatureQuantitiesParams } from "../../utils/parseFeatureQuantitiesParams";
import { fetchTargetCusProductForUpdate } from "./fetchTargetCusProductForUpdate";
import type { UpdateSubscriptionContext } from "./updateSubscriptionContextSchema";
/**
* Fetch the context for updating a subscription
@@ -18,13 +19,13 @@ import type { UpdateSubscriptionContext } from "./updateSubscriptionContextSchem
* @param body - The body of the request
* @returns The update subscription context
*/
export const fetchApiSubscriptionUpdateContext = async ({
export const fetchUpdateSubscriptionBillingContext = async ({
ctx,
params,
}: {
ctx: AutumnContext;
params: SubscriptionUpdateV0Params;
}): Promise<UpdateSubscriptionContext> => {
params: UpdateSubscriptionV0Params;
}): Promise<UpdateSubscriptionBillingContext> => {
const { db, org, env, features } = ctx;
const { customer_id: customerId, product_id: productId } = params;
@@ -49,6 +50,10 @@ export const fetchApiSubscriptionUpdateContext = async ({
});
}
const fullProduct = cusProductToProduct({
cusProduct: targetCustomerProduct,
});
const stripeSubscription = await fetchStripeSubscriptionForBilling({
ctx,
fullCus: fullCustomer,
@@ -91,16 +96,26 @@ export const fetchApiSubscriptionUpdateContext = async ({
stripeSubscription?.billing_cycle_anchor,
);
// Invoice mode
const invoiceMode =
params?.invoice === true
? {
finalizeInvoice: params.finalize_invoice === true,
enableProductImmediately: params.enable_product_immediately !== false,
}
: undefined;
return {
fullCustomer,
fullProducts: [],
fullProducts: [fullProduct],
customerProduct: targetCustomerProduct,
stripeSubscription,
stripeSubscriptionSchedule,
stripeCustomer,
paymentMethod,
testClockFrozenTime,
currentEpochMs,
billingCycleAnchorMs,
invoiceMode,
};
};

View File

@@ -1,15 +0,0 @@
import type { FullCusProduct, FullCustomer, FullProduct } from "@shared/index";
import type Stripe from "stripe";
export type UpdateSubscriptionContext = {
fullCustomer: FullCustomer;
fullProducts: FullProduct[];
customerProduct: FullCusProduct;
stripeSubscription?: Stripe.Subscription;
stripeSubscriptionSchedule?: Stripe.SubscriptionSchedule;
stripeCustomer: Stripe.Customer;
paymentMethod?: Stripe.PaymentMethod;
testClockFrozenTime?: number;
currentEpochMs: number;
billingCycleAnchorMs?: number;
};

View File

@@ -1,25 +0,0 @@
import { SubscriptionUpdateV0ParamsSchema } from "@autumn/shared";
import { createRoute } from "../../../../honoMiddlewares/routeHandler";
import { computeSubscriptionUpdatePlan } from "../subscriptionUpdate/compute/computeSubscriptionUpdatePlan";
import { fetchApiSubscriptionUpdateContext } from "../subscriptionUpdate/fetch/fetchApiSubscriptionUpdateContext";
export const handleSubscriptionUpdatePreview = createRoute({
body: SubscriptionUpdateV0ParamsSchema,
handler: async (c) => {
const ctx = c.get("ctx");
const body = c.req.valid("json");
const updateSubscriptionContext = await fetchApiSubscriptionUpdateContext({
ctx,
params: body,
});
const subscriptionUpdatePlan = await computeSubscriptionUpdatePlan({
ctx,
updateSubscriptionContext,
params: body,
});
return c.json(subscriptionUpdatePlan, 200);
},
});

View File

@@ -0,0 +1,39 @@
import { UpdateSubscriptionV0ParamsSchema } from "@autumn/shared";
import { evaluateStripeBillingPlan } from "@/internal/billing/v2/providers/stripe/actionBuilders/evaluateStripeBillingPlan";
import { createRoute } from "../../../../honoMiddlewares/routeHandler";
import { computeSubscriptionUpdatePlan } from "./compute/computeSubscriptionUpdatePlan";
import { fetchUpdateSubscriptionBillingContext } from "./fetch/fetchUpdateSubscriptionBillingContext";
export const handleUpdateSubscriptionPreview = createRoute({
body: UpdateSubscriptionV0ParamsSchema,
handler: async (c) => {
const ctx = c.get("ctx");
const body = c.req.valid("json");
const updateSubscriptionBillingContext =
await fetchUpdateSubscriptionBillingContext({
ctx,
params: body,
});
const autumnBillingPlan = await computeSubscriptionUpdatePlan({
ctx,
billingContext: updateSubscriptionBillingContext,
params: body,
});
const stripeBillingPlan = evaluateStripeBillingPlan({
ctx,
billingContext: updateSubscriptionBillingContext,
autumnBillingPlan,
});
return c.json(
{
autumn: autumnBillingPlan,
stripe: stripeBillingPlan,
},
200,
);
},
});

View File

@@ -6,6 +6,8 @@ import {
PriceSchema,
} from "@autumn/shared";
import { z } from "zod/v4";
import type { BillingContext } from "@/internal/billing/v2/billingContext";
import type { BillingPlan } from "@/internal/billing/v2/types/billingPlan";
import { FullCusProductSchema } from "../../../../../../shared/models/cusProductModels/cusProductModels";
export const FreeTrialPlanSchema = z.object({
@@ -15,13 +17,6 @@ export const FreeTrialPlanSchema = z.object({
export type FreeTrialPlan = z.infer<typeof FreeTrialPlanSchema>;
export const InvoiceModeSchema = z.object({
finalizeInvoice: z.boolean().default(false),
enableProductImmediately: z.boolean().default(true),
});
export type InvoiceMode = z.infer<typeof InvoiceModeSchema>;
export const UpdateCustomerEntitlementSchema = z.object({
customerEntitlementId: z.string(),
balanceChange: z.number(),
@@ -50,5 +45,6 @@ export type AutumnBillingPlan = z.infer<typeof AutumnBillingPlanSchema>;
export type DeferredAutumnBillingPlanData = {
orgId: string;
env: AppEnv;
autumnBillingPlan: AutumnBillingPlan;
billingPlan: BillingPlan;
billingContext: BillingContext;
};

View File

@@ -12,6 +12,8 @@ import {
StripeBillingPlanSchema,
type StripeInvoiceAction,
StripeInvoiceActionSchema,
type StripeInvoiceItemsAction,
StripeInvoiceItemsActionSchema,
type StripeInvoiceMetadata,
type StripeSubscriptionAction,
StripeSubscriptionActionSchema,
@@ -24,6 +26,7 @@ export {
InvoiceModeSchema,
StripeBillingPlanSchema,
StripeInvoiceActionSchema,
StripeInvoiceItemsActionSchema,
StripeSubscriptionActionSchema,
StripeSubscriptionScheduleActionSchema,
type AutumnBillingPlan,
@@ -32,6 +35,7 @@ export {
type InvoiceMode,
type StripeBillingPlan,
type StripeInvoiceAction,
type StripeInvoiceItemsAction,
type StripeInvoiceMetadata,
type StripeSubscriptionAction,
type StripeSubscriptionScheduleAction,

View File

@@ -1,22 +1,28 @@
import { z } from "zod/v4";
import {
StripeInvoiceActionSchema,
type StripeInvoiceAction,
StripeInvoiceActionSchema,
} from "./stripeInvoiceAction";
import {
StripeSubscriptionActionSchema,
type StripeInvoiceItemsAction,
StripeInvoiceItemsActionSchema,
} from "./stripeInvoiceItemsAction";
import {
type StripeSubscriptionAction,
StripeSubscriptionActionSchema,
} from "./stripeSubscriptionAction";
import {
StripeSubscriptionScheduleActionSchema,
type StripeSubscriptionScheduleAction,
StripeSubscriptionScheduleActionSchema,
} from "./stripeSubscriptionScheduleAction";
export {
StripeInvoiceActionSchema,
StripeInvoiceItemsActionSchema,
StripeSubscriptionActionSchema,
StripeSubscriptionScheduleActionSchema,
type StripeInvoiceAction,
type StripeInvoiceItemsAction,
type StripeSubscriptionAction,
type StripeSubscriptionScheduleAction,
};
@@ -25,6 +31,7 @@ export const StripeBillingPlanSchema = z.object({
subscriptionAction: StripeSubscriptionActionSchema.optional(),
subscriptionScheduleAction: StripeSubscriptionScheduleActionSchema.optional(),
invoiceAction: StripeInvoiceActionSchema.optional(),
invoiceItemsAction: StripeInvoiceItemsActionSchema.optional(),
});
export type StripeBillingPlan = z.infer<typeof StripeBillingPlanSchema>;
@@ -32,4 +39,3 @@ export type StripeBillingPlan = z.infer<typeof StripeBillingPlanSchema>;
export type StripeInvoiceMetadata = {
autumn_metadata_id: string;
};

View File

@@ -1,10 +1,7 @@
import { z } from "zod/v4";
import { InvoiceModeSchema } from "../autumnBillingPlan";
export const StripeInvoiceActionSchema = z.object({
addLineParams: z.custom<import("stripe").Stripe.InvoiceAddLinesParams>(),
invoiceMode: InvoiceModeSchema.optional(),
});
export type StripeInvoiceAction = z.infer<typeof StripeInvoiceActionSchema>;

View File

@@ -0,0 +1,11 @@
import { z } from "zod/v4";
export const StripeInvoiceItemsActionSchema = z.object({
createInvoiceItems: z.array(
z.custom<import("stripe").Stripe.InvoiceItemCreateParams>(),
),
});
export type StripeInvoiceItemsAction = z.infer<
typeof StripeInvoiceItemsActionSchema
>;

View File

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

View File

@@ -0,0 +1,43 @@
import type { BillingContext } from "@/internal/billing/v2/billingContext";
import type { AutumnBillingPlan } from "@/internal/billing/v2/types/billingPlan";
export const autumnBillingPlanToFinalFullCustomer = ({
billingContext,
autumnBillingPlan,
}: {
billingContext: BillingContext;
autumnBillingPlan: AutumnBillingPlan;
}) => {
const finalFullCustomer = structuredClone(billingContext.fullCustomer);
// 1. Update full customer with new customer products
finalFullCustomer.customer_products = [
...finalFullCustomer.customer_products,
...autumnBillingPlan.insertCustomerProducts,
];
// 2. Update customer product
for (let i = 0; i < finalFullCustomer.customer_products.length; i++) {
const customerProduct = finalFullCustomer.customer_products[i];
if (customerProduct.id === autumnBillingPlan.updateCustomerProduct?.id) {
finalFullCustomer.customer_products[i] =
autumnBillingPlan.updateCustomerProduct;
}
}
// 3. Update full customer with updated customer entitlements
if (autumnBillingPlan.updateCustomerEntitlements) {
for (const update of autumnBillingPlan.updateCustomerEntitlements) {
for (const customerProduct of finalFullCustomer.customer_products) {
for (const customerEntitlement of customerProduct.customer_entitlements) {
if (customerEntitlement.id === update.customerEntitlementId) {
customerEntitlement.balance =
(customerEntitlement.balance ?? 0) + update.balanceChange;
}
}
}
}
}
return finalFullCustomer;
};

View File

@@ -1,49 +0,0 @@
import type { FullCusProduct } from "@shared/models/cusProductModels/cusProductModels";
import {
isCustomerProductOnStripeSubscription,
isCustomerProductOnStripeSubscriptionSchedule,
} from "@shared/utils";
import type { BillingContext } from "@/internal/billing/v2/billingContext";
/**
* Gets the final customer product state after applying add/remove operations.
* Filters to only products on the current subscription.
*/
export const getFinalCustomerProductsState = ({
billingContext,
addCustomerProducts = [],
removeCustomerProducts = [],
}: {
billingContext: BillingContext;
addCustomerProducts?: FullCusProduct[];
removeCustomerProducts?: FullCusProduct[];
}): FullCusProduct[] => {
const { fullCustomer, stripeSubscription, stripeSubscriptionSchedule } =
billingContext;
// Start with existing products on this subscription
let customerProducts = stripeSubscription
? fullCustomer.customer_products.filter(
(cp) =>
isCustomerProductOnStripeSubscription({
customerProduct: cp,
stripeSubscriptionId: stripeSubscription.id,
}) ||
isCustomerProductOnStripeSubscriptionSchedule({
customerProduct: cp,
stripeSubscriptionScheduleId: stripeSubscriptionSchedule?.id ?? "",
}),
)
: [];
// Remove specified products
customerProducts = customerProducts.filter(
(cp) =>
!removeCustomerProducts.some((cpToRemove) => cpToRemove.id === cp.id),
);
// Add new products
customerProducts = [...customerProducts, ...addCustomerProducts];
return customerProducts;
};

View File

@@ -7,9 +7,14 @@ import {
import { and, eq } from "drizzle-orm";
import type { DrizzleCli } from "@/db/initDrizzle.js";
/**
* MetadataService handles CRUD operations for the metadata table.
*/
export class MetadataService {
static async insert({ db, data }: { db: DrizzleCli; data: MetadataInsert }) {
await db.insert(metadata).values(data);
const insertedMetadata = await db.insert(metadata).values(data).returning();
return insertedMetadata[0] as Metadata;
}
static async get({ db, id }: { db: DrizzleCli; id: string }) {

View File

@@ -0,0 +1,72 @@
import { generateId, InternalError, MetadataType } from "@autumn/shared";
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 {
BillingPlan,
DeferredAutumnBillingPlanData,
} from "@/internal/billing/v2/types/billingPlan";
import { MetadataService } from "../MetadataService";
/**
* Creates metadata from a billing plan and optionally links it to a Stripe invoice.
*/
export const insertMetadataFromBillingPlan = async ({
ctx,
billingPlan,
billingContext,
enableProductAfterInvoice,
invoiceActionRequired,
stripeInvoice,
}: {
ctx: AutumnContext;
billingPlan: BillingPlan;
billingContext: BillingContext;
enableProductAfterInvoice?: boolean;
invoiceActionRequired?: boolean;
stripeInvoice?: Stripe.Invoice;
}) => {
const id = generateId("meta");
const type = enableProductAfterInvoice
? MetadataType.InvoiceCheckoutV2
: invoiceActionRequired
? MetadataType.InvoiceActionRequiredV2
: undefined;
const data = {
orgId: ctx.org.id,
env: ctx.env,
billingPlan,
billingContext,
} satisfies DeferredAutumnBillingPlanData;
const metadata = await MetadataService.insert({
db: ctx.db,
data: {
id,
type,
stripe_invoice_id: stripeInvoice?.id,
data,
},
});
if (!metadata) {
throw new InternalError({
message: "Failed to insert metadata from billing plan",
});
}
// If stripeInvoice, update stripeInvoice with metadata id
if (stripeInvoice) {
const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env });
await stripeCli.invoices.update(stripeInvoice.id, {
metadata: {
autumn_metadata_id: metadata.id,
},
});
}
return metadata;
};

View File

@@ -41,7 +41,6 @@ const parseFreeTrials = ({
return product;
};
// biome-ignore lint/complexity/noStaticOnlyClass: no thanks m8
export class ProductService {
static async getByFeature({
db,

View File

@@ -56,7 +56,7 @@ export const checkForMisingBalance = async ({
const feature = cusEnt.entitlement.feature;
// 1. If unlimited or boolean feature, skip
if (isUnlimitedCusEnt({ cusEnt }) || isBooleanCusEnt({ cusEnt })) continue;
if (isUnlimitedCusEnt(cusEnt) || isBooleanCusEnt({ cusEnt })) continue;
if (isContUseFeature({ feature })) continue;

View File

@@ -1,9 +1,17 @@
import { beforeAll, describe, expect, test } from "bun:test";
import { type ApiCustomer, ApiVersion } from "@autumn/shared";
import {
type ApiCustomer,
ApiVersion,
findPriceByFeatureId,
type Price,
priceToLineAmount,
} from "@autumn/shared";
import { expectLatestInvoiceCorrect } from "@tests/billing/utils/expectLatestInvoiceCorrect";
import { TestFeature } from "@tests/setup/v2Features.js";
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
import chalk from "chalk";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { ProductService } from "@/internal/products/ProductService";
import { constructPrepaidItem } from "@/utils/scriptUtils/constructItem.js";
import { constructRawProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
@@ -30,17 +38,19 @@ describe(`${chalk.yellowBright("subscription-update: basic quantity updates")}`,
const customerId = "sub-update-basic";
const autumnV1 = new AutumnInt({ version: ApiVersion.V1_2 });
const prepaidProduct = constructRawProduct({
id: "prepaid_messages",
items: [
constructPrepaidItem({
const prepaidMessages = constructPrepaidItem({
featureId: TestFeature.Messages,
billingUnits,
price: pricePerUnit,
}),
],
});
const prepaidProduct = constructRawProduct({
id: "prepaid_messages",
items: [prepaidMessages],
});
let prepaidMessagesPrice: Price | undefined;
beforeAll(async () => {
await initCustomerV3({
ctx,
@@ -55,6 +65,18 @@ describe(`${chalk.yellowBright("subscription-update: basic quantity updates")}`,
prefix: customerId,
});
const fullProduct = await ProductService.getFull({
db: ctx.db,
idOrInternalId: prepaidProduct.id,
orgId: ctx.org.id,
env: ctx.env,
});
prepaidMessagesPrice = findPriceByFeatureId({
prices: fullProduct.prices,
featureId: TestFeature.Messages,
});
await autumnV1.attach({
customer_id: customerId,
product_id: prepaidProduct.id,
@@ -85,6 +107,16 @@ describe(`${chalk.yellowBright("subscription-update: basic quantity updates")}`,
// Should have 240 messages (20 units × 12 billing_units)
expect(balance?.purchased_balance).toBe(240);
expect(balance?.current_balance).toBe(240);
// Expect invoices to be created
expectLatestInvoiceCorrect({
customer,
productId: prepaidProduct.id,
amount: priceToLineAmount({
price: prepaidMessagesPrice!,
overage: 10 * billingUnits,
}),
});
});
test("should downgrade quantity from 20 to 5 units", async () => {
@@ -100,15 +132,29 @@ describe(`${chalk.yellowBright("subscription-update: basic quantity updates")}`,
});
const customer = await autumnV1.customers.get<ApiCustomer>(customerId);
const balance = customer.balances?.[TestFeature.Messages];
// Should have 60 messages (5 units × 12 billing_units)
expect(balance?.purchased_balance).toBe(60);
expect(balance?.current_balance).toBe(60);
// Expect credit invoice for downgrade (20 -> 5 = -15 units)
const expectedAmount = priceToLineAmount({
price: prepaidMessagesPrice!,
overage: -15 * billingUnits,
});
expectLatestInvoiceCorrect({
customer,
productId: prepaidProduct.id,
amount: expectedAmount,
});
});
test("should update to same quantity (no-op)", async () => {
const beforeUpdate = await autumnV1.customers.get<ApiCustomer>(customerId);
const beforeBalance = beforeUpdate.balances?.[TestFeature.Messages];
const beforeInvoiceCount = beforeUpdate.invoices?.length ?? 0;
await autumnV1.subscriptionUpdate({
customer_id: customerId,
@@ -123,10 +169,12 @@ describe(`${chalk.yellowBright("subscription-update: basic quantity updates")}`,
const afterUpdate = await autumnV1.customers.get<ApiCustomer>(customerId);
const afterBalance = afterUpdate.balances?.[TestFeature.Messages];
const afterInvoiceCount = afterUpdate.invoices?.length ?? 0;
expect(afterBalance?.purchased_balance).toBe(
beforeBalance?.purchased_balance,
);
expect(afterBalance?.current_balance).toBe(beforeBalance?.current_balance);
// No-op should not create a new invoice
expect(afterInvoiceCount).toBe(beforeInvoiceCount);
});
});
@@ -134,22 +182,28 @@ describe(`${chalk.yellowBright("subscription-update: multiple features")}`, () =
const customerId = "sub-update-multi";
const autumnV1 = new AutumnInt({ version: ApiVersion.V1_2 });
const messagesBillingUnits = 10;
const wordsBillingUnits = 100;
const multiFeatureProduct = constructRawProduct({
id: "multi_feature_product",
items: [
constructPrepaidItem({
featureId: TestFeature.Messages,
billingUnits: 10,
billingUnits: messagesBillingUnits,
price: 5,
}),
constructPrepaidItem({
featureId: TestFeature.Words,
billingUnits: 100,
billingUnits: wordsBillingUnits,
price: 10,
}),
],
});
let messagesPrice: Price | undefined;
let wordsPrice: Price | undefined;
beforeAll(async () => {
await initCustomerV3({
ctx,
@@ -164,17 +218,34 @@ describe(`${chalk.yellowBright("subscription-update: multiple features")}`, () =
prefix: customerId,
});
const fullProduct = await ProductService.getFull({
db: ctx.db,
idOrInternalId: multiFeatureProduct.id,
orgId: ctx.org.id,
env: ctx.env,
});
messagesPrice = findPriceByFeatureId({
prices: fullProduct.prices,
featureId: TestFeature.Messages,
});
wordsPrice = findPriceByFeatureId({
prices: fullProduct.prices,
featureId: TestFeature.Words,
});
await autumnV1.attach({
customer_id: customerId,
product_id: multiFeatureProduct.id,
options: [
{
feature_id: TestFeature.Messages,
quantity: 5 * 10, // 50 messages
quantity: 5 * messagesBillingUnits, // 50 messages
},
{
feature_id: TestFeature.Words,
quantity: 2 * 100, // 200 words
quantity: 2 * wordsBillingUnits, // 200 words
},
],
});
@@ -187,11 +258,11 @@ describe(`${chalk.yellowBright("subscription-update: multiple features")}`, () =
options: [
{
feature_id: TestFeature.Messages,
quantity: 10 * 10, // 100 messages
quantity: 10 * messagesBillingUnits, // 100 messages
},
{
feature_id: TestFeature.Words,
quantity: 5 * 100, // 500 words
quantity: 5 * wordsBillingUnits, // 500 words
},
],
});
@@ -203,6 +274,23 @@ describe(`${chalk.yellowBright("subscription-update: multiple features")}`, () =
const wordsBalance = customer.balances?.[TestFeature.Words];
expect(wordsBalance?.purchased_balance).toBe(500);
// Invoice total: Messages (5->10 units = +5) + Words (2->5 units = +3)
const expectedAmount =
priceToLineAmount({
price: messagesPrice!,
overage: 5 * messagesBillingUnits,
}) +
priceToLineAmount({
price: wordsPrice!,
overage: 3 * wordsBillingUnits,
});
expectLatestInvoiceCorrect({
customer,
productId: multiFeatureProduct.id,
amount: expectedAmount,
});
});
test("should update only one feature while keeping the other unchanged", async () => {
@@ -212,11 +300,11 @@ describe(`${chalk.yellowBright("subscription-update: multiple features")}`, () =
options: [
{
feature_id: TestFeature.Messages,
quantity: 15 * 10, // 150 messages (changed)
quantity: 15 * messagesBillingUnits, // 150 messages (changed)
},
{
feature_id: TestFeature.Words,
quantity: 5 * 100, // 500 words (unchanged)
quantity: 5 * wordsBillingUnits, // 500 words (unchanged)
},
],
});
@@ -228,5 +316,25 @@ describe(`${chalk.yellowBright("subscription-update: multiple features")}`, () =
const wordsBalance = customer.balances?.[TestFeature.Words];
expect(wordsBalance?.purchased_balance).toBe(500);
// Invoice total: Messages (10->15 units = +5), Words unchanged
const expectedAmount = priceToLineAmount({
price: messagesPrice!,
overage: 5 * messagesBillingUnits,
});
expectLatestInvoiceCorrect({
customer,
productId: multiFeatureProduct.id,
amount: expectedAmount,
});
// Verify Stripe invoice has only 2 line items
const stripeInvoices = await ctx.stripeCli.invoices.list({
customer: customer.stripe_id!,
});
const latestInvoice = stripeInvoices.data[0];
expect(latestInvoice.lines.data.length).toBe(2);
});
});

View File

@@ -0,0 +1,17 @@
import { expect } from "bun:test";
import type { ApiCustomer } from "@autumn/shared";
export const expectLatestInvoiceCorrect = ({
customer,
productId,
amount,
}: {
customer: ApiCustomer;
productId: string;
amount: number;
}) => {
const invoices = customer.invoices;
expect(invoices?.[0].total).toBe(amount);
expect(invoices?.[0].plan_ids).toContain(productId);
};

View File

@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
import type { SubscriptionUpdateV0Params } from "@autumn/shared";
import type { UpdateSubscriptionV0Params } from "@autumn/shared";
import { createMockCtx } from "@tests/utils/mockUtils/contextMocks";
import { createMockCustomerProduct } from "@tests/utils/mockUtils/cusProductMocks";
import { createMockFeature } from "@tests/utils/mockUtils/featureMocks";
@@ -41,7 +41,7 @@ describe(
customerPrices: [createMockCustomerPrice({ price })],
});
const params: SubscriptionUpdateV0Params = {
const params: UpdateSubscriptionV0Params = {
customer_id: "cus_test",
product_id: "prod_test",
// No options provided
@@ -75,7 +75,7 @@ describe(
const fullProduct = createMockFullProduct({ prices: [price] });
const cusProduct = createMockCustomerProduct({ options: [] });
const params: SubscriptionUpdateV0Params = {
const params: UpdateSubscriptionV0Params = {
customer_id: "cus_test",
product_id: "prod_test",
options: [{ feature_id: "credits", quantity: 50 }],
@@ -117,7 +117,7 @@ describe(
],
});
const params: SubscriptionUpdateV0Params = {
const params: UpdateSubscriptionV0Params = {
customer_id: "cus_test",
product_id: "prod_test",
options: [{ feature_id: "credits", quantity: 200 }],
@@ -195,7 +195,7 @@ describe(
],
});
const params: SubscriptionUpdateV0Params = {
const params: UpdateSubscriptionV0Params = {
customer_id: "cus_test",
product_id: "prod_test",
options: [{ feature_id: "seats", quantity: 10 }], // Only updating seats
@@ -240,7 +240,7 @@ describe(
const fullProduct = createMockFullProduct({ prices: [price] });
const cusProduct = createMockCustomerProduct({ options: [] });
const params: SubscriptionUpdateV0Params = {
const params: UpdateSubscriptionV0Params = {
customer_id: "cus_test",
product_id: "prod_test",
options: [{ feature_id: "credits", quantity: 150 }], // Should round up to 200
@@ -276,7 +276,7 @@ describe(
const fullProduct = createMockFullProduct({ prices: [price] });
const cusProduct = createMockCustomerProduct({ options: [] });
const params: SubscriptionUpdateV0Params = {
const params: UpdateSubscriptionV0Params = {
customer_id: "cus_test",
product_id: "prod_test",
options: [{ feature_id: "credits", quantity: 200 }], // Exact multiple
@@ -311,7 +311,7 @@ describe(
const fullProduct = createMockFullProduct({ prices: [price] });
const cusProduct = createMockCustomerProduct({ options: [] });
const params: SubscriptionUpdateV0Params = {
const params: UpdateSubscriptionV0Params = {
customer_id: "cus_test",
product_id: "prod_test",
options: [{ feature_id: "credits", quantity: 1 }], // Should round to 1000
@@ -367,7 +367,7 @@ describe(
customerPrices: [createMockCustomerPrice({ price: oldPrice })],
});
const params: SubscriptionUpdateV0Params = {
const params: UpdateSubscriptionV0Params = {
customer_id: "cus_test",
product_id: "prod_test",
// No options - should inherit from current
@@ -424,7 +424,7 @@ describe(
customerPrices: [createMockCustomerPrice({ price: oldPrice })],
});
const params: SubscriptionUpdateV0Params = {
const params: UpdateSubscriptionV0Params = {
customer_id: "cus_test",
product_id: "prod_test",
};
@@ -472,7 +472,7 @@ describe(
// No customerPrices - can't interpret stored quantity
});
const params: SubscriptionUpdateV0Params = {
const params: UpdateSubscriptionV0Params = {
customer_id: "cus_test",
product_id: "prod_test",
};
@@ -511,7 +511,7 @@ describe(
const cusProduct = createMockCustomerProduct({ options: [] });
const params: SubscriptionUpdateV0Params = {
const params: UpdateSubscriptionV0Params = {
customer_id: "cus_test",
product_id: "prod_test",
options: [{ feature_id: "credits", quantity: 50 }],
@@ -537,7 +537,7 @@ describe(
const fullProduct = createMockFullProduct({ prices: [] });
const cusProduct = createMockCustomerProduct({ options: [] });
const params: SubscriptionUpdateV0Params = {
const params: UpdateSubscriptionV0Params = {
customer_id: "cus_test",
product_id: "prod_test",
};
@@ -563,7 +563,7 @@ describe(
const fullProduct = createMockFullProduct({ prices: [price] });
const cusProduct = createMockCustomerProduct({ options: [] });
const params: SubscriptionUpdateV0Params = {
const params: UpdateSubscriptionV0Params = {
customer_id: "cus_test",
product_id: "prod_test",
};
@@ -595,7 +595,7 @@ describe(
const fullProduct = createMockFullProduct({ prices: [price] });
const cusProduct = createMockCustomerProduct({ options: [] }); // No current options
const params: SubscriptionUpdateV0Params = {
const params: UpdateSubscriptionV0Params = {
customer_id: "cus_test",
product_id: "prod_test",
// No options in params either
@@ -637,7 +637,7 @@ describe(
customerPrices: [createMockCustomerPrice({ price })],
});
const params: SubscriptionUpdateV0Params = {
const params: UpdateSubscriptionV0Params = {
customer_id: "cus_test",
product_id: "prod_test",
options: [], // Explicitly empty
@@ -680,7 +680,7 @@ describe(
customerPrices: [createMockCustomerPrice({ price })],
});
const params: SubscriptionUpdateV0Params = {
const params: UpdateSubscriptionV0Params = {
customer_id: "cus_test",
product_id: "prod_test",
options: [{ feature_id: "credits", quantity: 0 }],
@@ -726,7 +726,7 @@ describe(
customerPrices: [createMockCustomerPrice({ price })],
});
const params: SubscriptionUpdateV0Params = {
const params: UpdateSubscriptionV0Params = {
customer_id: "cus_test",
product_id: "prod_test",
};

View File

@@ -4,8 +4,8 @@ import {
AffectedResource,
defineVersionChange,
} from "../../../versionUtils/versionChangeUtils/VersionChange";
import { SubscriptionUpdateV0ParamsSchema } from "../subscriptionUpdateV0Params";
import { SubscriptionUpdateV1ParamsSchema } from "../subscriptionUpdateV1Params";
import { UpdateSubscriptionV0ParamsSchema } from "../UpdateSubscriptionV0Params";
/**
* V2_0_SubscriptionUpdateChange: Transforms subscription update params from V2.0 to V2.1 format
@@ -18,7 +18,7 @@ import { SubscriptionUpdateV1ParamsSchema } from "../subscriptionUpdateV1Params"
* 2. Removed fields: `entity_id`, `customer_data`, `entity_data`, `options`, invoice settings
* 3. Added field: `plan_override` for customizations
*
* Input: SubscriptionUpdateV0Params (V2.0 format)
* Input: UpdateSubscriptionV0Params (V2.0 format)
* Output: SubscriptionUpdateV1Params (V2.1 format)
*/
@@ -31,15 +31,15 @@ export const V2_0_SubscriptionUpdateChange = defineVersionChange({
],
affectedResources: [AffectedResource.ApiSubscriptionUpdate],
newSchema: SubscriptionUpdateV1ParamsSchema,
oldSchema: SubscriptionUpdateV0ParamsSchema,
oldSchema: UpdateSubscriptionV0ParamsSchema,
affectsRequest: true,
affectsResponse: false,
// Request: V0 (SubscriptionUpdateV0Params) → V1 (SubscriptionUpdateV1Params)
// Request: V0 (UpdateSubscriptionV0Params) → V1 (SubscriptionUpdateV1Params)
transformRequest: ({
input,
}: {
input: z.infer<typeof SubscriptionUpdateV0ParamsSchema>;
input: z.infer<typeof UpdateSubscriptionV0ParamsSchema>;
}): z.infer<typeof SubscriptionUpdateV1ParamsSchema> => {
const planId = input.product_id;

View File

@@ -5,7 +5,7 @@ import { ProductItemSchema } from "../../../models/productV2Models/productItemMo
import { CustomerDataSchema } from "../../common/customerData";
import { EntityDataSchema } from "../../models";
export const ExtSubscriptionUpdateV0ParamsSchema = z.object({
export const ExtUpdateSubscriptionV0ParamsSchema = z.object({
// Customer / Entity Info
customer_id: z.string(),
product_id: z.string().nullish(),
@@ -33,17 +33,17 @@ export const ExtSubscriptionUpdateV0ParamsSchema = z.object({
prorate_billing: z.boolean().optional(),
});
export const SubscriptionUpdateV0ParamsSchema =
ExtSubscriptionUpdateV0ParamsSchema.extend({
export const UpdateSubscriptionV0ParamsSchema =
ExtUpdateSubscriptionV0ParamsSchema.extend({
customer_product_id: z.string().optional(),
});
export type ExtSubscriptionUpdateV0Params = z.infer<
typeof ExtSubscriptionUpdateV0ParamsSchema
export type ExtUpdateSubscriptionV0Params = z.infer<
typeof ExtUpdateSubscriptionV0ParamsSchema
>;
export type SubscriptionUpdateV0Params = z.infer<
typeof SubscriptionUpdateV0ParamsSchema
export type UpdateSubscriptionV0Params = z.infer<
typeof UpdateSubscriptionV0ParamsSchema
>;
// keep_existing_plan: true, //disable_plan_switch

View File

@@ -76,9 +76,9 @@ export * from "./billing/checkout/checkoutParamsV1.js";
export * from "./billing/checkout/prevVersions/checkoutParamsV0.js";
export * from "./billing/checkout/prevVersions/checkoutParamsV0.js";
export * from "./billing/checkout/prevVersions/checkoutResponseV0.js";
export * from "./billing/subscriptionUpdate/subscriptionUpdateV0Params.js";
export * from "./billing/subscriptionUpdate/subscriptionUpdateV0Params.js";
export * from "./billing/subscriptionUpdate/subscriptionUpdateV1Params.js";
export * from "./billing/subscriptionUpdate/updateSubscriptionV0Params.js";
export * from "./common/customerData.js";
export * from "./common/entityData.js";
export * from "./common/pagePaginationSchemas.js";

View File

@@ -20,6 +20,9 @@ export const LineItemSchema = z
stripePriceId: z.string().optional(),
stripeProductId: z.string().optional(),
// Optional - for testing
chargeImmediately: z.boolean().default(true),
})
.transform((data) => {
return {

View File

@@ -7,6 +7,9 @@ export enum MetadataType {
InvoiceCheckout = "invoice_checkout",
CheckoutSessionCompleted = "checkout_session_completed",
DeferredAutumnBillingPlan = "deferred_autumn_billing_plan",
InvoiceActionRequiredV2 = "invoice_action_required_v2",
InvoiceCheckoutV2 = "invoice_checkout_v2",
}
export const metadata = pgTable("metadata", {

View File

@@ -15,6 +15,7 @@ export const buildLineItem = ({
stripePriceId,
stripeProductId,
shouldProrate = true,
chargeImmediately = true,
}: {
context: LineItemContext;
amount: number;
@@ -22,6 +23,7 @@ export const buildLineItem = ({
stripePriceId?: string;
stripeProductId?: string;
shouldProrate?: boolean;
chargeImmediately?: boolean;
}): LineItem => {
// 1. Apply proration if needed
if (shouldProrate && context.billingPeriod) {
@@ -44,5 +46,6 @@ export const buildLineItem = ({
context,
stripePriceId,
stripeProductId,
chargeImmediately,
} satisfies LineItemCreate);
};

View File

@@ -17,9 +17,13 @@ import { buildLineItem } from "./buildLineItem";
export const usagePriceToLineItem = ({
cusEnt,
context,
shouldProrateOverride,
chargeImmediatelyOverride,
}: {
cusEnt: FullCusEntWithFullCusProduct;
context: LineItemContext;
shouldProrateOverride?: boolean;
chargeImmediatelyOverride?: boolean;
}) => {
const cusPrice = cusEntToCusPrice({ cusEnt });
const { feature } = context;
@@ -75,8 +79,8 @@ export const usagePriceToLineItem = ({
// 5. Get stripe price / product IDs
const { stripePriceId, stripeProductId } = cusEntToStripeIds({ cusEnt });
// 6. Should prorate: don't if consumable price
const shouldProrate = !isConsumablePrice(price);
// 6. Should prorate: don't if consumable price (unless override provided)
const shouldProrate = shouldProrateOverride ?? !isConsumablePrice(price);
return buildLineItem({
context,
@@ -87,5 +91,6 @@ export const usagePriceToLineItem = ({
stripeProductId,
shouldProrate,
chargeImmediately: chargeImmediatelyOverride,
});
};

View File

@@ -13,8 +13,11 @@ export const tiersToLineAmount = ({
overage: number;
billingUnits?: number;
}): number => {
const isNegative = overage < 0;
const absoluteOverage = Math.abs(overage);
const roundedOverage = roundUsageToNearestBillingUnit({
usage: overage,
usage: absoluteOverage,
billingUnits,
});
@@ -47,5 +50,6 @@ export const tiersToLineAmount = ({
}
}
return amount.toDecimalPlaces(10).toNumber();
const finalAmount = amount.toDecimalPlaces(10).toNumber();
return isNegative ? -finalAmount : finalAmount;
};

View File

@@ -54,18 +54,33 @@ export const shouldProrateDowngradeNow = ({
/**
* Determines if a quantity upgrade or downgrade should be prorated.
* @param onIncrease - The proration behavior configuration for the quantity increase.
* @returns True if a quantity upgrade or downgrade should be prorated, false otherwise.
* @param prorationConfig - The proration behavior configuration.
* @returns True if proration should be applied, false otherwise.
*/
export const shouldProrate = (onIncrease?: OnIncrease | OnDecrease) => {
if (!onIncrease) {
export const shouldProrate = (prorationConfig?: OnIncrease | OnDecrease) => {
if (!prorationConfig) {
return true;
}
return (
onIncrease === OnIncrease.ProrateNextCycle ||
onIncrease === OnIncrease.ProrateImmediately ||
onIncrease === OnDecrease.ProrateImmediately ||
onIncrease === OnDecrease.ProrateNextCycle
prorationConfig === OnIncrease.ProrateNextCycle ||
prorationConfig === OnIncrease.ProrateImmediately ||
prorationConfig === OnDecrease.ProrateImmediately ||
prorationConfig === OnDecrease.ProrateNextCycle
);
};
/**
* Determines if line items should be skipped entirely (no charge or refund).
* @param prorationConfig - The proration behavior configuration.
* @returns True if line items should be skipped, false otherwise.
*/
export const shouldSkipLineItems = (
prorationConfig: OnIncrease | OnDecrease,
) => {
return (
prorationConfig === OnDecrease.NoProrations ||
prorationConfig === OnDecrease.None ||
prorationConfig === OnIncrease.BillNextCycle
);
};

View File

@@ -1,4 +1,7 @@
import { CusProductStatus, type FullCustomer } from "../../index.js";
import {
type FullCustomer,
filterCustomerProductsByActiveStatuses,
} from "../../index.js";
import type { Entity } from "../../models/cusModels/entityModels/entityModels.js";
import type { FullCustomerEntitlement } from "../../models/cusProductModels/cusEntModels/cusEntModels.js";
import type { FullCusProduct } from "../../models/cusProductModels/cusProductModels.js";
@@ -96,16 +99,6 @@ export const filterOutEntitiesFromCusProducts = ({
return finalCusProducts;
};
export const getActiveCusProducts = ({
customer,
}: {
customer: FullCustomer;
}): FullCusProduct[] => {
return customer.customer_products.filter(
(p: FullCusProduct) => p.status === CusProductStatus.Active,
);
};
export const isProductAlreadyEnabled = ({
productId,
customer,
@@ -115,7 +108,9 @@ export const isProductAlreadyEnabled = ({
customer: FullCustomer;
entityId?: string;
}) => {
return getActiveCusProducts({ customer }).some((cp: FullCusProduct) => {
return filterCustomerProductsByActiveStatuses({
customerProducts: customer.customer_products,
}).some((cp: FullCusProduct) => {
// Check if product matches and is not an add-on
if (cp.product_id !== productId || cp.product.is_add_on) {
return false;
@@ -142,17 +137,3 @@ export const isProductAlreadyEnabled = ({
return false;
});
};
export const filterCusProductsBySubId = ({
cusProducts,
subId,
}: {
cusProducts: FullCusProduct[];
subId?: string;
}): FullCusProduct[] => {
if (!subId) return [];
return cusProducts.filter((cp: FullCusProduct) =>
cp.subscription_ids?.includes(subId),
);
};

View File

@@ -0,0 +1,12 @@
import type { FullCusProduct } from "@models/cusProductModels/cusProductModels";
import { ACTIVE_STATUSES } from "@utils/cusProductUtils/cusProductConstants";
export const filterCustomerProductsByActiveStatuses = ({
customerProducts,
}: {
customerProducts: FullCusProduct[];
}) => {
return customerProducts.filter((customerProduct) =>
ACTIVE_STATUSES.includes(customerProduct.status),
);
};

View File

@@ -0,0 +1,20 @@
import type { FullCusProduct } from "@models/cusProductModels/cusProductModels";
export const filterCustomerProductsByStripeSubscriptionId = ({
customerProducts,
stripeSubscriptionId,
}: {
customerProducts: FullCusProduct[];
stripeSubscriptionId?: string;
}) => {
return customerProducts.filter((customerProduct) => {
if (!stripeSubscriptionId) {
return (
customerProduct.subscription_ids?.length === 0 ||
!customerProduct.subscription_ids
);
}
return customerProduct.subscription_ids?.includes(stripeSubscriptionId);
});
};

View File

@@ -39,7 +39,9 @@ export * from "./cusProductUtils/cusProductConstants.js";
export * from "./cusProductUtils/cusProductUtils.js";
export * from "./cusProductUtils/featureOptionUtils/findFeatureOptions.js";
export * from "./cusProductUtils/filterCusProductUtils.js";
export * from "./cusProductUtils/filterCusProductUtils.js";
export * from "./cusProductUtils/filterCustomerProducts/filterCustomerProductsByActiveStatuses.js";
export * from "./cusProductUtils/filterCustomerProducts/filterCustomerProductsByStripeSubscriptionId.js";
export * from "./cusProductUtils/getCusProductFromCustomer.js";
export * from "./cusProductUtils/productIdToCusProduct.js";
// Cus utils
@@ -67,10 +69,7 @@ export * from "./productUtils/entUtils/formatEntUtils.js";
export * from "./productUtils/freeTrialUtils/initFreeTrial.js";
export * from "./productUtils/freeTrialUtils.js";
export * from "./productUtils/isProductUpgrade.js";
export * from "./productUtils/priceUtils/classifyPriceUtils.js";
export * from "./productUtils/priceUtils/convertAmountUtils.js";
export * from "./productUtils/priceUtils/convertPriceUtils.js";
export * from "./productUtils/priceUtils/formatPriceUtils.js";
export * from "./productUtils/priceUtils/index.js";
export * from "./productUtils/priceUtils.js";
export * from "./productV2Utils/mapToProductV2.js";
export * from "./productV2Utils/productItemUtils/classifyItemUtils.js";

View File

@@ -6,7 +6,11 @@ import {
OnDecrease,
OnIncrease,
} from "@models/productV2Models/productItemModels/productItemEnums";
import { shouldBillNow, shouldProrate } from "@utils/billingUtils";
import {
shouldBillNow,
shouldProrate,
shouldSkipLineItems,
} from "@utils/billingUtils";
import { priceToEnt } from "@utils/productUtils/convertProductUtils";
export const priceToFeature = ({
@@ -43,20 +47,17 @@ export const priceToProrationConfig = ({
}): {
prorationBehaviorConfig: OnIncrease | OnDecrease;
shouldApplyProration: boolean;
shouldFinalizeInvoiceImmediately: boolean;
chargeImmediately: boolean;
skipLineItems: boolean;
} => {
const prorationBehaviorConfig = isUpgrade
? (price.proration_config?.on_increase ?? OnIncrease.ProrateImmediately)
: (price.proration_config?.on_decrease ?? OnDecrease.ProrateImmediately);
const shouldApplyProration = shouldProrate(prorationBehaviorConfig);
const shouldFinalizeInvoiceImmediately = shouldBillNow(
prorationBehaviorConfig,
);
return {
prorationBehaviorConfig,
shouldApplyProration,
shouldFinalizeInvoiceImmediately,
shouldApplyProration: shouldProrate(prorationBehaviorConfig),
chargeImmediately: shouldBillNow(prorationBehaviorConfig),
skipLineItems: shouldSkipLineItems(prorationBehaviorConfig),
};
};

View File

@@ -0,0 +1,11 @@
import type { Price } from "@models/productModels/priceModels/priceModels";
export const findPriceByFeatureId = ({
prices,
featureId,
}: {
prices: Price[];
featureId: string;
}) => {
return prices.find((p) => p.config.feature_id === featureId);
};

View File

@@ -0,0 +1,5 @@
export * from "./classifyPriceUtils.js";
export * from "./convertAmountUtils.js";
export * from "./convertPriceUtils.js";
export * from "./findPrice/findPriceByFeatureId.js";
export * from "./formatPriceUtils.js";

View File

@@ -7,23 +7,32 @@ import {
getProductItemDisplay,
type ProductItem,
type ProductV2,
type SubscriptionUpdateV0Params,
stripeToAtmnAmount,
type UpdateSubscriptionV0Params,
} from "@autumn/shared";
import { Check, Copy, PencilSimple } from "@phosphor-icons/react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router";
import { toast } from "sonner";
import { DateInputUnix } from "@/components/general/DateInputUnix";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Button } from "@/components/v2/buttons/Button";
import { IconButton } from "@/components/v2/buttons/IconButton";
import { SheetHeader } from "@/components/v2/sheets/InlineSheet";
import { useOrgStripeQuery } from "@/hooks/queries/useOrgStripeQuery";
import { usePrepaidItems } from "@/hooks/stores/useProductStore";
import { useSheetStore } from "@/hooks/stores/useSheetStore";
import { useSubscriptionById } from "@/hooks/stores/useSubscriptionStore";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { useEnv } from "@/utils/envUtils";
import { formatUnixToDateTime } from "@/utils/formatUtils/formatDateUtils";
import { pushPage } from "@/utils/genUtils";
import { getStripeInvoiceLink } from "@/utils/linkUtils";
import { useCusQuery } from "@/views/customers/customer/hooks/useCusQuery";
/**
@@ -41,6 +50,7 @@ interface PrepaidEditorProps {
prepaidItems: Array<{
feature_id?: string | null;
feature?: { internal_id: string } | undefined;
billing_units?: number | null;
}>;
prepaidOptions: Record<string, number>;
onPrepaidChange: (featureId: string, quantity: number) => void;
@@ -61,15 +71,18 @@ function PrepaidEditor({
<div className="px-4 py-3 space-y-2">
{prepaidItems.map((item) => {
const featureId = item.feature_id ?? item.feature?.internal_id ?? "";
const billingUnits = item.billing_units ?? 1;
const inputId = `prepaid-${featureId}`;
return (
<div key={featureId} className="flex items-center gap-3">
<label
htmlFor={inputId}
className="text-sm text-t-secondary flex-1"
>
<div className="flex-1">
<label htmlFor={inputId} className="text-sm text-t-secondary">
{featureId}
</label>
<span className="text-xs text-t-secondary ml-2">
(billing_units: {billingUnits})
</span>
</div>
<input
id={inputId}
type="number"
@@ -158,6 +171,14 @@ interface BillingPlanData {
}>;
};
};
invoiceItemsAction?: {
createInvoiceItems?: Array<{
description?: string;
amount?: number;
customer?: string;
subscription?: string;
}>;
};
};
}
@@ -626,10 +647,16 @@ function PreviewResult({ data, isLoading, error }: PreviewResultProps) {
<h4 className="text-xs font-semibold text-purple-400 mb-1">
🧾 Stripe Invoice Action
</h4>
{/* Immediate Line Items (addLineParams) */}
{billingPlan.stripe.invoiceAction.addLineParams?.lines &&
billingPlan.stripe.invoiceAction.addLineParams.lines.length >
0 ? (
<div className="space-y-1">
<div className="mb-2">
<div className="text-xs text-t-secondary font-medium mb-1">
Immediate charges:
</div>
<div className="space-y-1 pl-2 border-l border-purple-500/30">
{billingPlan.stripe.invoiceAction.addLineParams.lines.map(
(
line: {
@@ -664,9 +691,13 @@ function PreviewResult({ data, isLoading, error }: PreviewResultProps) {
},
)}
</div>
</div>
) : (
<div className="text-xs text-t-secondary">No line items</div>
<div className="text-xs text-t-secondary mb-2">
No immediate line items
</div>
)}
<details className="mt-1">
<summary className="text-xs cursor-pointer text-t-secondary hover:text-t-primary">
View raw params
@@ -678,10 +709,71 @@ function PreviewResult({ data, isLoading, error }: PreviewResultProps) {
</div>
) : null}
{/* Stripe Invoice Items Action - Deferred charges added to next cycle */}
{billingPlan.stripe?.invoiceItemsAction?.createInvoiceItems &&
billingPlan.stripe.invoiceItemsAction.createInvoiceItems.length >
0 ? (
<div className="px-4 py-3 border-l-2 border-l-amber-500">
<h4 className="text-xs font-semibold text-amber-400 mb-1">
Stripe Invoice Items Action (Added to next cycle)
</h4>
<div className="space-y-1 pl-2 border-l border-amber-500/30">
{billingPlan.stripe.invoiceItemsAction.createInvoiceItems.map(
(
item: {
description?: string;
amount?: number;
customer?: string;
subscription?: string;
},
index: number,
) => {
const amount = item.amount
? stripeToAtmnAmount({
amount: item.amount,
currency: "usd",
})
: 0;
return (
<div key={index} className="flex justify-between text-xs">
<span className="text-t-primary">
{item.description || "Invoice item"}
</span>
<span
className={
amount >= 0 ? "text-amber-400" : "text-red-400"
}
>
${amount.toFixed(2)}
</span>
</div>
);
},
)}
</div>
<div className="text-xs text-t-secondary mt-1 italic">
These items will appear on the customer's next invoice
</div>
<details className="mt-1">
<summary className="text-xs cursor-pointer text-t-secondary hover:text-t-primary">
View raw params
</summary>
<pre className="text-xs bg-t-50 p-2 rounded mt-1 overflow-auto max-h-32">
{JSON.stringify(
billingPlan.stripe.invoiceItemsAction,
null,
2,
)}
</pre>
</details>
</div>
) : null}
{/* Empty Stripe section indicator */}
{billingPlan.stripe &&
!billingPlan.stripe.subscriptionAction &&
!billingPlan.stripe.invoiceAction ? (
!billingPlan.stripe.invoiceAction &&
!billingPlan.stripe.invoiceItemsAction ? (
<div className="px-4 py-2 text-xs text-t-secondary">
No Stripe actions required
</div>
@@ -740,7 +832,7 @@ function useSubscriptionUpdatePreview({
body,
enabled,
}: {
body: SubscriptionUpdateV0Params | null;
body: UpdateSubscriptionV0Params | null;
enabled: boolean;
}) {
const axiosInstance = useAxiosInstance();
@@ -780,17 +872,64 @@ function useSubscriptionUpdatePreview({
};
}
function useSubscriptionUpdate() {
interface SubscriptionUpdateParams {
body: UpdateSubscriptionV0Params;
useInvoice?: boolean;
enableProductImmediately?: boolean;
}
function useSubscriptionUpdate({
customerId,
onInvoiceCreated,
}: {
customerId?: string;
onInvoiceCreated?: (invoiceLink: string) => void;
}) {
const axiosInstance = useAxiosInstance();
const queryClient = useQueryClient();
const { closeSheet } = useSheetStore();
return useMutation({
mutationFn: async (body: SubscriptionUpdateV0Params) => {
mutationFn: async ({
body,
useInvoice,
enableProductImmediately,
}: SubscriptionUpdateParams) => {
const requestBody = {
...body,
invoice: useInvoice,
enable_product_immediately: useInvoice
? enableProductImmediately
: undefined,
finalize_invoice: useInvoice ? false : undefined,
force_checkout:
useInvoice && enableProductImmediately === false ? true : undefined,
};
const response = await axiosInstance.post(
"/v1/subscriptions/update",
body,
requestBody,
);
return response.data;
},
onSuccess: (data) => {
if (data?.invoice) {
onInvoiceCreated?.(data.invoice);
toast.success("Invoice created successfully");
} else if (data?.checkout_url) {
toast.success("Redirecting to checkout...");
window.open(data.checkout_url, "_blank");
} else {
toast.success("Subscription updated successfully");
}
closeSheet();
if (customerId) {
queryClient.invalidateQueries({ queryKey: ["customer", customerId] });
}
},
onError: (error) => {
console.error("Update failed:", error);
},
});
}
@@ -808,6 +947,10 @@ function SheetContent({
const customerId = customer?.id ?? customer?.internal_id;
const entityId = cusProduct?.entity_id ?? undefined;
// Stripe + invoice handling
const { stripeAccount } = useOrgStripeQuery();
const env = useEnv();
const product = customizedProduct?.id ? customizedProduct : productV2;
const { prepaidItems } = usePrepaidItems({ product });
@@ -874,23 +1017,30 @@ function SheetContent({
};
// Build the request body
const requestBody = useMemo<SubscriptionUpdateV0Params | null>(() => {
const requestBody = useMemo<UpdateSubscriptionV0Params | null>(() => {
if (!customerId) return null;
const body: SubscriptionUpdateV0Params = {
const body: UpdateSubscriptionV0Params = {
customer_id: customerId,
product_id: product?.id,
entity_id: entityId,
customer_product_id: cusProduct.id ?? cusProduct.internal_product_id,
};
// Add options if there are prepaid items with quantities set
// Add options only if they have changed from initial values
if (prepaidItems.length > 0) {
const options = prepaidItems
.map((item) => {
const featureId = item.feature_id ?? item.feature?.internal_id ?? "";
const quantity = prepaidOptions[featureId];
if (quantity !== undefined && quantity !== null && featureId) {
const initialQuantity = initialPrepaidOptions[featureId];
// Only include if changed from initial value
if (
quantity !== undefined &&
quantity !== null &&
featureId &&
quantity !== initialQuantity
) {
return { feature_id: featureId, quantity };
}
return null;
@@ -935,6 +1085,7 @@ function SheetContent({
cusProduct.internal_product_id,
prepaidItems,
prepaidOptions,
initialPrepaidOptions,
customizedProduct?.items,
customizedProduct?.free_trial,
planCustomStartDate,
@@ -948,12 +1099,35 @@ function SheetContent({
enabled: !!requestBody,
});
// Update mutation
const updateMutation = useSubscriptionUpdate();
// Update mutation with invoice handling
const updateMutation = useSubscriptionUpdate({
customerId,
onInvoiceCreated: (stripeInvoice) => {
const invoiceLink = getStripeInvoiceLink({
stripeInvoice,
env,
accountId: stripeAccount?.id,
});
window.open(invoiceLink, "_blank");
},
});
const handleConfirm = () => {
if (!requestBody) return;
updateMutation.mutate(requestBody);
updateMutation.mutate({ body: requestBody, useInvoice: false });
};
const handleInvoiceUpdate = ({
enableProductImmediately,
}: {
enableProductImmediately: boolean;
}) => {
if (!requestBody) return;
updateMutation.mutate({
body: requestBody,
useInvoice: true,
enableProductImmediately,
});
};
return (
@@ -1181,16 +1355,64 @@ function SheetContent({
</div>
{/* Footer Actions */}
<div className="p-4 border-t flex gap-3">
<div className="p-4 border-t flex flex-col gap-2">
{/* Send an Invoice Button with Dropdown */}
<Popover>
<PopoverTrigger asChild>
<Button
variant="secondary"
className="w-full"
disabled={!requestBody || updateMutation.isPending}
>
Send an Invoice
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 p-0" align="start">
<div className="flex flex-col">
<button
type="button"
onClick={() =>
handleInvoiceUpdate({ enableProductImmediately: true })
}
className="px-4 py-3 text-left text-sm hover:bg-accent"
>
<div className="font-medium">Enable plan immediately</div>
<div className="text-xs text-muted-foreground">
Enable the plan immediately and redirect to Stripe to finalize
the invoice
</div>
</button>
<button
type="button"
onClick={() =>
handleInvoiceUpdate({ enableProductImmediately: false })
}
className="px-4 py-3 text-left text-sm hover:bg-accent border-t"
>
<div className="font-medium">Enable plan after payment</div>
<div className="text-xs text-muted-foreground">
Generate an invoice link for the customer. The plan will be
enabled after they pay the invoice
</div>
</button>
</div>
</PopoverContent>
</Popover>
{/* Confirm Update Button */}
<Button
variant="primary"
className="w-full"
onClick={handleConfirm}
disabled={!requestBody || updateMutation.isPending}
>
{updateMutation.isPending ? "Updating..." : "Confirm Update"}
</Button>
{/* Refresh Preview Button */}
<Button
variant="secondary"
className="w-full"
onClick={() => previewQuery.refetch()}
disabled={!requestBody || previewQuery.isLoading}
>

View File

@@ -54,6 +54,7 @@ export default defineConfig({
"drizzle-orm",
"@date-fns/utc",
"date-fns",
"@owpz/ksuid",
],
},