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" source "$(dirname "$0")/config.sh"
BUN_PARALLEL_COMPACT \ BUN_PARALLEL_COMPACT \
'server/tests/attach/basic' \ 'server/tests/billing/subscription-update' \
'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 --max=6
BUN_PARALLEL_COMPACT \ # BUN_PARALLEL_COMPACT \
'server/tests/attach/entities' \ # 'server/tests/attach/basic' \
--max=6 # 'server/tests/attach/upgrade' \
# 'server/tests/external-psps/revenuecat' \ # '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 LegacyVersion,
type OrgConfig, type OrgConfig,
type RewardRedemption, type RewardRedemption,
type SubscriptionUpdateV0Params,
type TrackParams, type TrackParams,
type UpdateSubscriptionV0Params,
} from "@autumn/shared"; } from "@autumn/shared";
import { defaultApiVersion } from "@tests/constants.js"; import { defaultApiVersion } from "@tests/constants.js";
import type { 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); const data = await this.post(`/subscriptions/update`, params);
return data; return data;
}; };

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,11 @@
import { Hono } from "hono"; 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 type { HonoEnv } from "../../honoUtils/HonoEnv.js";
import { handleAttach } from "./attach/handleAttach.js"; import { handleAttach } from "./attach/handleAttach.js";
import { handleCheckoutV2 } from "./checkout/handleCheckoutV2.js"; import { handleCheckoutV2 } from "./checkout/handleCheckoutV2.js";
import { handleSetupPayment } from "./handlers/handleSetupPayment.js"; import { handleSetupPayment } from "./handlers/handleSetupPayment.js";
import { handleApiSubscriptionUpdate } from "./v2/handlers/handleApiSubscriptionUpdate.js";
import { handleAttachV2 } from "./v2/handlers/handleAttachV2.js"; import { handleAttachV2 } from "./v2/handlers/handleAttachV2.js";
import { handleUpdateSubscription } from "./v2/handlers/handleUpdateSubscription.js";
export const billingRouter = new Hono<HonoEnv>(); export const billingRouter = new Hono<HonoEnv>();
@@ -14,8 +14,8 @@ billingRouter.post("/checkout", ...handleCheckoutV2);
billingRouter.post("/attach", ...handleAttach); billingRouter.post("/attach", ...handleAttach);
billingRouter.post("/attach_v2", ...handleAttachV2); billingRouter.post("/attach_v2", ...handleAttachV2);
billingRouter.post("/subscriptions/update", ...handleApiSubscriptionUpdate); billingRouter.post("/subscriptions/update", ...handleUpdateSubscription);
billingRouter.post( billingRouter.post(
"/subscriptions/preview/update", "/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 { FullCustomer } from "@shared/models/cusModels/fullCusModel";
import type Stripe from "stripe"; 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 { export interface BillingContext {
fullCustomer: FullCustomer; fullCustomer: FullCustomer;
@@ -10,5 +18,17 @@ export interface BillingContext {
stripeSubscription?: Stripe.Subscription; stripeSubscription?: Stripe.Subscription;
stripeSubscriptionSchedule?: Stripe.SubscriptionSchedule; stripeSubscriptionSchedule?: Stripe.SubscriptionSchedule;
paymentMethod?: Stripe.PaymentMethod; 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, cusProductToLineItems,
type FullCusProduct, type FullCusProduct,
} from "@autumn/shared"; } from "@autumn/shared";
import type { BillingContext } from "@/internal/billing/v2/billingContext";
import type { AutumnContext } from "../../../../../honoUtils/HonoEnv"; import type { AutumnContext } from "../../../../../honoUtils/HonoEnv";
export const buildAutumnLineItems = ({ export const buildAutumnLineItems = ({
ctx, ctx,
newCusProducts, newCustomerProducts,
ongoingCustomerProduct, deletedCustomerProduct,
billingCycleAnchor, billingContext,
testClockFrozenTime,
}: { }: {
ctx: AutumnContext; ctx: AutumnContext;
newCusProducts: FullCusProduct[]; newCustomerProducts: FullCusProduct[];
ongoingCustomerProduct?: FullCusProduct; deletedCustomerProduct?: FullCusProduct;
billingCycleAnchor?: number; billingContext: BillingContext;
testClockFrozenTime?: number;
}) => { }) => {
const now = testClockFrozenTime ?? Date.now(); // billingCycleAnchor = billingCycleAnchor ?? now;
billingCycleAnchor = billingCycleAnchor ?? now; const billingCycleAnchor = billingContext.billingCycleAnchorMs;
const now = billingContext.currentEpochMs;
const { org } = ctx; const { org } = ctx;
const arrearLineItems = ongoingCustomerProduct const arrearLineItems = deletedCustomerProduct
? cusProductToArrearLineItems({ ? cusProductToArrearLineItems({
cusProduct: ongoingCustomerProduct, cusProduct: deletedCustomerProduct,
billingCycleAnchor: billingCycleAnchor!, billingCycleAnchor: billingCycleAnchor!,
now, now,
org, org,
@@ -34,9 +33,9 @@ export const buildAutumnLineItems = ({
: []; : [];
// Get line items for ongoing cus product // Get line items for ongoing cus product
const ongoingLineItems = ongoingCustomerProduct const deletedLineItems = deletedCustomerProduct
? cusProductToLineItems({ ? cusProductToLineItems({
cusProduct: ongoingCustomerProduct, cusProduct: deletedCustomerProduct,
now, now,
billingCycleAnchor: billingCycleAnchor!, billingCycleAnchor: billingCycleAnchor!,
direction: "refund", direction: "refund",
@@ -44,9 +43,9 @@ export const buildAutumnLineItems = ({
}) })
: []; : [];
const newLineItems = newCusProducts.flatMap((newCusProduct) => const newLineItems = newCustomerProducts.flatMap((newCustomerProduct) =>
cusProductToLineItems({ cusProductToLineItems({
cusProduct: newCusProduct, cusProduct: newCustomerProduct,
now, now,
billingCycleAnchor: billingCycleAnchor!, billingCycleAnchor: billingCycleAnchor!,
direction: "charge", direction: "charge",
@@ -56,7 +55,7 @@ export const buildAutumnLineItems = ({
// All items // All items
const allLineItems = [ const allLineItems = [
...ongoingLineItems, ...deletedLineItems,
...arrearLineItems, ...arrearLineItems,
...newLineItems, ...newLineItems,
]; ];

View File

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

View File

@@ -50,7 +50,10 @@ export const executeAutumnBillingPlan = async ({
await CusProductService.update({ await CusProductService.update({
db, db,
cusProductId: updateCustomerProduct.id, 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 }); logBillingPlan({ ctx, billingPlan });
const enableProductImmediately = const result = await executeStripeBillingPlan({
billingPlan.stripe.invoiceAction?.invoiceMode?.enableProductImmediately !==
false;
await executeStripeBillingPlan({
ctx, ctx,
stripeBillingPlan: billingPlan.stripe, billingPlan,
autumnBillingPlan: billingPlan.autumn,
billingContext, billingContext,
}); });
// if not enabling product immediately, it will be handled in webhook if (result.deferred) return result;
if (enableProductImmediately) {
await executeAutumnBillingPlan({ await executeAutumnBillingPlan({
ctx, ctx,
autumnBillingPlan: billingPlan.autumn, autumnBillingPlan: billingPlan.autumn,
}); });
}
return { billingPlan }; return { billingPlan };
}; };

View File

@@ -4,7 +4,7 @@ import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { InvoiceService } from "@/internal/invoices/InvoiceService"; import { InvoiceService } from "@/internal/invoices/InvoiceService";
import { getInvoiceItems } from "@/internal/invoices/invoiceUtils"; import { getInvoiceItems } from "@/internal/invoices/invoiceUtils";
import { createAndFinalizeInvoice } from "@/internal/invoices/invoiceUtils/createAndFinalizeInvoice"; import { createAndFinalizeInvoice } from "@/internal/invoices/invoiceUtils/createAndFinalizeInvoice";
import type { UpdateSubscriptionContext } from "../subscriptionUpdate/fetch/updateSubscriptionContextSchema"; import type { UpdateSubscriptionBillingContext } from "../billingContext";
import type { SubscriptionUpdateInvoiceAction } from "../typesOld"; import type { SubscriptionUpdateInvoiceAction } from "../typesOld";
/** /**
@@ -22,7 +22,7 @@ export const executeInvoiceAction = async ({
}: { }: {
ctx: AutumnContext; ctx: AutumnContext;
invoiceAction: SubscriptionUpdateInvoiceAction; invoiceAction: SubscriptionUpdateInvoiceAction;
updateSubscriptionContext: UpdateSubscriptionContext; updateSubscriptionContext: UpdateSubscriptionBillingContext;
}) => { }) => {
const { stripeCustomer, stripeSubscription, customerProduct } = const { stripeCustomer, stripeSubscription, customerProduct } =
updateSubscriptionContext; 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 { LineItem } from "@autumn/shared";
import type { import type { StripeInvoiceAction } from "../../../types/billingPlan";
InvoiceMode, import { lineItemsToInvoiceAddLinesParams } from "../utils/invoiceLines/lineItemsToInvoiceAddLinesParams";
StripeInvoiceAction,
} from "../../../types/billingPlan";
import { lineItemsToStripeLines } from "../utils/invoiceLines/lineItemsToStripeLines";
/** /**
* Builds a Stripe invoice action from Autumn line items. * Builds a StripeInvoiceAction for immediate charges.
* Returns undefined if no line items are provided. * Filters for line items where chargeImmediately === true.
* Returns undefined if no immediate line items are provided.
*/ */
export const buildStripeInvoiceAction = ({ export const buildStripeInvoiceAction = ({
autumnLineItems, lineItems,
invoiceMode,
}: { }: {
autumnLineItems: LineItem[]; lineItems: LineItem[];
invoiceMode?: InvoiceMode;
}): StripeInvoiceAction | undefined => { }): StripeInvoiceAction | undefined => {
if (autumnLineItems.length === 0) { const immediateLineItems = lineItems.filter(
(line) => line.chargeImmediately === true,
);
if (immediateLineItems.length === 0) {
return undefined; return undefined;
} }
const lines = lineItemsToStripeLines({ lineItems: autumnLineItems }); const lines = lineItemsToInvoiceAddLinesParams({
lineItems: immediateLineItems,
});
if (lines.length === 0) {
return undefined;
}
return { return {
addLineParams: { lines }, 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 { FullCusProduct } from "@autumn/shared";
import type { AutumnContext } from "@server/honoUtils/HonoEnv"; 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 { buildStripeSubscriptionItemsUpdate } from "@server/internal/billing/v2/providers/stripe/utils/subscriptionItems/buildStripeSubscriptionItemsUpdate";
import { buildStripeSubscriptionCreateAction } from "@server/internal/billing/v2/providers/stripe/utils/subscriptions/buildStripeSubscriptionCreateAction"; 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 { buildStripeSubscriptionUpdateAction } from "@server/internal/billing/v2/providers/stripe/utils/subscriptions/buildStripeSubscriptionUpdateAction";
import type { UpdateSubscriptionContext } from "@server/internal/billing/v2/subscriptionUpdate/fetch/updateSubscriptionContextSchema";
import type { import type {
FreeTrialPlan, FreeTrialPlan,
StripeSubscriptionAction, StripeSubscriptionAction,
@@ -13,13 +13,13 @@ import type {
export const buildStripeSubscriptionAction = ({ export const buildStripeSubscriptionAction = ({
ctx, ctx,
billingContext, billingContext,
updatedCustomerProducts, finalCustomerProducts,
stripeSubscriptionScheduleAction, stripeSubscriptionScheduleAction,
freeTrialPlan, freeTrialPlan,
}: { }: {
ctx: AutumnContext; ctx: AutumnContext;
billingContext: UpdateSubscriptionContext; billingContext: BillingContext;
updatedCustomerProducts: FullCusProduct[]; finalCustomerProducts: FullCusProduct[];
stripeSubscriptionScheduleAction?: StripeSubscriptionScheduleAction; stripeSubscriptionScheduleAction?: StripeSubscriptionScheduleAction;
freeTrialPlan?: FreeTrialPlan; freeTrialPlan?: FreeTrialPlan;
}): StripeSubscriptionAction | undefined => { }): StripeSubscriptionAction | undefined => {
@@ -28,11 +28,9 @@ export const buildStripeSubscriptionAction = ({
const subItemsUpdate = buildStripeSubscriptionItemsUpdate({ const subItemsUpdate = buildStripeSubscriptionItemsUpdate({
ctx, ctx,
billingContext, billingContext,
updatedCustomerProducts, finalCustomerProducts,
}); });
// 1. Compute the action type
// Case 1: No subscription and sub items update is empty -> no action // Case 1: No subscription and sub items update is empty -> no action
if (!stripeSubscription && subItemsUpdate.length === 0) { if (!stripeSubscription && subItemsUpdate.length === 0) {
return undefined; return undefined;

View File

@@ -1,9 +1,13 @@
import type { FullCusProduct } from "@autumn/shared"; 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 { AutumnContext } from "@server/honoUtils/HonoEnv";
import type { BillingContext } from "@server/internal/billing/v2/billingContext"; 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/providers/stripe/utils/subscriptionSchedules/buildSchedulePhases";
import { buildSchedulePhases } from "@server/internal/billing/v2/utils/stripeAdapter/subscriptionSchedules/buildSchedulePhases";
import type Stripe from "stripe"; import type Stripe from "stripe";
import type { StripeSubscriptionScheduleAction } from "@/internal/billing/v2/types/billingPlan"; import type { StripeSubscriptionScheduleAction } from "@/internal/billing/v2/types/billingPlan";
@@ -46,31 +50,42 @@ const filterEmptyPhases = (
export const buildStripeSubscriptionScheduleAction = ({ export const buildStripeSubscriptionScheduleAction = ({
ctx, ctx,
billingContext, billingContext,
addCustomerProducts = [], finalCustomerProducts,
removeCustomerProducts = [],
trialEndsAt, trialEndsAt,
nowMs, nowMs,
}: { }: {
ctx: AutumnContext; ctx: AutumnContext;
billingContext: BillingContext; billingContext: BillingContext;
addCustomerProducts?: FullCusProduct[]; finalCustomerProducts: FullCusProduct[];
removeCustomerProducts?: FullCusProduct[];
trialEndsAt?: number; trialEndsAt?: number;
nowMs: number; nowMs: number;
}): StripeSubscriptionScheduleAction | undefined => { }): StripeSubscriptionScheduleAction | undefined => {
const { stripeSubscriptionSchedule } = billingContext; const { stripeSubscriptionSchedule, stripeSubscription } = billingContext;
// 1. Get final customer product state // 1. Filter customer products by stripe subscription id or stripe subscription schedule ID?
const customerProducts = getFinalCustomerProductsState({
billingContext, let customerProducts = stripeSubscription
addCustomerProducts, ? finalCustomerProducts.filter(
removeCustomerProducts, (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({ const phases = buildSchedulePhases({
ctx, ctx,
billingContext, billingContext,
customerProducts, customerProducts: finalCustomerProducts,
trialEndsAt, trialEndsAt,
nowMs, 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 type Stripe from "stripe";
import { isStripeSubscriptionCanceled } from "@/external/stripe/subscriptions/utils/classifyStripeSubscriptionUtils";
import type { AutumnContext } from "@/honoUtils/HonoEnv"; import type { AutumnContext } from "@/honoUtils/HonoEnv";
import type { BillingContext } from "@/internal/billing/v2/billingContext"; 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 { 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 { executeStripeSubscriptionAction } from "@/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionAction";
import { executeStripeSubscriptionScheduleAction } from "@/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionScheduleAction"; import { executeStripeSubscriptionScheduleAction } from "@/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionScheduleAction";
import { createInvoiceForBilling } from "@/internal/billing/v2/providers/stripe/utils/invoices/createInvoiceForBilling"; import { createStripeInvoiceItems } from "@/internal/billing/v2/providers/stripe/utils/invoices/stripeInvoiceOps";
import type { import type { BillingPlan } from "@/internal/billing/v2/types/billingPlan";
AutumnBillingPlan, import type { StripeBillingPlanResult } from "@/internal/billing/v2/types/stripeBillingPlanResult";
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";
export const executeStripeBillingPlan = async ({ export const executeStripeBillingPlan = async ({
ctx, ctx,
stripeBillingPlan, billingPlan,
autumnBillingPlan,
billingContext, billingContext,
resumeFromDeferred = false,
}: { }: {
ctx: AutumnContext; ctx: AutumnContext;
stripeBillingPlan: StripeBillingPlan; billingPlan: BillingPlan;
autumnBillingPlan: AutumnBillingPlan;
billingContext: BillingContext; billingContext: BillingContext;
}) => { resumeFromDeferred?: boolean;
}): Promise<StripeBillingPlanResult> => {
const { logger } = ctx;
const { const {
subscriptionAction: stripeSubscriptionAction, subscriptionAction: stripeSubscriptionAction,
invoiceAction: stripeInvoiceAction, invoiceAction: stripeInvoiceAction,
invoiceItemsAction: stripeInvoiceItemsAction,
subscriptionScheduleAction: stripeSubscriptionScheduleAction, subscriptionScheduleAction: stripeSubscriptionScheduleAction,
} = stripeBillingPlan; } = billingPlan.stripe;
const enableProductImmediately = if (stripeInvoiceAction && !resumeFromDeferred) {
stripeInvoiceAction?.invoiceMode?.enableProductImmediately !== false; const result = await executeStripeInvoiceAction({
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({
ctx, ctx,
billingPlan,
billingContext, billingContext,
stripeInvoiceAction,
invoiceMetadata,
}); });
if (invoice) { if (result.deferred) return result;
await upsertInvoiceFromBilling({
ctx,
stripeInvoice: invoice,
fullProducts: billingContext.fullProducts,
fullCustomer: billingContext.fullCustomer,
});
} }
if (stripeInvoiceItemsAction?.createInvoiceItems) {
logger.info(
"[executeStripeBillingPlan] Creating invoice items for next cycle",
);
await createStripeInvoiceItems({
ctx,
invoiceItems: stripeInvoiceItemsAction.createInvoiceItems,
});
} }
let stripeSubscription: Stripe.Subscription | undefined = let stripeSubscription: Stripe.Subscription | undefined =
billingContext.stripeSubscription; billingContext.stripeSubscription;
if (stripeSubscriptionAction) { if (stripeSubscriptionAction) {
// 1. Insert stripe subscription ID into cache const result = await executeStripeSubscriptionAction({
if (stripeSubscription?.id) {
await addSubIdToCache({
subId: stripeSubscription.id,
scenario: "billing",
});
}
stripeSubscription = await executeStripeSubscriptionAction({
ctx, ctx,
subscriptionAction: stripeSubscriptionAction, billingPlan,
billingContext,
}); });
if (stripeSubscription) { if (result?.deferred) return result;
addStripeSubscriptionIdToBillingPlan({
autumnBillingPlan,
stripeSubscriptionId: stripeSubscription.id,
});
// Add subscription to DB stripeSubscription = result.stripeSubscription;
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;
}
} }
if (stripeSubscriptionScheduleAction) { if (stripeSubscriptionScheduleAction) {
@@ -132,9 +74,11 @@ export const executeStripeBillingPlan = async ({
if (stripeSubscriptionSchedule) { if (stripeSubscriptionSchedule) {
addStripeSubscriptionScheduleIdToBillingPlan({ addStripeSubscriptionScheduleIdToBillingPlan({
autumnBillingPlan, autumnBillingPlan: billingPlan.autumn,
stripeSubscriptionScheduleId: stripeSubscriptionSchedule.id, 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 { createStripeCli } from "@/external/connect/createStripeCli";
import { isStripeSubscriptionCanceled } from "@/external/stripe/subscriptions/utils/classifyStripeSubscriptionUtils";
import type { AutumnContext } from "@/honoUtils/HonoEnv"; 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, ctx,
subscriptionAction, subscriptionAction,
invoiceModeParams,
}: { }: {
ctx: AutumnContext; ctx: AutumnContext;
subscriptionAction: StripeSubscriptionAction; subscriptionAction: StripeSubscriptionAction;
invoiceModeParams: InvoiceModeParams;
}) => { }) => {
const { org, env } = ctx; const { org, env } = ctx;
const stripeClient = createStripeCli({ org, env }); const stripeClient = createStripeCli({ org, env });
@@ -16,13 +35,118 @@ export const executeStripeSubscriptionAction = async ({
case "update": case "update":
return await stripeClient.subscriptions.update( return await stripeClient.subscriptions.update(
subscriptionAction.stripeSubscriptionId, subscriptionAction.stripeSubscriptionId,
subscriptionAction.params, {
...subscriptionAction.params,
...invoiceModeParams,
expand: ["latest_invoice"],
},
); );
case "create": case "create":
return await stripeClient.subscriptions.create(subscriptionAction.params); return await stripeClient.subscriptions.create({
...subscriptionAction.params,
...invoiceModeParams,
expand: ["latest_invoice"],
});
case "cancel": case "cancel":
return await stripeClient.subscriptions.cancel( return await stripeClient.subscriptions.cancel(
subscriptionAction.stripeSubscriptionId, 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 * Converts a single LineItem to Stripe.InvoiceAddLinesParams.Line
*/ */
export const lineItemToStripeLine = ({ const toStripeAddLineParams = ({
lineItem, lineItem,
}: { }: {
lineItem: LineItem; lineItem: LineItem;
@@ -27,10 +27,10 @@ export const lineItemToStripeLine = ({
/** /**
* Converts an array of LineItems to Stripe.InvoiceAddLinesParams.Line[] * Converts an array of LineItems to Stripe.InvoiceAddLinesParams.Line[]
*/ */
export const lineItemsToStripeLines = ({ export const lineItemsToInvoiceAddLinesParams = ({
lineItems, lineItems,
}: { }: {
lineItems: LineItem[]; lineItems: LineItem[];
}): Stripe.InvoiceAddLinesParams.Line[] => { }): 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; invoiceMetadata?: StripeInvoiceMetadata;
}): Promise<PayInvoiceResult> => { }): Promise<PayInvoiceResult> => {
const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env }); const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env });
const { addLineParams, invoiceMode } = stripeInvoiceAction; const { addLineParams } = stripeInvoiceAction;
const invoiceMode = billingContext.invoiceMode;
const shouldFinalizeInvoice = invoiceMode const shouldFinalizeInvoice = invoiceMode
? invoiceMode.finalizeInvoice ? invoiceMode.finalizeInvoice
: true; : true;

View File

@@ -1,4 +1,6 @@
import type Stripe from "stripe"; import type Stripe from "stripe";
import { createStripeCli } from "@/external/connect/createStripeCli";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
// ============================================ // ============================================
// Create Invoice // Create Invoice
@@ -84,3 +86,22 @@ export const finalizeStripeInvoice = async ({
return invoice; 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 { customerProductToStripeItemSpecs } from "@server/internal/billing/v2/providers/stripe/utils/subscriptionItems/customerProductToStripeItemSpecs";
import type { StripeItemSpec } from "@shared/models/billingModels/stripeAdapterModels/stripeItemSpec"; import type { StripeItemSpec } from "@shared/models/billingModels/stripeAdapterModels/stripeItemSpec";
import type { FullCusProduct } from "@shared/models/cusProductModels/cusProductModels"; import type { FullCusProduct } from "@shared/models/cusProductModels/cusProductModels";
import {
ACTIVE_STATUSES,
isCustomerProductOnStripeSubscription,
} from "@shared/utils";
import type Stripe from "stripe"; import type Stripe from "stripe";
import { stripeSubscriptionItemToStripePriceId } from "@/external/stripe/subscriptions/subscriptionItems/utils/convertStripeSubscriptionItemUtils"; import { stripeSubscriptionItemToStripePriceId } from "@/external/stripe/subscriptions/subscriptionItems/utils/convertStripeSubscriptionItemUtils";
import { findStripeSubscriptionItemByStripePriceId } from "@/external/stripe/subscriptions/subscriptionItems/utils/findStripeSubscriptionItemUtils"; 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 type { BillingContext } from "@/internal/billing/v2/billingContext";
import { findStripeItemSpecByStripePriceId } from "./findStripeItemSpec"; 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 = ({ const customerProductsToRecurringStripeItemSpecs = ({
ctx, ctx,
billingContext, billingContext,
@@ -121,30 +94,31 @@ const stripeItemSpecsToSubItemsUpdate = ({
export const buildStripeSubscriptionItemsUpdate = ({ export const buildStripeSubscriptionItemsUpdate = ({
ctx, ctx,
billingContext, billingContext,
updatedCustomerProducts = [], finalCustomerProducts,
}: { }: {
ctx: AutumnContext; ctx: AutumnContext;
billingContext: BillingContext; billingContext: BillingContext;
updatedCustomerProducts?: FullCusProduct[]; finalCustomerProducts: FullCusProduct[];
}) => { }) => {
// 1. Get final customer product state (with updates applied) // 1. Filter customer products by stripe subscription id
let customerProducts = getFinalCustomerProductsState({ let customerProducts = filterCustomerProductsByStripeSubscriptionId({
billingContext, customerProducts: finalCustomerProducts,
updatedCustomerProducts, stripeSubscriptionId: billingContext.stripeSubscription?.id,
}); });
customerProducts = customerProducts.filter((customerProduct) => // 2. Filter customer products by active statuses
ACTIVE_STATUSES.includes(customerProduct.status), 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({ const recurringItems = customerProductsToRecurringStripeItemSpecs({
ctx, ctx,
billingContext, billingContext,
customerProducts, customerProducts,
}); });
// 3. Diff it with the current subscription items // 4. Diff it with the current subscription items
return stripeItemSpecsToSubItemsUpdate({ return stripeItemSpecsToSubItemsUpdate({
billingContext, billingContext,
stripeItemSpecs: recurringItems, stripeItemSpecs: recurringItems,

View File

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

View File

@@ -25,14 +25,6 @@ export const buildStripeSubscriptionCreateAction = ({
const isFreeTrialWithCardRequired = Boolean(freeTrial?.card_required); const isFreeTrialWithCardRequired = Boolean(freeTrial?.card_required);
const isCustomPaymentMethod = paymentMethod?.type === "custom"; 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 = { const stripeSubscriptionCreateParams: Stripe.SubscriptionCreateParams = {
customer: stripeCustomer.id, customer: stripeCustomer.id,
items: subItemsUpdate.map((item) => ({ items: subItemsUpdate.map((item) => ({
@@ -42,6 +34,8 @@ export const buildStripeSubscriptionCreateAction = ({
billing_mode: { type: "flexible" }, billing_mode: { type: "flexible" },
collection_method: "charge_automatically",
payment_behavior: isCustomPaymentMethod payment_behavior: isCustomPaymentMethod
? "default_incomplete" ? "default_incomplete"
: "error_if_incomplete", : "error_if_incomplete",

View File

@@ -9,6 +9,8 @@ import {
InternalError, InternalError,
type LineItemContext, type LineItemContext,
orgToCurrency, orgToCurrency,
priceToProrationConfig,
sumValues,
usagePriceToLineItem, usagePriceToLineItem,
} from "@autumn/shared"; } from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv"; 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 // Clone entitlement with updated quantity for the charge line item
const newCustomerEntitlement = cloneEntitlementWithUpdatedQuantity({ const newCustomerEntitlement = cloneEntitlementWithUpdatedQuantity({
customerEntitlement: prepaidCustomerEntitlement, customerEntitlement: prepaidCustomerEntitlement,
@@ -76,12 +90,23 @@ export const buildQuantityUpdateLineItems = ({
...lineItemContext, ...lineItemContext,
direction: "refund", direction: "refund",
}, },
shouldProrateOverride: shouldApplyProration,
chargeImmediatelyOverride: chargeImmediately,
}); });
const chargeLineItem = usagePriceToLineItem({ const chargeLineItem = usagePriceToLineItem({
cusEnt: newCustomerEntitlement, cusEnt: newCustomerEntitlement,
context: lineItemContext, 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]; return [refundLineItem, chargeLineItem];
}; };

View File

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

View File

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

View File

@@ -1,10 +1,12 @@
import { import {
CusProductStatus,
cusProductToProduct, cusProductToProduct,
type SubscriptionUpdateV0Params,
secondsToMs, secondsToMs,
type UpdateSubscriptionV0Params,
} from "@autumn/shared"; } from "@autumn/shared";
import type { AutumnContext } from "@server/honoUtils/HonoEnv"; 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 { computeSubscriptionUpdateFreeTrialPlan } from "@/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateFreeTrialPlan";
import { computeSubscriptionUpdateNewCustomerProduct } from "@/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateNewCustomerProduct"; import { computeSubscriptionUpdateNewCustomerProduct } from "@/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateNewCustomerProduct";
import type { AutumnBillingPlan } from "@/internal/billing/v2/types/billingPlan"; import type { AutumnBillingPlan } from "@/internal/billing/v2/types/billingPlan";
@@ -16,8 +18,8 @@ export const computeSubscriptionUpdateCustomPlan = async ({
params, params,
}: { }: {
ctx: AutumnContext; ctx: AutumnContext;
updateSubscriptionContext: UpdateSubscriptionContext; updateSubscriptionContext: UpdateSubscriptionBillingContext;
params: SubscriptionUpdateV0Params; params: UpdateSubscriptionV0Params;
}) => { }) => {
const { customerProduct, stripeSubscription } = updateSubscriptionContext; const { customerProduct, stripeSubscription } = updateSubscriptionContext;
@@ -36,7 +38,7 @@ export const computeSubscriptionUpdateCustomPlan = async ({
customItems: params.items, customItems: params.items,
}); });
updateSubscriptionContext.fullProducts.push(customFullProduct); updateSubscriptionContext.fullProducts = [customFullProduct];
// 2. Compute the custom trial details // 2. Compute the custom trial details
const { freeTrialPlan, customFreeTrial } = const { freeTrialPlan, customFreeTrial } =
@@ -46,12 +48,10 @@ export const computeSubscriptionUpdateCustomPlan = async ({
fullProduct: customFullProduct, fullProduct: customFullProduct,
}); });
const billingCycleAnchor = updateSubscriptionContext.billingCycleAnchorMs =
freeTrialPlan.trialEndsAt ?? freeTrialPlan.trialEndsAt ??
secondsToMs(stripeSubscription?.billing_cycle_anchor); secondsToMs(stripeSubscription?.billing_cycle_anchor);
const nowMs = updateSubscriptionContext.testClockFrozenTime ?? Date.now();
// 3. Compute the new customer product // 3. Compute the new customer product
const newFullCustomerProduct = computeSubscriptionUpdateNewCustomerProduct({ const newFullCustomerProduct = computeSubscriptionUpdateNewCustomerProduct({
ctx, ctx,
@@ -59,85 +59,24 @@ export const computeSubscriptionUpdateCustomPlan = async ({
params, params,
fullProduct: customFullProduct, fullProduct: customFullProduct,
freeTrialPlan, freeTrialPlan,
billingCycleAnchor,
}); });
// Line items? const autumnLineItems = buildAutumnLineItems({
ctx,
newCustomerProducts: [newFullCustomerProduct],
deletedCustomerProduct: customerProduct,
billingContext: updateSubscriptionContext,
});
return { return {
insertCustomerProducts: [newFullCustomerProduct], insertCustomerProducts: [newFullCustomerProduct],
// updateCustomerProduct: { updateCustomerProduct: {
// customerProduct: customerProduct, ...customerProduct,
// updates: { status: CusProductStatus.Expired,
// status: CusProductStatus.Expired, },
// },
// },
customPrices: customPrices, customPrices: customPrices,
customEntitlements: customEnts, customEntitlements: customEnts,
customFreeTrial: customFreeTrial, customFreeTrial: customFreeTrial,
autumnLineItems: [], autumnLineItems,
} satisfies AutumnBillingPlan; } 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, InternalError,
isPrepaidPrice, isPrepaidPrice,
priceToFeature, priceToFeature,
type SubscriptionUpdateV0Params, type UpdateSubscriptionV0Params,
} from "@autumn/shared"; } from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv"; import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { paramsToFeatureOptions } from "@/internal/billing/v2/compute/computeAutumnUtils/paramsToFeatureOptions"; import { paramsToFeatureOptions } from "@/internal/billing/v2/compute/computeAutumnUtils/paramsToFeatureOptions";
@@ -23,7 +23,7 @@ export const computeSubscriptionUpdateFeatureQuantities = ({
ctx: AutumnContext; ctx: AutumnContext;
fullProduct: FullProduct; fullProduct: FullProduct;
currentCustomerProduct: FullCusProduct; currentCustomerProduct: FullCusProduct;
params: SubscriptionUpdateV0Params; params: UpdateSubscriptionV0Params;
}) => { }) => {
const newFeatureQuantities: FeatureOptions[] = []; const newFeatureQuantities: FeatureOptions[] = [];
for (const price of fullProduct.prices) { for (const price of fullProduct.prices) {

View File

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

View File

@@ -1,11 +1,7 @@
import { import type { FullProduct, UpdateSubscriptionV0Params } from "@autumn/shared";
type FullProduct,
InternalError,
type SubscriptionUpdateV0Params,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv"; 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 { 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 type { FreeTrialPlan } from "@/internal/billing/v2/types/billingPlan";
import { cusProductToExistingRollovers } from "@/internal/billing/v2/utils/handleExistingRollovers/cusProductToExistingRollovers"; import { cusProductToExistingRollovers } from "@/internal/billing/v2/utils/handleExistingRollovers/cusProductToExistingRollovers";
import { cusProductToExistingUsages } from "@/internal/billing/v2/utils/handleExistingUsages/cusProductToExistingUsages"; import { cusProductToExistingUsages } from "@/internal/billing/v2/utils/handleExistingUsages/cusProductToExistingUsages";
@@ -17,29 +13,22 @@ export const computeSubscriptionUpdateNewCustomerProduct = ({
updateSubscriptionContext, updateSubscriptionContext,
fullProduct, fullProduct,
freeTrialPlan, freeTrialPlan,
billingCycleAnchor,
}: { }: {
ctx: AutumnContext; ctx: AutumnContext;
params: SubscriptionUpdateV0Params; params: UpdateSubscriptionV0Params;
updateSubscriptionContext: UpdateSubscriptionContext; updateSubscriptionContext: UpdateSubscriptionBillingContext;
fullProduct: FullProduct; fullProduct: FullProduct;
freeTrialPlan: FreeTrialPlan; freeTrialPlan: FreeTrialPlan;
billingCycleAnchor?: number;
}) => { }) => {
const { const {
customerProduct, customerProduct,
fullCustomer, fullCustomer,
stripeSubscription, stripeSubscription,
stripeSubscriptionSchedule, stripeSubscriptionSchedule,
billingCycleAnchorMs,
currentEpochMs, currentEpochMs,
} = updateSubscriptionContext; } = updateSubscriptionContext;
if (!stripeSubscription) {
throw new InternalError({
message: `[Subscription Update] Stripe subscription not found`,
});
}
// 1. Get feature quantities // 1. Get feature quantities
const existingUsages = cusProductToExistingUsages({ const existingUsages = cusProductToExistingUsages({
cusProduct: customerProduct, cusProduct: customerProduct,
@@ -67,7 +56,7 @@ export const computeSubscriptionUpdateNewCustomerProduct = ({
featureQuantities, featureQuantities,
existingUsages, existingUsages,
existingRollovers, existingRollovers,
resetCycleAnchor: billingCycleAnchor ?? "now", resetCycleAnchor: billingCycleAnchorMs ?? "now",
now: currentEpochMs, now: currentEpochMs,
freeTrial: freeTrialPlan.freeTrial ?? null, freeTrial: freeTrialPlan.freeTrial ?? null,
@@ -76,7 +65,7 @@ export const computeSubscriptionUpdateNewCustomerProduct = ({
initOptions: { initOptions: {
isCustom: true, isCustom: true,
subscriptionId: stripeSubscription.id, subscriptionId: stripeSubscription?.id,
subscriptionScheduleId: stripeSubscriptionSchedule?.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"; import { SubscriptionUpdateIntentEnum } from "./computeSubscriptionUpdateSchema";
/** /**
@@ -7,7 +7,7 @@ import { SubscriptionUpdateIntentEnum } from "./computeSubscriptionUpdateSchema"
* @returns The intent for the subscription update * @returns The intent for the subscription update
*/ */
export const computeSubscriptionUpdateIntent = ( export const computeSubscriptionUpdateIntent = (
params: SubscriptionUpdateV0Params, params: UpdateSubscriptionV0Params,
): SubscriptionUpdateIntentEnum => { ): SubscriptionUpdateIntentEnum => {
if (params.options?.length && !params.items?.length) if (params.options?.length && !params.items?.length)
return SubscriptionUpdateIntentEnum.UpdateQuantity; 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 { AutumnContext } from "@/honoUtils/HonoEnv";
import type { UpdateSubscriptionBillingContext } from "@/internal/billing/v2/billingContext";
import { computeSubscriptionUpdateCustomPlan } from "@/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateCustomPlan"; import { computeSubscriptionUpdateCustomPlan } from "@/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateCustomPlan";
import { computeSubscriptionUpdateQuantityPlan } from "@/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateQuantityPlan"; import { computeSubscriptionUpdateQuantityPlan } from "@/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateQuantityPlan";
import { SubscriptionUpdateIntentEnum } from "@/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateSchema"; import { SubscriptionUpdateIntentEnum } from "@/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateSchema";
import type { AutumnBillingPlan } from "@/internal/billing/v2/types/billingPlan"; import type { AutumnBillingPlan } from "@/internal/billing/v2/types/billingPlan";
import type { UpdateSubscriptionContext } from "../fetch/updateSubscriptionContextSchema";
import { computeSubscriptionUpdateIntent } from "./computeSubscriptionUpdateIntent"; import { computeSubscriptionUpdateIntent } from "./computeSubscriptionUpdateIntent";
/** /**
@@ -15,12 +15,12 @@ import { computeSubscriptionUpdateIntent } from "./computeSubscriptionUpdateInte
*/ */
export const computeSubscriptionUpdatePlan = async ({ export const computeSubscriptionUpdatePlan = async ({
ctx, ctx,
updateSubscriptionContext, billingContext,
params, params,
}: { }: {
ctx: AutumnContext; ctx: AutumnContext;
updateSubscriptionContext: UpdateSubscriptionContext; billingContext: UpdateSubscriptionBillingContext;
params: SubscriptionUpdateV0Params; params: UpdateSubscriptionV0Params;
}): Promise<AutumnBillingPlan> => { }): Promise<AutumnBillingPlan> => {
const intent = computeSubscriptionUpdateIntent(params); const intent = computeSubscriptionUpdateIntent(params);
@@ -28,13 +28,13 @@ export const computeSubscriptionUpdatePlan = async ({
case SubscriptionUpdateIntentEnum.UpdateQuantity: case SubscriptionUpdateIntentEnum.UpdateQuantity:
return computeSubscriptionUpdateQuantityPlan({ return computeSubscriptionUpdateQuantityPlan({
ctx, ctx,
updateSubscriptionContext, updateSubscriptionContext: billingContext,
params, params,
}); });
case SubscriptionUpdateIntentEnum.UpdatePlan: case SubscriptionUpdateIntentEnum.UpdatePlan:
return await computeSubscriptionUpdateCustomPlan({ return await computeSubscriptionUpdateCustomPlan({
ctx, ctx,
updateSubscriptionContext, updateSubscriptionContext: billingContext,
params, 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 { AutumnContext } from "@/honoUtils/HonoEnv";
import type { AutumnBillingPlan } from "../../types/billingPlan"; import type { AutumnBillingPlan } from "../../types/billingPlan";
import type { UpdateSubscriptionContext } from "../fetch/updateSubscriptionContextSchema"; import type { UpdateSubscriptionBillingContext } from "../../billingContext";
import { computeQuantityUpdateDetails } from "./computeQuantityUpdateDetails"; import { computeQuantityUpdateDetails } from "./computeQuantityUpdateDetails";
export const computeSubscriptionUpdateQuantityPlan = ({ export const computeSubscriptionUpdateQuantityPlan = ({
@@ -10,8 +10,8 @@ export const computeSubscriptionUpdateQuantityPlan = ({
params, params,
}: { }: {
ctx: AutumnContext; ctx: AutumnContext;
updateSubscriptionContext: UpdateSubscriptionContext; updateSubscriptionContext: UpdateSubscriptionBillingContext;
params: SubscriptionUpdateV0Params; params: UpdateSubscriptionV0Params;
}): AutumnBillingPlan => { }): AutumnBillingPlan => {
const { customerProduct, stripeSubscription } = updateSubscriptionContext; const { customerProduct, stripeSubscription } = updateSubscriptionContext;

View File

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

View File

@@ -4,40 +4,31 @@ import {
type FullCustomerPrice, type FullCustomerPrice,
findCusPriceByFeature, findCusPriceByFeature,
InternalError, InternalError,
type OnDecrease,
type OnIncrease,
type Price, type Price,
priceToProrationConfig,
type UsagePriceConfig, type UsagePriceConfig,
} from "@autumn/shared"; } from "@autumn/shared";
/** /**
* Extracts and validates price configuration for a quantity update. * 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 updatedOptions - New feature options with target quantity
* @param customerProduct - Customer product with associated prices * @param customerProduct - Customer product with associated prices
* @param previousOptions - Current feature options * @returns Price config including billing units
* @returns Price config including proration rules and billing units
* @throws {InternalError} When internal_feature_id is missing or price not found * @throws {InternalError} When internal_feature_id is missing or price not found
*/ */
export const resolvePriceForQuantityUpdate = ({ export const resolvePriceForQuantityUpdate = ({
customerProduct, customerProduct,
updatedOptions, updatedOptions,
isUpgrade,
}: { }: {
customerProduct: FullCusProduct; customerProduct: FullCusProduct;
updatedOptions: FeatureOptions; updatedOptions: FeatureOptions;
isUpgrade: boolean;
}): { }): {
customerPrice: FullCustomerPrice; customerPrice: FullCustomerPrice;
price: Price; price: Price;
priceConfig: UsagePriceConfig; priceConfig: UsagePriceConfig;
billingUnitsPerQuantity: number; billingUnitsPerQuantity: number;
prorationBehaviorConfig: OnIncrease | OnDecrease;
shouldApplyProration: boolean;
shouldFinalizeInvoiceImmediately: boolean;
} => { } => {
if (!updatedOptions.internal_feature_id) { if (!updatedOptions.internal_feature_id) {
throw new InternalError({ throw new InternalError({
@@ -60,22 +51,10 @@ export const resolvePriceForQuantityUpdate = ({
const priceConfig = price.config as UsagePriceConfig; const priceConfig = price.config as UsagePriceConfig;
const billingUnitsPerQuantity = priceConfig.billing_units ?? 1; const billingUnitsPerQuantity = priceConfig.billing_units ?? 1;
const {
prorationBehaviorConfig,
shouldApplyProration,
shouldFinalizeInvoiceImmediately,
} = priceToProrationConfig({
price,
isUpgrade,
});
return { return {
customerPrice, customerPrice,
price, price,
priceConfig, priceConfig,
billingUnitsPerQuantity, 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 { import {
type FullCustomer, type FullCustomer,
isCusProductOnEntity, isCusProductOnEntity,
type SubscriptionUpdateV0Params, type UpdateSubscriptionV0Params,
} from "@autumn/shared"; } from "@autumn/shared";
export const fetchTargetCusProductForUpdate = ({ export const fetchTargetCusProductForUpdate = ({
params, params,
fullCustomer, fullCustomer,
}: { }: {
params: SubscriptionUpdateV0Params; params: UpdateSubscriptionV0Params;
fullCustomer: FullCustomer; fullCustomer: FullCustomer;
}) => { }) => {
const cusProducts = fullCustomer.customer_products; const cusProducts = fullCustomer.customer_products;

View File

@@ -1,16 +1,17 @@
import { import {
cusProductToProduct,
InternalError, InternalError,
type SubscriptionUpdateV0Params,
secondsToMs, secondsToMs,
} from "@shared/index"; type UpdateSubscriptionV0Params,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv"; import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { fetchStripeCustomerForBilling } from "@/internal/billing/v2/providers/stripe/fetch/fetchStripeCustomerForBilling"; import { fetchStripeCustomerForBilling } from "@/internal/billing/v2/providers/stripe/fetch/fetchStripeCustomerForBilling";
import { fetchStripeSubscriptionForBilling } from "@/internal/billing/v2/providers/stripe/fetch/fetchStripeSubscriptionForBilling"; import { fetchStripeSubscriptionForBilling } from "@/internal/billing/v2/providers/stripe/fetch/fetchStripeSubscriptionForBilling";
import { fetchStripeSubscriptionScheduleForBilling } from "@/internal/billing/v2/providers/stripe/fetch/fetchStripeSubscriptionScheduleForBilling"; import { fetchStripeSubscriptionScheduleForBilling } from "@/internal/billing/v2/providers/stripe/fetch/fetchStripeSubscriptionScheduleForBilling";
import { CusService } from "../../../../customers/CusService"; import { CusService } from "../../../../customers/CusService";
import type { UpdateSubscriptionBillingContext } from "../../billingContext";
import { parseFeatureQuantitiesParams } from "../../utils/parseFeatureQuantitiesParams"; import { parseFeatureQuantitiesParams } from "../../utils/parseFeatureQuantitiesParams";
import { fetchTargetCusProductForUpdate } from "./fetchTargetCusProductForUpdate"; import { fetchTargetCusProductForUpdate } from "./fetchTargetCusProductForUpdate";
import type { UpdateSubscriptionContext } from "./updateSubscriptionContextSchema";
/** /**
* Fetch the context for updating a subscription * Fetch the context for updating a subscription
@@ -18,13 +19,13 @@ import type { UpdateSubscriptionContext } from "./updateSubscriptionContextSchem
* @param body - The body of the request * @param body - The body of the request
* @returns The update subscription context * @returns The update subscription context
*/ */
export const fetchApiSubscriptionUpdateContext = async ({ export const fetchUpdateSubscriptionBillingContext = async ({
ctx, ctx,
params, params,
}: { }: {
ctx: AutumnContext; ctx: AutumnContext;
params: SubscriptionUpdateV0Params; params: UpdateSubscriptionV0Params;
}): Promise<UpdateSubscriptionContext> => { }): Promise<UpdateSubscriptionBillingContext> => {
const { db, org, env, features } = ctx; const { db, org, env, features } = ctx;
const { customer_id: customerId, product_id: productId } = params; 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({ const stripeSubscription = await fetchStripeSubscriptionForBilling({
ctx, ctx,
fullCus: fullCustomer, fullCus: fullCustomer,
@@ -91,16 +96,26 @@ export const fetchApiSubscriptionUpdateContext = async ({
stripeSubscription?.billing_cycle_anchor, stripeSubscription?.billing_cycle_anchor,
); );
// Invoice mode
const invoiceMode =
params?.invoice === true
? {
finalizeInvoice: params.finalize_invoice === true,
enableProductImmediately: params.enable_product_immediately !== false,
}
: undefined;
return { return {
fullCustomer, fullCustomer,
fullProducts: [], fullProducts: [fullProduct],
customerProduct: targetCustomerProduct, customerProduct: targetCustomerProduct,
stripeSubscription, stripeSubscription,
stripeSubscriptionSchedule, stripeSubscriptionSchedule,
stripeCustomer, stripeCustomer,
paymentMethod, paymentMethod,
testClockFrozenTime,
currentEpochMs, currentEpochMs,
billingCycleAnchorMs, 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, PriceSchema,
} from "@autumn/shared"; } from "@autumn/shared";
import { z } from "zod/v4"; 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"; import { FullCusProductSchema } from "../../../../../../shared/models/cusProductModels/cusProductModels";
export const FreeTrialPlanSchema = z.object({ export const FreeTrialPlanSchema = z.object({
@@ -15,13 +17,6 @@ export const FreeTrialPlanSchema = z.object({
export type FreeTrialPlan = z.infer<typeof FreeTrialPlanSchema>; 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({ export const UpdateCustomerEntitlementSchema = z.object({
customerEntitlementId: z.string(), customerEntitlementId: z.string(),
balanceChange: z.number(), balanceChange: z.number(),
@@ -50,5 +45,6 @@ export type AutumnBillingPlan = z.infer<typeof AutumnBillingPlanSchema>;
export type DeferredAutumnBillingPlanData = { export type DeferredAutumnBillingPlanData = {
orgId: string; orgId: string;
env: AppEnv; env: AppEnv;
autumnBillingPlan: AutumnBillingPlan; billingPlan: BillingPlan;
billingContext: BillingContext;
}; };

View File

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

View File

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

View File

@@ -1,10 +1,7 @@
import { z } from "zod/v4"; import { z } from "zod/v4";
import { InvoiceModeSchema } from "../autumnBillingPlan";
export const StripeInvoiceActionSchema = z.object({ export const StripeInvoiceActionSchema = z.object({
addLineParams: z.custom<import("stripe").Stripe.InvoiceAddLinesParams>(), addLineParams: z.custom<import("stripe").Stripe.InvoiceAddLinesParams>(),
invoiceMode: InvoiceModeSchema.optional(),
}); });
export type StripeInvoiceAction = z.infer<typeof StripeInvoiceActionSchema>; 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 { and, eq } from "drizzle-orm";
import type { DrizzleCli } from "@/db/initDrizzle.js"; import type { DrizzleCli } from "@/db/initDrizzle.js";
/**
* MetadataService handles CRUD operations for the metadata table.
*/
export class MetadataService { export class MetadataService {
static async insert({ db, data }: { db: DrizzleCli; data: MetadataInsert }) { 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 }) { 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; return product;
}; };
// biome-ignore lint/complexity/noStaticOnlyClass: no thanks m8
export class ProductService { export class ProductService {
static async getByFeature({ static async getByFeature({
db, db,

View File

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

View File

@@ -1,9 +1,17 @@
import { beforeAll, describe, expect, test } from "bun:test"; 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 { TestFeature } from "@tests/setup/v2Features.js";
import ctx from "@tests/utils/testInitUtils/createTestContext.js"; import ctx from "@tests/utils/testInitUtils/createTestContext.js";
import chalk from "chalk"; import chalk from "chalk";
import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { ProductService } from "@/internal/products/ProductService";
import { constructPrepaidItem } from "@/utils/scriptUtils/constructItem.js"; import { constructPrepaidItem } from "@/utils/scriptUtils/constructItem.js";
import { constructRawProduct } from "@/utils/scriptUtils/createTestProducts.js"; import { constructRawProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.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 customerId = "sub-update-basic";
const autumnV1 = new AutumnInt({ version: ApiVersion.V1_2 }); const autumnV1 = new AutumnInt({ version: ApiVersion.V1_2 });
const prepaidProduct = constructRawProduct({ const prepaidMessages = constructPrepaidItem({
id: "prepaid_messages",
items: [
constructPrepaidItem({
featureId: TestFeature.Messages, featureId: TestFeature.Messages,
billingUnits, billingUnits,
price: pricePerUnit, price: pricePerUnit,
}),
],
}); });
const prepaidProduct = constructRawProduct({
id: "prepaid_messages",
items: [prepaidMessages],
});
let prepaidMessagesPrice: Price | undefined;
beforeAll(async () => { beforeAll(async () => {
await initCustomerV3({ await initCustomerV3({
ctx, ctx,
@@ -55,6 +65,18 @@ describe(`${chalk.yellowBright("subscription-update: basic quantity updates")}`,
prefix: customerId, 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({ await autumnV1.attach({
customer_id: customerId, customer_id: customerId,
product_id: prepaidProduct.id, 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) // Should have 240 messages (20 units × 12 billing_units)
expect(balance?.purchased_balance).toBe(240); expect(balance?.purchased_balance).toBe(240);
expect(balance?.current_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 () => { 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 customer = await autumnV1.customers.get<ApiCustomer>(customerId);
const balance = customer.balances?.[TestFeature.Messages]; const balance = customer.balances?.[TestFeature.Messages];
// Should have 60 messages (5 units × 12 billing_units) // 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 () => { test("should update to same quantity (no-op)", async () => {
const beforeUpdate = await autumnV1.customers.get<ApiCustomer>(customerId); const beforeUpdate = await autumnV1.customers.get<ApiCustomer>(customerId);
const beforeBalance = beforeUpdate.balances?.[TestFeature.Messages]; const beforeBalance = beforeUpdate.balances?.[TestFeature.Messages];
const beforeInvoiceCount = beforeUpdate.invoices?.length ?? 0;
await autumnV1.subscriptionUpdate({ await autumnV1.subscriptionUpdate({
customer_id: customerId, customer_id: customerId,
@@ -123,10 +169,12 @@ describe(`${chalk.yellowBright("subscription-update: basic quantity updates")}`,
const afterUpdate = await autumnV1.customers.get<ApiCustomer>(customerId); const afterUpdate = await autumnV1.customers.get<ApiCustomer>(customerId);
const afterBalance = afterUpdate.balances?.[TestFeature.Messages]; const afterBalance = afterUpdate.balances?.[TestFeature.Messages];
const afterInvoiceCount = afterUpdate.invoices?.length ?? 0;
expect(afterBalance?.purchased_balance).toBe( expect(afterBalance?.current_balance).toBe(beforeBalance?.current_balance);
beforeBalance?.purchased_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 customerId = "sub-update-multi";
const autumnV1 = new AutumnInt({ version: ApiVersion.V1_2 }); const autumnV1 = new AutumnInt({ version: ApiVersion.V1_2 });
const messagesBillingUnits = 10;
const wordsBillingUnits = 100;
const multiFeatureProduct = constructRawProduct({ const multiFeatureProduct = constructRawProduct({
id: "multi_feature_product", id: "multi_feature_product",
items: [ items: [
constructPrepaidItem({ constructPrepaidItem({
featureId: TestFeature.Messages, featureId: TestFeature.Messages,
billingUnits: 10, billingUnits: messagesBillingUnits,
price: 5, price: 5,
}), }),
constructPrepaidItem({ constructPrepaidItem({
featureId: TestFeature.Words, featureId: TestFeature.Words,
billingUnits: 100, billingUnits: wordsBillingUnits,
price: 10, price: 10,
}), }),
], ],
}); });
let messagesPrice: Price | undefined;
let wordsPrice: Price | undefined;
beforeAll(async () => { beforeAll(async () => {
await initCustomerV3({ await initCustomerV3({
ctx, ctx,
@@ -164,17 +218,34 @@ describe(`${chalk.yellowBright("subscription-update: multiple features")}`, () =
prefix: customerId, 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({ await autumnV1.attach({
customer_id: customerId, customer_id: customerId,
product_id: multiFeatureProduct.id, product_id: multiFeatureProduct.id,
options: [ options: [
{ {
feature_id: TestFeature.Messages, feature_id: TestFeature.Messages,
quantity: 5 * 10, // 50 messages quantity: 5 * messagesBillingUnits, // 50 messages
}, },
{ {
feature_id: TestFeature.Words, 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: [ options: [
{ {
feature_id: TestFeature.Messages, feature_id: TestFeature.Messages,
quantity: 10 * 10, // 100 messages quantity: 10 * messagesBillingUnits, // 100 messages
}, },
{ {
feature_id: TestFeature.Words, 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]; const wordsBalance = customer.balances?.[TestFeature.Words];
expect(wordsBalance?.purchased_balance).toBe(500); 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 () => { test("should update only one feature while keeping the other unchanged", async () => {
@@ -212,11 +300,11 @@ describe(`${chalk.yellowBright("subscription-update: multiple features")}`, () =
options: [ options: [
{ {
feature_id: TestFeature.Messages, feature_id: TestFeature.Messages,
quantity: 15 * 10, // 150 messages (changed) quantity: 15 * messagesBillingUnits, // 150 messages (changed)
}, },
{ {
feature_id: TestFeature.Words, 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]; const wordsBalance = customer.balances?.[TestFeature.Words];
expect(wordsBalance?.purchased_balance).toBe(500); 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 { 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 { createMockCtx } from "@tests/utils/mockUtils/contextMocks";
import { createMockCustomerProduct } from "@tests/utils/mockUtils/cusProductMocks"; import { createMockCustomerProduct } from "@tests/utils/mockUtils/cusProductMocks";
import { createMockFeature } from "@tests/utils/mockUtils/featureMocks"; import { createMockFeature } from "@tests/utils/mockUtils/featureMocks";
@@ -41,7 +41,7 @@ describe(
customerPrices: [createMockCustomerPrice({ price })], customerPrices: [createMockCustomerPrice({ price })],
}); });
const params: SubscriptionUpdateV0Params = { const params: UpdateSubscriptionV0Params = {
customer_id: "cus_test", customer_id: "cus_test",
product_id: "prod_test", product_id: "prod_test",
// No options provided // No options provided
@@ -75,7 +75,7 @@ describe(
const fullProduct = createMockFullProduct({ prices: [price] }); const fullProduct = createMockFullProduct({ prices: [price] });
const cusProduct = createMockCustomerProduct({ options: [] }); const cusProduct = createMockCustomerProduct({ options: [] });
const params: SubscriptionUpdateV0Params = { const params: UpdateSubscriptionV0Params = {
customer_id: "cus_test", customer_id: "cus_test",
product_id: "prod_test", product_id: "prod_test",
options: [{ feature_id: "credits", quantity: 50 }], options: [{ feature_id: "credits", quantity: 50 }],
@@ -117,7 +117,7 @@ describe(
], ],
}); });
const params: SubscriptionUpdateV0Params = { const params: UpdateSubscriptionV0Params = {
customer_id: "cus_test", customer_id: "cus_test",
product_id: "prod_test", product_id: "prod_test",
options: [{ feature_id: "credits", quantity: 200 }], options: [{ feature_id: "credits", quantity: 200 }],
@@ -195,7 +195,7 @@ describe(
], ],
}); });
const params: SubscriptionUpdateV0Params = { const params: UpdateSubscriptionV0Params = {
customer_id: "cus_test", customer_id: "cus_test",
product_id: "prod_test", product_id: "prod_test",
options: [{ feature_id: "seats", quantity: 10 }], // Only updating seats options: [{ feature_id: "seats", quantity: 10 }], // Only updating seats
@@ -240,7 +240,7 @@ describe(
const fullProduct = createMockFullProduct({ prices: [price] }); const fullProduct = createMockFullProduct({ prices: [price] });
const cusProduct = createMockCustomerProduct({ options: [] }); const cusProduct = createMockCustomerProduct({ options: [] });
const params: SubscriptionUpdateV0Params = { const params: UpdateSubscriptionV0Params = {
customer_id: "cus_test", customer_id: "cus_test",
product_id: "prod_test", product_id: "prod_test",
options: [{ feature_id: "credits", quantity: 150 }], // Should round up to 200 options: [{ feature_id: "credits", quantity: 150 }], // Should round up to 200
@@ -276,7 +276,7 @@ describe(
const fullProduct = createMockFullProduct({ prices: [price] }); const fullProduct = createMockFullProduct({ prices: [price] });
const cusProduct = createMockCustomerProduct({ options: [] }); const cusProduct = createMockCustomerProduct({ options: [] });
const params: SubscriptionUpdateV0Params = { const params: UpdateSubscriptionV0Params = {
customer_id: "cus_test", customer_id: "cus_test",
product_id: "prod_test", product_id: "prod_test",
options: [{ feature_id: "credits", quantity: 200 }], // Exact multiple options: [{ feature_id: "credits", quantity: 200 }], // Exact multiple
@@ -311,7 +311,7 @@ describe(
const fullProduct = createMockFullProduct({ prices: [price] }); const fullProduct = createMockFullProduct({ prices: [price] });
const cusProduct = createMockCustomerProduct({ options: [] }); const cusProduct = createMockCustomerProduct({ options: [] });
const params: SubscriptionUpdateV0Params = { const params: UpdateSubscriptionV0Params = {
customer_id: "cus_test", customer_id: "cus_test",
product_id: "prod_test", product_id: "prod_test",
options: [{ feature_id: "credits", quantity: 1 }], // Should round to 1000 options: [{ feature_id: "credits", quantity: 1 }], // Should round to 1000
@@ -367,7 +367,7 @@ describe(
customerPrices: [createMockCustomerPrice({ price: oldPrice })], customerPrices: [createMockCustomerPrice({ price: oldPrice })],
}); });
const params: SubscriptionUpdateV0Params = { const params: UpdateSubscriptionV0Params = {
customer_id: "cus_test", customer_id: "cus_test",
product_id: "prod_test", product_id: "prod_test",
// No options - should inherit from current // No options - should inherit from current
@@ -424,7 +424,7 @@ describe(
customerPrices: [createMockCustomerPrice({ price: oldPrice })], customerPrices: [createMockCustomerPrice({ price: oldPrice })],
}); });
const params: SubscriptionUpdateV0Params = { const params: UpdateSubscriptionV0Params = {
customer_id: "cus_test", customer_id: "cus_test",
product_id: "prod_test", product_id: "prod_test",
}; };
@@ -472,7 +472,7 @@ describe(
// No customerPrices - can't interpret stored quantity // No customerPrices - can't interpret stored quantity
}); });
const params: SubscriptionUpdateV0Params = { const params: UpdateSubscriptionV0Params = {
customer_id: "cus_test", customer_id: "cus_test",
product_id: "prod_test", product_id: "prod_test",
}; };
@@ -511,7 +511,7 @@ describe(
const cusProduct = createMockCustomerProduct({ options: [] }); const cusProduct = createMockCustomerProduct({ options: [] });
const params: SubscriptionUpdateV0Params = { const params: UpdateSubscriptionV0Params = {
customer_id: "cus_test", customer_id: "cus_test",
product_id: "prod_test", product_id: "prod_test",
options: [{ feature_id: "credits", quantity: 50 }], options: [{ feature_id: "credits", quantity: 50 }],
@@ -537,7 +537,7 @@ describe(
const fullProduct = createMockFullProduct({ prices: [] }); const fullProduct = createMockFullProduct({ prices: [] });
const cusProduct = createMockCustomerProduct({ options: [] }); const cusProduct = createMockCustomerProduct({ options: [] });
const params: SubscriptionUpdateV0Params = { const params: UpdateSubscriptionV0Params = {
customer_id: "cus_test", customer_id: "cus_test",
product_id: "prod_test", product_id: "prod_test",
}; };
@@ -563,7 +563,7 @@ describe(
const fullProduct = createMockFullProduct({ prices: [price] }); const fullProduct = createMockFullProduct({ prices: [price] });
const cusProduct = createMockCustomerProduct({ options: [] }); const cusProduct = createMockCustomerProduct({ options: [] });
const params: SubscriptionUpdateV0Params = { const params: UpdateSubscriptionV0Params = {
customer_id: "cus_test", customer_id: "cus_test",
product_id: "prod_test", product_id: "prod_test",
}; };
@@ -595,7 +595,7 @@ describe(
const fullProduct = createMockFullProduct({ prices: [price] }); const fullProduct = createMockFullProduct({ prices: [price] });
const cusProduct = createMockCustomerProduct({ options: [] }); // No current options const cusProduct = createMockCustomerProduct({ options: [] }); // No current options
const params: SubscriptionUpdateV0Params = { const params: UpdateSubscriptionV0Params = {
customer_id: "cus_test", customer_id: "cus_test",
product_id: "prod_test", product_id: "prod_test",
// No options in params either // No options in params either
@@ -637,7 +637,7 @@ describe(
customerPrices: [createMockCustomerPrice({ price })], customerPrices: [createMockCustomerPrice({ price })],
}); });
const params: SubscriptionUpdateV0Params = { const params: UpdateSubscriptionV0Params = {
customer_id: "cus_test", customer_id: "cus_test",
product_id: "prod_test", product_id: "prod_test",
options: [], // Explicitly empty options: [], // Explicitly empty
@@ -680,7 +680,7 @@ describe(
customerPrices: [createMockCustomerPrice({ price })], customerPrices: [createMockCustomerPrice({ price })],
}); });
const params: SubscriptionUpdateV0Params = { const params: UpdateSubscriptionV0Params = {
customer_id: "cus_test", customer_id: "cus_test",
product_id: "prod_test", product_id: "prod_test",
options: [{ feature_id: "credits", quantity: 0 }], options: [{ feature_id: "credits", quantity: 0 }],
@@ -726,7 +726,7 @@ describe(
customerPrices: [createMockCustomerPrice({ price })], customerPrices: [createMockCustomerPrice({ price })],
}); });
const params: SubscriptionUpdateV0Params = { const params: UpdateSubscriptionV0Params = {
customer_id: "cus_test", customer_id: "cus_test",
product_id: "prod_test", product_id: "prod_test",
}; };

View File

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

View File

@@ -5,7 +5,7 @@ import { ProductItemSchema } from "../../../models/productV2Models/productItemMo
import { CustomerDataSchema } from "../../common/customerData"; import { CustomerDataSchema } from "../../common/customerData";
import { EntityDataSchema } from "../../models"; import { EntityDataSchema } from "../../models";
export const ExtSubscriptionUpdateV0ParamsSchema = z.object({ export const ExtUpdateSubscriptionV0ParamsSchema = z.object({
// Customer / Entity Info // Customer / Entity Info
customer_id: z.string(), customer_id: z.string(),
product_id: z.string().nullish(), product_id: z.string().nullish(),
@@ -33,17 +33,17 @@ export const ExtSubscriptionUpdateV0ParamsSchema = z.object({
prorate_billing: z.boolean().optional(), prorate_billing: z.boolean().optional(),
}); });
export const SubscriptionUpdateV0ParamsSchema = export const UpdateSubscriptionV0ParamsSchema =
ExtSubscriptionUpdateV0ParamsSchema.extend({ ExtUpdateSubscriptionV0ParamsSchema.extend({
customer_product_id: z.string().optional(), customer_product_id: z.string().optional(),
}); });
export type ExtSubscriptionUpdateV0Params = z.infer< export type ExtUpdateSubscriptionV0Params = z.infer<
typeof ExtSubscriptionUpdateV0ParamsSchema typeof ExtUpdateSubscriptionV0ParamsSchema
>; >;
export type SubscriptionUpdateV0Params = z.infer< export type UpdateSubscriptionV0Params = z.infer<
typeof SubscriptionUpdateV0ParamsSchema typeof UpdateSubscriptionV0ParamsSchema
>; >;
// keep_existing_plan: true, //disable_plan_switch // 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/checkoutParamsV0.js"; export * from "./billing/checkout/prevVersions/checkoutParamsV0.js";
export * from "./billing/checkout/prevVersions/checkoutResponseV0.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/subscriptionUpdateV1Params.js";
export * from "./billing/subscriptionUpdate/updateSubscriptionV0Params.js";
export * from "./common/customerData.js"; export * from "./common/customerData.js";
export * from "./common/entityData.js"; export * from "./common/entityData.js";
export * from "./common/pagePaginationSchemas.js"; export * from "./common/pagePaginationSchemas.js";

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,8 +13,11 @@ export const tiersToLineAmount = ({
overage: number; overage: number;
billingUnits?: number; billingUnits?: number;
}): number => { }): number => {
const isNegative = overage < 0;
const absoluteOverage = Math.abs(overage);
const roundedOverage = roundUsageToNearestBillingUnit({ const roundedOverage = roundUsageToNearestBillingUnit({
usage: overage, usage: absoluteOverage,
billingUnits, 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. * Determines if a quantity upgrade or downgrade should be prorated.
* @param onIncrease - The proration behavior configuration for the quantity increase. * @param prorationConfig - The proration behavior configuration.
* @returns True if a quantity upgrade or downgrade should be prorated, false otherwise. * @returns True if proration should be applied, false otherwise.
*/ */
export const shouldProrate = (onIncrease?: OnIncrease | OnDecrease) => { export const shouldProrate = (prorationConfig?: OnIncrease | OnDecrease) => {
if (!onIncrease) { if (!prorationConfig) {
return true; return true;
} }
return ( return (
onIncrease === OnIncrease.ProrateNextCycle || prorationConfig === OnIncrease.ProrateNextCycle ||
onIncrease === OnIncrease.ProrateImmediately || prorationConfig === OnIncrease.ProrateImmediately ||
onIncrease === OnDecrease.ProrateImmediately || prorationConfig === OnDecrease.ProrateImmediately ||
onIncrease === OnDecrease.ProrateNextCycle 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 { Entity } from "../../models/cusModels/entityModels/entityModels.js";
import type { FullCustomerEntitlement } from "../../models/cusProductModels/cusEntModels/cusEntModels.js"; import type { FullCustomerEntitlement } from "../../models/cusProductModels/cusEntModels/cusEntModels.js";
import type { FullCusProduct } from "../../models/cusProductModels/cusProductModels.js"; import type { FullCusProduct } from "../../models/cusProductModels/cusProductModels.js";
@@ -96,16 +99,6 @@ export const filterOutEntitiesFromCusProducts = ({
return finalCusProducts; return finalCusProducts;
}; };
export const getActiveCusProducts = ({
customer,
}: {
customer: FullCustomer;
}): FullCusProduct[] => {
return customer.customer_products.filter(
(p: FullCusProduct) => p.status === CusProductStatus.Active,
);
};
export const isProductAlreadyEnabled = ({ export const isProductAlreadyEnabled = ({
productId, productId,
customer, customer,
@@ -115,7 +108,9 @@ export const isProductAlreadyEnabled = ({
customer: FullCustomer; customer: FullCustomer;
entityId?: string; 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 // Check if product matches and is not an add-on
if (cp.product_id !== productId || cp.product.is_add_on) { if (cp.product_id !== productId || cp.product.is_add_on) {
return false; return false;
@@ -142,17 +137,3 @@ export const isProductAlreadyEnabled = ({
return false; 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/cusProductUtils.js";
export * from "./cusProductUtils/featureOptionUtils/findFeatureOptions.js"; export * from "./cusProductUtils/featureOptionUtils/findFeatureOptions.js";
export * from "./cusProductUtils/filterCusProductUtils.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/getCusProductFromCustomer.js";
export * from "./cusProductUtils/productIdToCusProduct.js"; export * from "./cusProductUtils/productIdToCusProduct.js";
// Cus utils // Cus utils
@@ -67,10 +69,7 @@ export * from "./productUtils/entUtils/formatEntUtils.js";
export * from "./productUtils/freeTrialUtils/initFreeTrial.js"; export * from "./productUtils/freeTrialUtils/initFreeTrial.js";
export * from "./productUtils/freeTrialUtils.js"; export * from "./productUtils/freeTrialUtils.js";
export * from "./productUtils/isProductUpgrade.js"; export * from "./productUtils/isProductUpgrade.js";
export * from "./productUtils/priceUtils/classifyPriceUtils.js"; export * from "./productUtils/priceUtils/index.js";
export * from "./productUtils/priceUtils/convertAmountUtils.js";
export * from "./productUtils/priceUtils/convertPriceUtils.js";
export * from "./productUtils/priceUtils/formatPriceUtils.js";
export * from "./productUtils/priceUtils.js"; export * from "./productUtils/priceUtils.js";
export * from "./productV2Utils/mapToProductV2.js"; export * from "./productV2Utils/mapToProductV2.js";
export * from "./productV2Utils/productItemUtils/classifyItemUtils.js"; export * from "./productV2Utils/productItemUtils/classifyItemUtils.js";

View File

@@ -6,7 +6,11 @@ import {
OnDecrease, OnDecrease,
OnIncrease, OnIncrease,
} from "@models/productV2Models/productItemModels/productItemEnums"; } from "@models/productV2Models/productItemModels/productItemEnums";
import { shouldBillNow, shouldProrate } from "@utils/billingUtils"; import {
shouldBillNow,
shouldProrate,
shouldSkipLineItems,
} from "@utils/billingUtils";
import { priceToEnt } from "@utils/productUtils/convertProductUtils"; import { priceToEnt } from "@utils/productUtils/convertProductUtils";
export const priceToFeature = ({ export const priceToFeature = ({
@@ -43,20 +47,17 @@ export const priceToProrationConfig = ({
}): { }): {
prorationBehaviorConfig: OnIncrease | OnDecrease; prorationBehaviorConfig: OnIncrease | OnDecrease;
shouldApplyProration: boolean; shouldApplyProration: boolean;
shouldFinalizeInvoiceImmediately: boolean; chargeImmediately: boolean;
skipLineItems: boolean;
} => { } => {
const prorationBehaviorConfig = isUpgrade const prorationBehaviorConfig = isUpgrade
? (price.proration_config?.on_increase ?? OnIncrease.ProrateImmediately) ? (price.proration_config?.on_increase ?? OnIncrease.ProrateImmediately)
: (price.proration_config?.on_decrease ?? OnDecrease.ProrateImmediately); : (price.proration_config?.on_decrease ?? OnDecrease.ProrateImmediately);
const shouldApplyProration = shouldProrate(prorationBehaviorConfig);
const shouldFinalizeInvoiceImmediately = shouldBillNow(
prorationBehaviorConfig,
);
return { return {
prorationBehaviorConfig, prorationBehaviorConfig,
shouldApplyProration, shouldApplyProration: shouldProrate(prorationBehaviorConfig),
shouldFinalizeInvoiceImmediately, 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, getProductItemDisplay,
type ProductItem, type ProductItem,
type ProductV2, type ProductV2,
type SubscriptionUpdateV0Params,
stripeToAtmnAmount, stripeToAtmnAmount,
type UpdateSubscriptionV0Params,
} from "@autumn/shared"; } from "@autumn/shared";
import { Check, Copy, PencilSimple } from "@phosphor-icons/react"; 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 { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { toast } from "sonner";
import { DateInputUnix } from "@/components/general/DateInputUnix"; import { DateInputUnix } from "@/components/general/DateInputUnix";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Button } from "@/components/v2/buttons/Button"; import { Button } from "@/components/v2/buttons/Button";
import { IconButton } from "@/components/v2/buttons/IconButton"; import { IconButton } from "@/components/v2/buttons/IconButton";
import { SheetHeader } from "@/components/v2/sheets/InlineSheet"; import { SheetHeader } from "@/components/v2/sheets/InlineSheet";
import { useOrgStripeQuery } from "@/hooks/queries/useOrgStripeQuery";
import { usePrepaidItems } from "@/hooks/stores/useProductStore"; import { usePrepaidItems } from "@/hooks/stores/useProductStore";
import { useSheetStore } from "@/hooks/stores/useSheetStore"; import { useSheetStore } from "@/hooks/stores/useSheetStore";
import { useSubscriptionById } from "@/hooks/stores/useSubscriptionStore"; import { useSubscriptionById } from "@/hooks/stores/useSubscriptionStore";
import { useAxiosInstance } from "@/services/useAxiosInstance"; import { useAxiosInstance } from "@/services/useAxiosInstance";
import { useEnv } from "@/utils/envUtils";
import { formatUnixToDateTime } from "@/utils/formatUtils/formatDateUtils"; import { formatUnixToDateTime } from "@/utils/formatUtils/formatDateUtils";
import { pushPage } from "@/utils/genUtils"; import { pushPage } from "@/utils/genUtils";
import { getStripeInvoiceLink } from "@/utils/linkUtils";
import { useCusQuery } from "@/views/customers/customer/hooks/useCusQuery"; import { useCusQuery } from "@/views/customers/customer/hooks/useCusQuery";
/** /**
@@ -41,6 +50,7 @@ interface PrepaidEditorProps {
prepaidItems: Array<{ prepaidItems: Array<{
feature_id?: string | null; feature_id?: string | null;
feature?: { internal_id: string } | undefined; feature?: { internal_id: string } | undefined;
billing_units?: number | null;
}>; }>;
prepaidOptions: Record<string, number>; prepaidOptions: Record<string, number>;
onPrepaidChange: (featureId: string, quantity: number) => void; onPrepaidChange: (featureId: string, quantity: number) => void;
@@ -61,15 +71,18 @@ function PrepaidEditor({
<div className="px-4 py-3 space-y-2"> <div className="px-4 py-3 space-y-2">
{prepaidItems.map((item) => { {prepaidItems.map((item) => {
const featureId = item.feature_id ?? item.feature?.internal_id ?? ""; const featureId = item.feature_id ?? item.feature?.internal_id ?? "";
const billingUnits = item.billing_units ?? 1;
const inputId = `prepaid-${featureId}`; const inputId = `prepaid-${featureId}`;
return ( return (
<div key={featureId} className="flex items-center gap-3"> <div key={featureId} className="flex items-center gap-3">
<label <div className="flex-1">
htmlFor={inputId} <label htmlFor={inputId} className="text-sm text-t-secondary">
className="text-sm text-t-secondary flex-1"
>
{featureId} {featureId}
</label> </label>
<span className="text-xs text-t-secondary ml-2">
(billing_units: {billingUnits})
</span>
</div>
<input <input
id={inputId} id={inputId}
type="number" 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"> <h4 className="text-xs font-semibold text-purple-400 mb-1">
🧾 Stripe Invoice Action 🧾 Stripe Invoice Action
</h4> </h4>
{/* Immediate Line Items (addLineParams) */}
{billingPlan.stripe.invoiceAction.addLineParams?.lines && {billingPlan.stripe.invoiceAction.addLineParams?.lines &&
billingPlan.stripe.invoiceAction.addLineParams.lines.length > billingPlan.stripe.invoiceAction.addLineParams.lines.length >
0 ? ( 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( {billingPlan.stripe.invoiceAction.addLineParams.lines.map(
( (
line: { line: {
@@ -664,9 +691,13 @@ function PreviewResult({ data, isLoading, error }: PreviewResultProps) {
}, },
)} )}
</div> </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"> <details className="mt-1">
<summary className="text-xs cursor-pointer text-t-secondary hover:text-t-primary"> <summary className="text-xs cursor-pointer text-t-secondary hover:text-t-primary">
View raw params View raw params
@@ -678,10 +709,71 @@ function PreviewResult({ data, isLoading, error }: PreviewResultProps) {
</div> </div>
) : null} ) : 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 */} {/* Empty Stripe section indicator */}
{billingPlan.stripe && {billingPlan.stripe &&
!billingPlan.stripe.subscriptionAction && !billingPlan.stripe.subscriptionAction &&
!billingPlan.stripe.invoiceAction ? ( !billingPlan.stripe.invoiceAction &&
!billingPlan.stripe.invoiceItemsAction ? (
<div className="px-4 py-2 text-xs text-t-secondary"> <div className="px-4 py-2 text-xs text-t-secondary">
No Stripe actions required No Stripe actions required
</div> </div>
@@ -740,7 +832,7 @@ function useSubscriptionUpdatePreview({
body, body,
enabled, enabled,
}: { }: {
body: SubscriptionUpdateV0Params | null; body: UpdateSubscriptionV0Params | null;
enabled: boolean; enabled: boolean;
}) { }) {
const axiosInstance = useAxiosInstance(); 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 axiosInstance = useAxiosInstance();
const queryClient = useQueryClient();
const { closeSheet } = useSheetStore();
return useMutation({ 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( const response = await axiosInstance.post(
"/v1/subscriptions/update", "/v1/subscriptions/update",
body, requestBody,
); );
return response.data; 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 customerId = customer?.id ?? customer?.internal_id;
const entityId = cusProduct?.entity_id ?? undefined; const entityId = cusProduct?.entity_id ?? undefined;
// Stripe + invoice handling
const { stripeAccount } = useOrgStripeQuery();
const env = useEnv();
const product = customizedProduct?.id ? customizedProduct : productV2; const product = customizedProduct?.id ? customizedProduct : productV2;
const { prepaidItems } = usePrepaidItems({ product }); const { prepaidItems } = usePrepaidItems({ product });
@@ -874,23 +1017,30 @@ function SheetContent({
}; };
// Build the request body // Build the request body
const requestBody = useMemo<SubscriptionUpdateV0Params | null>(() => { const requestBody = useMemo<UpdateSubscriptionV0Params | null>(() => {
if (!customerId) return null; if (!customerId) return null;
const body: SubscriptionUpdateV0Params = { const body: UpdateSubscriptionV0Params = {
customer_id: customerId, customer_id: customerId,
product_id: product?.id, product_id: product?.id,
entity_id: entityId, entity_id: entityId,
customer_product_id: cusProduct.id ?? cusProduct.internal_product_id, 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) { if (prepaidItems.length > 0) {
const options = prepaidItems const options = prepaidItems
.map((item) => { .map((item) => {
const featureId = item.feature_id ?? item.feature?.internal_id ?? ""; const featureId = item.feature_id ?? item.feature?.internal_id ?? "";
const quantity = prepaidOptions[featureId]; 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 { feature_id: featureId, quantity };
} }
return null; return null;
@@ -935,6 +1085,7 @@ function SheetContent({
cusProduct.internal_product_id, cusProduct.internal_product_id,
prepaidItems, prepaidItems,
prepaidOptions, prepaidOptions,
initialPrepaidOptions,
customizedProduct?.items, customizedProduct?.items,
customizedProduct?.free_trial, customizedProduct?.free_trial,
planCustomStartDate, planCustomStartDate,
@@ -948,12 +1099,35 @@ function SheetContent({
enabled: !!requestBody, enabled: !!requestBody,
}); });
// Update mutation // Update mutation with invoice handling
const updateMutation = useSubscriptionUpdate(); const updateMutation = useSubscriptionUpdate({
customerId,
onInvoiceCreated: (stripeInvoice) => {
const invoiceLink = getStripeInvoiceLink({
stripeInvoice,
env,
accountId: stripeAccount?.id,
});
window.open(invoiceLink, "_blank");
},
});
const handleConfirm = () => { const handleConfirm = () => {
if (!requestBody) return; 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 ( return (
@@ -1181,16 +1355,64 @@ function SheetContent({
</div> </div>
{/* Footer Actions */} {/* 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 <Button
variant="primary" variant="primary"
className="w-full"
onClick={handleConfirm} onClick={handleConfirm}
disabled={!requestBody || updateMutation.isPending} disabled={!requestBody || updateMutation.isPending}
> >
{updateMutation.isPending ? "Updating..." : "Confirm Update"} {updateMutation.isPending ? "Updating..." : "Confirm Update"}
</Button> </Button>
{/* Refresh Preview Button */}
<Button <Button
variant="secondary" variant="secondary"
className="w-full"
onClick={() => previewQuery.refetch()} onClick={() => previewQuery.refetch()}
disabled={!requestBody || previewQuery.isLoading} disabled={!requestBody || previewQuery.isLoading}
> >

View File

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