skeleton complete
This commit is contained in:
@@ -3,20 +3,24 @@
|
||||
source "$(dirname "$0")/config.sh"
|
||||
|
||||
BUN_PARALLEL_COMPACT \
|
||||
'server/tests/attach/basic' \
|
||||
'server/tests/attach/upgrade' \
|
||||
'server/tests/attach/downgrade' \
|
||||
'server/tests/attach/free' \
|
||||
'server/tests/attach/addOn' \
|
||||
'server/tests/attach/checkout' \
|
||||
'server/tests/attach/misc' \
|
||||
'server/tests/billing/invoice-action-required' \
|
||||
'server/tests/billing/cancel' \
|
||||
'server/tests/billing/cancel/add-ons' \
|
||||
'server/tests/renew' \
|
||||
'server/tests/billing/subscription-update' \
|
||||
--max=6
|
||||
|
||||
BUN_PARALLEL_COMPACT \
|
||||
'server/tests/attach/entities' \
|
||||
--max=6
|
||||
# 'server/tests/external-psps/revenuecat' \
|
||||
# BUN_PARALLEL_COMPACT \
|
||||
# 'server/tests/attach/basic' \
|
||||
# 'server/tests/attach/upgrade' \
|
||||
# 'server/tests/attach/downgrade' \
|
||||
# 'server/tests/attach/free' \
|
||||
# 'server/tests/attach/addOn' \
|
||||
# 'server/tests/attach/checkout' \
|
||||
# 'server/tests/attach/misc' \
|
||||
# 'server/tests/billing/invoice-action-required' \
|
||||
# 'server/tests/billing/cancel' \
|
||||
# 'server/tests/billing/cancel/add-ons' \
|
||||
# 'server/tests/renew' \
|
||||
# --max=6
|
||||
|
||||
# BUN_PARALLEL_COMPACT \
|
||||
# 'server/tests/attach/entities' \
|
||||
# --max=6
|
||||
# # 'server/tests/external-psps/revenuecat' \
|
||||
|
||||
4
server/src/external/autumn/autumnCli.ts
vendored
4
server/src/external/autumn/autumnCli.ts
vendored
@@ -17,8 +17,8 @@ import {
|
||||
type LegacyVersion,
|
||||
type OrgConfig,
|
||||
type RewardRedemption,
|
||||
type SubscriptionUpdateV0Params,
|
||||
type TrackParams,
|
||||
type UpdateSubscriptionV0Params,
|
||||
} from "@autumn/shared";
|
||||
import { defaultApiVersion } from "@tests/constants.js";
|
||||
import type {
|
||||
@@ -683,7 +683,7 @@ export class AutumnInt {
|
||||
},
|
||||
};
|
||||
|
||||
subscriptionUpdate = async (params: SubscriptionUpdateV0Params) => {
|
||||
subscriptionUpdate = async (params: UpdateSubscriptionV0Params) => {
|
||||
const data = await this.post(`/subscriptions/update`, params);
|
||||
return data;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Metadata } from "@autumn/shared";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import { executeAutumnBillingPlan } from "@/internal/billing/v2/execute/executeAutumnBillingPlan";
|
||||
import { executeStripeBillingPlan } from "@/internal/billing/v2/providers/stripe/execute/executeStripeBillingPlan";
|
||||
import type { DeferredAutumnBillingPlanData } from "@/internal/billing/v2/types/billingPlan";
|
||||
import { MetadataService } from "@/internal/metadata/MetadataService";
|
||||
|
||||
@@ -19,9 +20,20 @@ export const handleDeferredAutumnBillingPlan = async ({
|
||||
return;
|
||||
}
|
||||
|
||||
const { billingPlan, billingContext } = data;
|
||||
|
||||
// Execute stripe billing plan
|
||||
await executeStripeBillingPlan({
|
||||
ctx,
|
||||
stripeBillingPlan: billingPlan.stripe,
|
||||
autumnBillingPlan: billingPlan.autumn,
|
||||
billingContext,
|
||||
resumeFromDeferred: true,
|
||||
});
|
||||
|
||||
await executeAutumnBillingPlan({
|
||||
ctx,
|
||||
autumnBillingPlan: data.autumnBillingPlan,
|
||||
autumnBillingPlan: billingPlan.autumn,
|
||||
});
|
||||
|
||||
await MetadataService.delete({ db, id: metadata.id });
|
||||
|
||||
@@ -26,7 +26,10 @@ export const handleInvoicePaidMetadata = async ({
|
||||
if (!metadata) return;
|
||||
|
||||
// Handle deferred billing plan (v2 flow)
|
||||
if (metadata.type === MetadataType.DeferredAutumnBillingPlan) {
|
||||
if (
|
||||
metadata.type === MetadataType.InvoiceCheckoutV2 ||
|
||||
metadata.type === MetadataType.InvoiceActionRequiredV2
|
||||
) {
|
||||
await handleDeferredAutumnBillingPlan({ ctx, metadata });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -51,6 +51,10 @@ const coreUrls = [
|
||||
method: "POST",
|
||||
url: "/cancel",
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
url: "/subscriptions/update",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Hono } from "hono";
|
||||
import { handleSubscriptionUpdatePreview } from "@/internal/billing/v2/subscriptionUpdate/handleSubscriptionUpdatePreview.js";
|
||||
import { handleUpdateSubscriptionPreview } from "@/internal/billing/v2/subscriptionUpdate/handleUpdateSubscriptionPreview.js";
|
||||
import type { HonoEnv } from "../../honoUtils/HonoEnv.js";
|
||||
import { handleAttach } from "./attach/handleAttach.js";
|
||||
import { handleCheckoutV2 } from "./checkout/handleCheckoutV2.js";
|
||||
import { handleSetupPayment } from "./handlers/handleSetupPayment.js";
|
||||
import { handleApiSubscriptionUpdate } from "./v2/handlers/handleApiSubscriptionUpdate.js";
|
||||
import { handleAttachV2 } from "./v2/handlers/handleAttachV2.js";
|
||||
import { handleUpdateSubscription } from "./v2/handlers/handleUpdateSubscription.js";
|
||||
|
||||
export const billingRouter = new Hono<HonoEnv>();
|
||||
|
||||
@@ -14,8 +14,8 @@ billingRouter.post("/checkout", ...handleCheckoutV2);
|
||||
billingRouter.post("/attach", ...handleAttach);
|
||||
billingRouter.post("/attach_v2", ...handleAttachV2);
|
||||
|
||||
billingRouter.post("/subscriptions/update", ...handleApiSubscriptionUpdate);
|
||||
billingRouter.post("/subscriptions/update", ...handleUpdateSubscription);
|
||||
billingRouter.post(
|
||||
"/subscriptions/preview/update",
|
||||
...handleSubscriptionUpdatePreview,
|
||||
...handleUpdateSubscriptionPreview,
|
||||
);
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import type { FullProduct } from "@autumn/shared";
|
||||
import type { FullCusProduct, FullProduct } from "@autumn/shared";
|
||||
import type { FullCustomer } from "@shared/models/cusModels/fullCusModel";
|
||||
import type Stripe from "stripe";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const InvoiceModeSchema = z.object({
|
||||
finalizeInvoice: z.boolean().default(false),
|
||||
enableProductImmediately: z.boolean().default(true),
|
||||
});
|
||||
|
||||
export type InvoiceMode = z.infer<typeof InvoiceModeSchema>;
|
||||
|
||||
export interface BillingContext {
|
||||
fullCustomer: FullCustomer;
|
||||
@@ -10,5 +18,17 @@ export interface BillingContext {
|
||||
stripeSubscription?: Stripe.Subscription;
|
||||
stripeSubscriptionSchedule?: Stripe.SubscriptionSchedule;
|
||||
paymentMethod?: Stripe.PaymentMethod;
|
||||
testClockFrozenTime?: number;
|
||||
|
||||
// Timestamps...
|
||||
currentEpochMs: number;
|
||||
billingCycleAnchorMs?: number;
|
||||
|
||||
// Invoice mode
|
||||
invoiceMode?: InvoiceMode;
|
||||
}
|
||||
|
||||
export interface UpdateSubscriptionBillingContext extends BillingContext {
|
||||
customerProduct: FullCusProduct; // target customer product
|
||||
}
|
||||
|
||||
// testClockFrozenTime?: number;
|
||||
|
||||
@@ -3,30 +3,29 @@ import {
|
||||
cusProductToLineItems,
|
||||
type FullCusProduct,
|
||||
} from "@autumn/shared";
|
||||
|
||||
import type { BillingContext } from "@/internal/billing/v2/billingContext";
|
||||
import type { AutumnContext } from "../../../../../honoUtils/HonoEnv";
|
||||
|
||||
export const buildAutumnLineItems = ({
|
||||
ctx,
|
||||
newCusProducts,
|
||||
ongoingCustomerProduct,
|
||||
billingCycleAnchor,
|
||||
testClockFrozenTime,
|
||||
newCustomerProducts,
|
||||
deletedCustomerProduct,
|
||||
billingContext,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
newCusProducts: FullCusProduct[];
|
||||
ongoingCustomerProduct?: FullCusProduct;
|
||||
billingCycleAnchor?: number;
|
||||
testClockFrozenTime?: number;
|
||||
newCustomerProducts: FullCusProduct[];
|
||||
deletedCustomerProduct?: FullCusProduct;
|
||||
billingContext: BillingContext;
|
||||
}) => {
|
||||
const now = testClockFrozenTime ?? Date.now();
|
||||
billingCycleAnchor = billingCycleAnchor ?? now;
|
||||
// billingCycleAnchor = billingCycleAnchor ?? now;
|
||||
const billingCycleAnchor = billingContext.billingCycleAnchorMs;
|
||||
const now = billingContext.currentEpochMs;
|
||||
|
||||
const { org } = ctx;
|
||||
|
||||
const arrearLineItems = ongoingCustomerProduct
|
||||
const arrearLineItems = deletedCustomerProduct
|
||||
? cusProductToArrearLineItems({
|
||||
cusProduct: ongoingCustomerProduct,
|
||||
cusProduct: deletedCustomerProduct,
|
||||
billingCycleAnchor: billingCycleAnchor!,
|
||||
now,
|
||||
org,
|
||||
@@ -34,9 +33,9 @@ export const buildAutumnLineItems = ({
|
||||
: [];
|
||||
|
||||
// Get line items for ongoing cus product
|
||||
const ongoingLineItems = ongoingCustomerProduct
|
||||
const deletedLineItems = deletedCustomerProduct
|
||||
? cusProductToLineItems({
|
||||
cusProduct: ongoingCustomerProduct,
|
||||
cusProduct: deletedCustomerProduct,
|
||||
now,
|
||||
billingCycleAnchor: billingCycleAnchor!,
|
||||
direction: "refund",
|
||||
@@ -44,9 +43,9 @@ export const buildAutumnLineItems = ({
|
||||
})
|
||||
: [];
|
||||
|
||||
const newLineItems = newCusProducts.flatMap((newCusProduct) =>
|
||||
const newLineItems = newCustomerProducts.flatMap((newCustomerProduct) =>
|
||||
cusProductToLineItems({
|
||||
cusProduct: newCusProduct,
|
||||
cusProduct: newCustomerProduct,
|
||||
now,
|
||||
billingCycleAnchor: billingCycleAnchor!,
|
||||
direction: "charge",
|
||||
@@ -56,7 +55,7 @@ export const buildAutumnLineItems = ({
|
||||
|
||||
// All items
|
||||
const allLineItems = [
|
||||
...ongoingLineItems,
|
||||
...deletedLineItems,
|
||||
...arrearLineItems,
|
||||
...newLineItems,
|
||||
];
|
||||
|
||||
@@ -2,7 +2,7 @@ import type {
|
||||
Feature,
|
||||
FeatureOptions,
|
||||
Price,
|
||||
SubscriptionUpdateV0Params,
|
||||
UpdateSubscriptionV0Params,
|
||||
} from "@autumn/shared";
|
||||
import { roundUsageToNearestBillingUnit } from "@autumn/shared";
|
||||
import { Decimal } from "decimal.js";
|
||||
@@ -12,7 +12,7 @@ export const paramsToFeatureOptions = ({
|
||||
price,
|
||||
feature,
|
||||
}: {
|
||||
params: SubscriptionUpdateV0Params;
|
||||
params: UpdateSubscriptionV0Params;
|
||||
price: Price;
|
||||
feature: Feature;
|
||||
}): FeatureOptions | undefined => {
|
||||
|
||||
@@ -50,7 +50,10 @@ export const executeAutumnBillingPlan = async ({
|
||||
await CusProductService.update({
|
||||
db,
|
||||
cusProductId: updateCustomerProduct.id,
|
||||
updates: { options: updateCustomerProduct.options },
|
||||
updates: {
|
||||
options: updateCustomerProduct.options,
|
||||
status: updateCustomerProduct.status,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -16,24 +16,18 @@ export const executeBillingPlan = async ({
|
||||
}) => {
|
||||
logBillingPlan({ ctx, billingPlan });
|
||||
|
||||
const enableProductImmediately =
|
||||
billingPlan.stripe.invoiceAction?.invoiceMode?.enableProductImmediately !==
|
||||
false;
|
||||
|
||||
await executeStripeBillingPlan({
|
||||
const result = await executeStripeBillingPlan({
|
||||
ctx,
|
||||
stripeBillingPlan: billingPlan.stripe,
|
||||
autumnBillingPlan: billingPlan.autumn,
|
||||
billingPlan,
|
||||
billingContext,
|
||||
});
|
||||
|
||||
// if not enabling product immediately, it will be handled in webhook
|
||||
if (enableProductImmediately) {
|
||||
if (result.deferred) return result;
|
||||
|
||||
await executeAutumnBillingPlan({
|
||||
ctx,
|
||||
autumnBillingPlan: billingPlan.autumn,
|
||||
});
|
||||
}
|
||||
|
||||
return { billingPlan };
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import { InvoiceService } from "@/internal/invoices/InvoiceService";
|
||||
import { getInvoiceItems } from "@/internal/invoices/invoiceUtils";
|
||||
import { createAndFinalizeInvoice } from "@/internal/invoices/invoiceUtils/createAndFinalizeInvoice";
|
||||
import type { UpdateSubscriptionContext } from "../subscriptionUpdate/fetch/updateSubscriptionContextSchema";
|
||||
import type { UpdateSubscriptionBillingContext } from "../billingContext";
|
||||
import type { SubscriptionUpdateInvoiceAction } from "../typesOld";
|
||||
|
||||
/**
|
||||
@@ -22,7 +22,7 @@ export const executeInvoiceAction = async ({
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
invoiceAction: SubscriptionUpdateInvoiceAction;
|
||||
updateSubscriptionContext: UpdateSubscriptionContext;
|
||||
updateSubscriptionContext: UpdateSubscriptionBillingContext;
|
||||
}) => {
|
||||
const { stripeCustomer, stripeSubscription, customerProduct } =
|
||||
updateSubscriptionContext;
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -1,29 +1,34 @@
|
||||
import type { LineItem } from "@autumn/shared";
|
||||
import type {
|
||||
InvoiceMode,
|
||||
StripeInvoiceAction,
|
||||
} from "../../../types/billingPlan";
|
||||
import { lineItemsToStripeLines } from "../utils/invoiceLines/lineItemsToStripeLines";
|
||||
import type { StripeInvoiceAction } from "../../../types/billingPlan";
|
||||
import { lineItemsToInvoiceAddLinesParams } from "../utils/invoiceLines/lineItemsToInvoiceAddLinesParams";
|
||||
|
||||
/**
|
||||
* Builds a Stripe invoice action from Autumn line items.
|
||||
* Returns undefined if no line items are provided.
|
||||
* Builds a StripeInvoiceAction for immediate charges.
|
||||
* Filters for line items where chargeImmediately === true.
|
||||
* Returns undefined if no immediate line items are provided.
|
||||
*/
|
||||
export const buildStripeInvoiceAction = ({
|
||||
autumnLineItems,
|
||||
invoiceMode,
|
||||
lineItems,
|
||||
}: {
|
||||
autumnLineItems: LineItem[];
|
||||
invoiceMode?: InvoiceMode;
|
||||
lineItems: LineItem[];
|
||||
}): StripeInvoiceAction | undefined => {
|
||||
if (autumnLineItems.length === 0) {
|
||||
const immediateLineItems = lineItems.filter(
|
||||
(line) => line.chargeImmediately === true,
|
||||
);
|
||||
|
||||
if (immediateLineItems.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const lines = lineItemsToStripeLines({ lineItems: autumnLineItems });
|
||||
const lines = lineItemsToInvoiceAddLinesParams({
|
||||
lineItems: immediateLineItems,
|
||||
});
|
||||
|
||||
if (lines.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
addLineParams: { lines },
|
||||
invoiceMode,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { FullCusProduct } from "@autumn/shared";
|
||||
import type { AutumnContext } from "@server/honoUtils/HonoEnv";
|
||||
import type { BillingContext } from "@server/internal/billing/v2/billingContext";
|
||||
import { buildStripeSubscriptionItemsUpdate } from "@server/internal/billing/v2/providers/stripe/utils/subscriptionItems/buildStripeSubscriptionItemsUpdate";
|
||||
import { buildStripeSubscriptionCreateAction } from "@server/internal/billing/v2/providers/stripe/utils/subscriptions/buildStripeSubscriptionCreateAction";
|
||||
import { buildStripeSubscriptionUpdateAction } from "@server/internal/billing/v2/providers/stripe/utils/subscriptions/buildStripeSubscriptionUpdateAction";
|
||||
import type { UpdateSubscriptionContext } from "@server/internal/billing/v2/subscriptionUpdate/fetch/updateSubscriptionContextSchema";
|
||||
import type {
|
||||
FreeTrialPlan,
|
||||
StripeSubscriptionAction,
|
||||
@@ -13,13 +13,13 @@ import type {
|
||||
export const buildStripeSubscriptionAction = ({
|
||||
ctx,
|
||||
billingContext,
|
||||
updatedCustomerProducts,
|
||||
finalCustomerProducts,
|
||||
stripeSubscriptionScheduleAction,
|
||||
freeTrialPlan,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
billingContext: UpdateSubscriptionContext;
|
||||
updatedCustomerProducts: FullCusProduct[];
|
||||
billingContext: BillingContext;
|
||||
finalCustomerProducts: FullCusProduct[];
|
||||
stripeSubscriptionScheduleAction?: StripeSubscriptionScheduleAction;
|
||||
freeTrialPlan?: FreeTrialPlan;
|
||||
}): StripeSubscriptionAction | undefined => {
|
||||
@@ -28,11 +28,9 @@ export const buildStripeSubscriptionAction = ({
|
||||
const subItemsUpdate = buildStripeSubscriptionItemsUpdate({
|
||||
ctx,
|
||||
billingContext,
|
||||
updatedCustomerProducts,
|
||||
finalCustomerProducts,
|
||||
});
|
||||
|
||||
// 1. Compute the action type
|
||||
|
||||
// Case 1: No subscription and sub items update is empty -> no action
|
||||
if (!stripeSubscription && subItemsUpdate.length === 0) {
|
||||
return undefined;
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import type { FullCusProduct } from "@autumn/shared";
|
||||
import { msToSeconds } from "@autumn/shared";
|
||||
import {
|
||||
isCustomerProductOnStripeSubscription,
|
||||
isCustomerProductOnStripeSubscriptionSchedule,
|
||||
msToSeconds,
|
||||
RELEVANT_STATUSES,
|
||||
} from "@autumn/shared";
|
||||
import type { AutumnContext } from "@server/honoUtils/HonoEnv";
|
||||
import type { BillingContext } from "@server/internal/billing/v2/billingContext";
|
||||
import { getFinalCustomerProductsState } from "@server/internal/billing/v2/utils/getFinalCustomerProductsState";
|
||||
import { buildSchedulePhases } from "@server/internal/billing/v2/utils/stripeAdapter/subscriptionSchedules/buildSchedulePhases";
|
||||
import { buildSchedulePhases } from "@server/internal/billing/v2/providers/stripe/utils/subscriptionSchedules/buildSchedulePhases";
|
||||
import type Stripe from "stripe";
|
||||
import type { StripeSubscriptionScheduleAction } from "@/internal/billing/v2/types/billingPlan";
|
||||
|
||||
@@ -46,31 +50,42 @@ const filterEmptyPhases = (
|
||||
export const buildStripeSubscriptionScheduleAction = ({
|
||||
ctx,
|
||||
billingContext,
|
||||
addCustomerProducts = [],
|
||||
removeCustomerProducts = [],
|
||||
finalCustomerProducts,
|
||||
trialEndsAt,
|
||||
nowMs,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
billingContext: BillingContext;
|
||||
addCustomerProducts?: FullCusProduct[];
|
||||
removeCustomerProducts?: FullCusProduct[];
|
||||
finalCustomerProducts: FullCusProduct[];
|
||||
trialEndsAt?: number;
|
||||
nowMs: number;
|
||||
}): StripeSubscriptionScheduleAction | undefined => {
|
||||
const { stripeSubscriptionSchedule } = billingContext;
|
||||
const { stripeSubscriptionSchedule, stripeSubscription } = billingContext;
|
||||
|
||||
// 1. Get final customer product state
|
||||
const customerProducts = getFinalCustomerProductsState({
|
||||
billingContext,
|
||||
addCustomerProducts,
|
||||
removeCustomerProducts,
|
||||
});
|
||||
// 1. Filter customer products by stripe subscription id or stripe subscription schedule ID?
|
||||
|
||||
let customerProducts = stripeSubscription
|
||||
? finalCustomerProducts.filter(
|
||||
(cp) =>
|
||||
isCustomerProductOnStripeSubscription({
|
||||
customerProduct: cp,
|
||||
stripeSubscriptionId: stripeSubscription.id,
|
||||
}) ||
|
||||
isCustomerProductOnStripeSubscriptionSchedule({
|
||||
customerProduct: cp,
|
||||
stripeSubscriptionScheduleId: stripeSubscriptionSchedule?.id ?? "",
|
||||
}),
|
||||
)
|
||||
: [];
|
||||
|
||||
customerProducts = customerProducts.filter((cp) =>
|
||||
RELEVANT_STATUSES.includes(cp.status),
|
||||
);
|
||||
|
||||
const phases = buildSchedulePhases({
|
||||
ctx,
|
||||
billingContext,
|
||||
customerProducts,
|
||||
customerProducts: finalCustomerProducts,
|
||||
trialEndsAt,
|
||||
nowMs,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -1,124 +1,66 @@
|
||||
import { MetadataType } from "@autumn/shared";
|
||||
import type Stripe from "stripe";
|
||||
import { isStripeSubscriptionCanceled } from "@/external/stripe/subscriptions/utils/classifyStripeSubscriptionUtils";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import type { BillingContext } from "@/internal/billing/v2/billingContext";
|
||||
import { addStripeSubscriptionIdToBillingPlan } from "@/internal/billing/v2/execute/addStripeSubscriptionIdToBillingPlan";
|
||||
import { addStripeSubscriptionScheduleIdToBillingPlan } from "@/internal/billing/v2/execute/addStripeSubscriptionScheduleIdToBillingPlan";
|
||||
import { removeStripeSubscriptionIdFromBillingPlan } from "@/internal/billing/v2/execute/removeStripeSubscriptionIdFromBillingPlan";
|
||||
import { executeStripeInvoiceAction } from "@/internal/billing/v2/providers/stripe/execute/executeStripeInvoiceAction";
|
||||
import { executeStripeSubscriptionAction } from "@/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionAction";
|
||||
import { executeStripeSubscriptionScheduleAction } from "@/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionScheduleAction";
|
||||
import { createInvoiceForBilling } from "@/internal/billing/v2/providers/stripe/utils/invoices/createInvoiceForBilling";
|
||||
import type {
|
||||
AutumnBillingPlan,
|
||||
StripeBillingPlan,
|
||||
StripeInvoiceMetadata,
|
||||
} from "@/internal/billing/v2/types/billingPlan";
|
||||
import { upsertInvoiceFromBilling } from "@/internal/billing/v2/utils/upsertFromStripe/upsertInvoiceFromBilling";
|
||||
import { upsertSubscriptionFromBilling } from "@/internal/billing/v2/utils/upsertFromStripe/upsertSubscriptionFromBilling";
|
||||
import { addSubIdToCache } from "@/internal/customers/cusCache/subCacheUtils";
|
||||
import { MetadataService } from "@/internal/metadata/MetadataService";
|
||||
import { generateId } from "@/utils/genUtils";
|
||||
import { createStripeInvoiceItems } from "@/internal/billing/v2/providers/stripe/utils/invoices/stripeInvoiceOps";
|
||||
import type { BillingPlan } from "@/internal/billing/v2/types/billingPlan";
|
||||
import type { StripeBillingPlanResult } from "@/internal/billing/v2/types/stripeBillingPlanResult";
|
||||
|
||||
export const executeStripeBillingPlan = async ({
|
||||
ctx,
|
||||
stripeBillingPlan,
|
||||
autumnBillingPlan,
|
||||
billingPlan,
|
||||
billingContext,
|
||||
resumeFromDeferred = false,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
stripeBillingPlan: StripeBillingPlan;
|
||||
autumnBillingPlan: AutumnBillingPlan;
|
||||
billingPlan: BillingPlan;
|
||||
billingContext: BillingContext;
|
||||
}) => {
|
||||
resumeFromDeferred?: boolean;
|
||||
}): Promise<StripeBillingPlanResult> => {
|
||||
const { logger } = ctx;
|
||||
const {
|
||||
subscriptionAction: stripeSubscriptionAction,
|
||||
invoiceAction: stripeInvoiceAction,
|
||||
invoiceItemsAction: stripeInvoiceItemsAction,
|
||||
subscriptionScheduleAction: stripeSubscriptionScheduleAction,
|
||||
} = stripeBillingPlan;
|
||||
} = billingPlan.stripe;
|
||||
|
||||
const enableProductImmediately =
|
||||
stripeInvoiceAction?.invoiceMode?.enableProductImmediately !== false;
|
||||
|
||||
if (stripeInvoiceAction) {
|
||||
let invoiceMetadata: StripeInvoiceMetadata | undefined;
|
||||
|
||||
if (!enableProductImmediately) {
|
||||
const metadataId = generateId("meta");
|
||||
await MetadataService.insert({
|
||||
db: ctx.db,
|
||||
data: {
|
||||
id: metadataId,
|
||||
type: MetadataType.DeferredAutumnBillingPlan,
|
||||
data: {
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
autumnBillingPlan,
|
||||
},
|
||||
},
|
||||
});
|
||||
invoiceMetadata = { autumn_metadata_id: metadataId };
|
||||
}
|
||||
|
||||
const { invoice } = await createInvoiceForBilling({
|
||||
if (stripeInvoiceAction && !resumeFromDeferred) {
|
||||
const result = await executeStripeInvoiceAction({
|
||||
ctx,
|
||||
billingPlan,
|
||||
billingContext,
|
||||
stripeInvoiceAction,
|
||||
invoiceMetadata,
|
||||
});
|
||||
|
||||
if (invoice) {
|
||||
await upsertInvoiceFromBilling({
|
||||
ctx,
|
||||
stripeInvoice: invoice,
|
||||
fullProducts: billingContext.fullProducts,
|
||||
fullCustomer: billingContext.fullCustomer,
|
||||
});
|
||||
if (result.deferred) return result;
|
||||
}
|
||||
|
||||
if (stripeInvoiceItemsAction?.createInvoiceItems) {
|
||||
logger.info(
|
||||
"[executeStripeBillingPlan] Creating invoice items for next cycle",
|
||||
);
|
||||
await createStripeInvoiceItems({
|
||||
ctx,
|
||||
invoiceItems: stripeInvoiceItemsAction.createInvoiceItems,
|
||||
});
|
||||
}
|
||||
|
||||
let stripeSubscription: Stripe.Subscription | undefined =
|
||||
billingContext.stripeSubscription;
|
||||
|
||||
if (stripeSubscriptionAction) {
|
||||
// 1. Insert stripe subscription ID into cache
|
||||
if (stripeSubscription?.id) {
|
||||
await addSubIdToCache({
|
||||
subId: stripeSubscription.id,
|
||||
scenario: "billing",
|
||||
});
|
||||
}
|
||||
|
||||
stripeSubscription = await executeStripeSubscriptionAction({
|
||||
const result = await executeStripeSubscriptionAction({
|
||||
ctx,
|
||||
subscriptionAction: stripeSubscriptionAction,
|
||||
billingPlan,
|
||||
billingContext,
|
||||
});
|
||||
|
||||
if (stripeSubscription) {
|
||||
addStripeSubscriptionIdToBillingPlan({
|
||||
autumnBillingPlan,
|
||||
stripeSubscriptionId: stripeSubscription.id,
|
||||
});
|
||||
if (result?.deferred) return result;
|
||||
|
||||
// Add subscription to DB
|
||||
await upsertSubscriptionFromBilling({
|
||||
ctx,
|
||||
stripeSubscription,
|
||||
});
|
||||
}
|
||||
|
||||
// If the stripe subscription is canceled, remove the subscription from the billing plan
|
||||
if (
|
||||
stripeSubscription &&
|
||||
isStripeSubscriptionCanceled(stripeSubscription)
|
||||
) {
|
||||
removeStripeSubscriptionIdFromBillingPlan({
|
||||
autumnBillingPlan,
|
||||
stripeSubscriptionId: stripeSubscription.id,
|
||||
});
|
||||
|
||||
stripeSubscription = undefined;
|
||||
}
|
||||
stripeSubscription = result.stripeSubscription;
|
||||
}
|
||||
|
||||
if (stripeSubscriptionScheduleAction) {
|
||||
@@ -132,9 +74,11 @@ export const executeStripeBillingPlan = async ({
|
||||
|
||||
if (stripeSubscriptionSchedule) {
|
||||
addStripeSubscriptionScheduleIdToBillingPlan({
|
||||
autumnBillingPlan,
|
||||
autumnBillingPlan: billingPlan.autumn,
|
||||
stripeSubscriptionScheduleId: stripeSubscriptionSchedule.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { stripeInvoice: undefined };
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -1,13 +1,32 @@
|
||||
import { InternalError } from "@autumn/shared";
|
||||
import type Stripe from "stripe";
|
||||
import { createStripeCli } from "@/external/connect/createStripeCli";
|
||||
import { isStripeSubscriptionCanceled } from "@/external/stripe/subscriptions/utils/classifyStripeSubscriptionUtils";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import type { StripeSubscriptionAction } from "@/internal/billing/v2/types/billingPlan";
|
||||
import type { BillingContext } from "@/internal/billing/v2/billingContext";
|
||||
import { addStripeSubscriptionIdToBillingPlan } from "@/internal/billing/v2/execute/addStripeSubscriptionIdToBillingPlan";
|
||||
import { removeStripeSubscriptionIdFromBillingPlan } from "@/internal/billing/v2/execute/removeStripeSubscriptionIdFromBillingPlan";
|
||||
import type {
|
||||
BillingPlan,
|
||||
StripeSubscriptionAction,
|
||||
} from "@/internal/billing/v2/types/billingPlan";
|
||||
import type { StripeBillingPlanResult } from "@/internal/billing/v2/types/stripeBillingPlanResult";
|
||||
import { upsertSubscriptionFromBilling } from "@/internal/billing/v2/utils/upsertFromStripe/upsertSubscriptionFromBilling";
|
||||
import { insertMetadataFromBillingPlan } from "@/internal/metadata/utils/insertMetadataFromBillingPlan";
|
||||
|
||||
export const executeStripeSubscriptionAction = async ({
|
||||
type InvoiceModeParams = {
|
||||
collection_method?: "send_invoice";
|
||||
days_until_due?: number;
|
||||
};
|
||||
|
||||
const executeSubscriptionOperation = async ({
|
||||
ctx,
|
||||
subscriptionAction,
|
||||
invoiceModeParams,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
subscriptionAction: StripeSubscriptionAction;
|
||||
invoiceModeParams: InvoiceModeParams;
|
||||
}) => {
|
||||
const { org, env } = ctx;
|
||||
const stripeClient = createStripeCli({ org, env });
|
||||
@@ -16,13 +35,118 @@ export const executeStripeSubscriptionAction = async ({
|
||||
case "update":
|
||||
return await stripeClient.subscriptions.update(
|
||||
subscriptionAction.stripeSubscriptionId,
|
||||
subscriptionAction.params,
|
||||
{
|
||||
...subscriptionAction.params,
|
||||
...invoiceModeParams,
|
||||
expand: ["latest_invoice"],
|
||||
},
|
||||
);
|
||||
case "create":
|
||||
return await stripeClient.subscriptions.create(subscriptionAction.params);
|
||||
return await stripeClient.subscriptions.create({
|
||||
...subscriptionAction.params,
|
||||
...invoiceModeParams,
|
||||
expand: ["latest_invoice"],
|
||||
});
|
||||
case "cancel":
|
||||
return await stripeClient.subscriptions.cancel(
|
||||
subscriptionAction.stripeSubscriptionId,
|
||||
{
|
||||
expand: ["latest_invoice"],
|
||||
},
|
||||
);
|
||||
|
||||
default:
|
||||
throw new InternalError({
|
||||
message: "Invalid subscription action type",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const executeStripeSubscriptionAction = async ({
|
||||
ctx,
|
||||
billingPlan,
|
||||
billingContext,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
billingPlan: BillingPlan;
|
||||
billingContext: BillingContext;
|
||||
}): Promise<StripeBillingPlanResult> => {
|
||||
// 1. Perform stripe subscription operation
|
||||
const { subscriptionAction } = billingPlan.stripe;
|
||||
|
||||
if (!subscriptionAction) return {};
|
||||
|
||||
// Invoice mode:
|
||||
const invoiceMode = billingContext.invoiceMode;
|
||||
const invoiceModeParams = invoiceMode
|
||||
? {
|
||||
collection_method: "send_invoice" as const,
|
||||
days_until_due: 30,
|
||||
}
|
||||
: {};
|
||||
|
||||
let stripeSubscription: Stripe.Subscription | undefined =
|
||||
await executeSubscriptionOperation({
|
||||
ctx,
|
||||
subscriptionAction,
|
||||
invoiceModeParams,
|
||||
});
|
||||
|
||||
const latestStripeInvoice =
|
||||
subscriptionAction.type === "create"
|
||||
? (stripeSubscription.latest_invoice as Stripe.Invoice)
|
||||
: undefined;
|
||||
|
||||
// Defer billing plan
|
||||
const enableProductAfterInvoice =
|
||||
invoiceMode?.enableProductImmediately === false;
|
||||
|
||||
const invoiceActionRequired =
|
||||
subscriptionAction.type === "create" &&
|
||||
latestStripeInvoice?.status === "open";
|
||||
|
||||
const deferBillingPlan = enableProductAfterInvoice || invoiceActionRequired;
|
||||
|
||||
if (deferBillingPlan) {
|
||||
await insertMetadataFromBillingPlan({
|
||||
ctx,
|
||||
billingPlan,
|
||||
billingContext,
|
||||
enableProductAfterInvoice,
|
||||
invoiceActionRequired,
|
||||
stripeInvoice: latestStripeInvoice,
|
||||
});
|
||||
|
||||
return {
|
||||
stripeInvoice: latestStripeInvoice,
|
||||
stripeSubscription,
|
||||
deferred: true,
|
||||
};
|
||||
}
|
||||
|
||||
addStripeSubscriptionIdToBillingPlan({
|
||||
autumnBillingPlan: billingPlan.autumn,
|
||||
stripeSubscriptionId: stripeSubscription.id,
|
||||
});
|
||||
|
||||
// Add subscription to DB
|
||||
await upsertSubscriptionFromBilling({
|
||||
ctx,
|
||||
stripeSubscription,
|
||||
});
|
||||
|
||||
// If the stripe subscription is canceled, remove the subscription from the billing plan
|
||||
if (isStripeSubscriptionCanceled(stripeSubscription)) {
|
||||
removeStripeSubscriptionIdFromBillingPlan({
|
||||
autumnBillingPlan: billingPlan.autumn,
|
||||
stripeSubscriptionId: stripeSubscription.id,
|
||||
});
|
||||
|
||||
stripeSubscription = undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
stripeSubscription,
|
||||
stripeInvoice: latestStripeInvoice,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -4,7 +4,7 @@ import type Stripe from "stripe";
|
||||
/**
|
||||
* Converts a single LineItem to Stripe.InvoiceAddLinesParams.Line
|
||||
*/
|
||||
export const lineItemToStripeLine = ({
|
||||
const toStripeAddLineParams = ({
|
||||
lineItem,
|
||||
}: {
|
||||
lineItem: LineItem;
|
||||
@@ -27,10 +27,10 @@ export const lineItemToStripeLine = ({
|
||||
/**
|
||||
* Converts an array of LineItems to Stripe.InvoiceAddLinesParams.Line[]
|
||||
*/
|
||||
export const lineItemsToStripeLines = ({
|
||||
export const lineItemsToInvoiceAddLinesParams = ({
|
||||
lineItems,
|
||||
}: {
|
||||
lineItems: LineItem[];
|
||||
}): Stripe.InvoiceAddLinesParams.Line[] => {
|
||||
return lineItems.map((lineItem) => lineItemToStripeLine({ lineItem }));
|
||||
return lineItems.map((lineItem) => toStripeAddLineParams({ lineItem }));
|
||||
};
|
||||
@@ -27,7 +27,9 @@ export const createInvoiceForBilling = async ({
|
||||
invoiceMetadata?: StripeInvoiceMetadata;
|
||||
}): Promise<PayInvoiceResult> => {
|
||||
const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env });
|
||||
const { addLineParams, invoiceMode } = stripeInvoiceAction;
|
||||
const { addLineParams } = stripeInvoiceAction;
|
||||
const invoiceMode = billingContext.invoiceMode;
|
||||
|
||||
const shouldFinalizeInvoice = invoiceMode
|
||||
? invoiceMode.finalizeInvoice
|
||||
: true;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type Stripe from "stripe";
|
||||
import { createStripeCli } from "@/external/connect/createStripeCli";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
|
||||
// ============================================
|
||||
// Create Invoice
|
||||
@@ -84,3 +86,22 @@ export const finalizeStripeInvoice = async ({
|
||||
|
||||
return invoice;
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Create Invoice Items
|
||||
// ============================================
|
||||
|
||||
export type CreateStripeInvoiceItemsParams = {
|
||||
ctx: AutumnContext;
|
||||
invoiceItems: Stripe.InvoiceItemCreateParams[];
|
||||
};
|
||||
|
||||
export const createStripeInvoiceItems = async ({
|
||||
ctx,
|
||||
invoiceItems,
|
||||
}: CreateStripeInvoiceItemsParams): Promise<void> => {
|
||||
const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env });
|
||||
for (const item of invoiceItems) {
|
||||
await stripeCli.invoiceItems.create(item);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import {
|
||||
filterCustomerProductsByActiveStatuses,
|
||||
filterCustomerProductsByStripeSubscriptionId,
|
||||
} from "@autumn/shared";
|
||||
import { customerProductToStripeItemSpecs } from "@server/internal/billing/v2/providers/stripe/utils/subscriptionItems/customerProductToStripeItemSpecs";
|
||||
import type { StripeItemSpec } from "@shared/models/billingModels/stripeAdapterModels/stripeItemSpec";
|
||||
import type { FullCusProduct } from "@shared/models/cusProductModels/cusProductModels";
|
||||
import {
|
||||
ACTIVE_STATUSES,
|
||||
isCustomerProductOnStripeSubscription,
|
||||
} from "@shared/utils";
|
||||
import type Stripe from "stripe";
|
||||
import { stripeSubscriptionItemToStripePriceId } from "@/external/stripe/subscriptions/subscriptionItems/utils/convertStripeSubscriptionItemUtils";
|
||||
import { findStripeSubscriptionItemByStripePriceId } from "@/external/stripe/subscriptions/subscriptionItems/utils/findStripeSubscriptionItemUtils";
|
||||
@@ -12,33 +12,6 @@ import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import type { BillingContext } from "@/internal/billing/v2/billingContext";
|
||||
import { findStripeItemSpecByStripePriceId } from "./findStripeItemSpec";
|
||||
|
||||
const getFinalCustomerProductsState = ({
|
||||
billingContext,
|
||||
updatedCustomerProducts = [],
|
||||
}: {
|
||||
billingContext: BillingContext;
|
||||
updatedCustomerProducts?: FullCusProduct[];
|
||||
}) => {
|
||||
const { fullCustomer, stripeSubscription } = billingContext;
|
||||
|
||||
const customerProducts = stripeSubscription
|
||||
? fullCustomer.customer_products.filter((customerProduct) =>
|
||||
isCustomerProductOnStripeSubscription({
|
||||
customerProduct,
|
||||
stripeSubscriptionId: stripeSubscription.id,
|
||||
}),
|
||||
)
|
||||
: [];
|
||||
|
||||
return customerProducts.map((customerProduct) => {
|
||||
const updated = updatedCustomerProducts.find(
|
||||
(updatedCustomerProduct) =>
|
||||
updatedCustomerProduct.id === customerProduct.id,
|
||||
);
|
||||
return updated ?? customerProduct;
|
||||
});
|
||||
};
|
||||
|
||||
const customerProductsToRecurringStripeItemSpecs = ({
|
||||
ctx,
|
||||
billingContext,
|
||||
@@ -121,30 +94,31 @@ const stripeItemSpecsToSubItemsUpdate = ({
|
||||
export const buildStripeSubscriptionItemsUpdate = ({
|
||||
ctx,
|
||||
billingContext,
|
||||
updatedCustomerProducts = [],
|
||||
finalCustomerProducts,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
billingContext: BillingContext;
|
||||
updatedCustomerProducts?: FullCusProduct[];
|
||||
finalCustomerProducts: FullCusProduct[];
|
||||
}) => {
|
||||
// 1. Get final customer product state (with updates applied)
|
||||
let customerProducts = getFinalCustomerProductsState({
|
||||
billingContext,
|
||||
updatedCustomerProducts,
|
||||
// 1. Filter customer products by stripe subscription id
|
||||
let customerProducts = filterCustomerProductsByStripeSubscriptionId({
|
||||
customerProducts: finalCustomerProducts,
|
||||
stripeSubscriptionId: billingContext.stripeSubscription?.id,
|
||||
});
|
||||
|
||||
customerProducts = customerProducts.filter((customerProduct) =>
|
||||
ACTIVE_STATUSES.includes(customerProduct.status),
|
||||
);
|
||||
// 2. Filter customer products by active statuses
|
||||
customerProducts = filterCustomerProductsByActiveStatuses({
|
||||
customerProducts,
|
||||
});
|
||||
|
||||
// 2. Get recurring subscription item array (doesn't include one off items)
|
||||
// 3. Get recurring subscription item array (doesn't include one off items)
|
||||
const recurringItems = customerProductsToRecurringStripeItemSpecs({
|
||||
ctx,
|
||||
billingContext,
|
||||
customerProducts,
|
||||
});
|
||||
|
||||
// 3. Diff it with the current subscription items
|
||||
// 4. Diff it with the current subscription items
|
||||
return stripeItemSpecsToSubItemsUpdate({
|
||||
billingContext,
|
||||
stripeItemSpecs: recurringItems,
|
||||
|
||||
@@ -7,8 +7,8 @@ import {
|
||||
import type Stripe from "stripe";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import type { BillingContext } from "@/internal/billing/v2/billingContext";
|
||||
import { customerProductToStripeItemSpecs } from "@/internal/billing/v2/utils/stripeAdapter/subscriptionItems/customerProductToStripeItemSpecs";
|
||||
import { isCustomerProductActiveDuringPeriod } from "@/internal/billing/v2/utils/stripeAdapter/subscriptionSchedules/isCustomerProductActiveAtEpochMs";
|
||||
import { customerProductToStripeItemSpecs } from "@/internal/billing/v2/providers/stripe/utils/subscriptionItems/customerProductToStripeItemSpecs";
|
||||
import { isCustomerProductActiveDuringPeriod } from "@/internal/billing/v2/providers/stripe/utils/subscriptionSchedules/isCustomerProductActiveAtEpochMs";
|
||||
import { buildTransitionPoints } from "./buildTransitionPoints";
|
||||
|
||||
/**
|
||||
|
||||
@@ -25,14 +25,6 @@ export const buildStripeSubscriptionCreateAction = ({
|
||||
const isFreeTrialWithCardRequired = Boolean(freeTrial?.card_required);
|
||||
const isCustomPaymentMethod = paymentMethod?.type === "custom";
|
||||
|
||||
// add_invoice_items: invoiceItems,
|
||||
// collection_method: invoiceOnly ? "send_invoice" : "charge_automatically",
|
||||
// days_until_due: invoiceOnly ? 30 : undefined,
|
||||
// billing cycle anchor?
|
||||
// discounts?
|
||||
// expand: ["latest_invoice"],
|
||||
// metadata?
|
||||
|
||||
const stripeSubscriptionCreateParams: Stripe.SubscriptionCreateParams = {
|
||||
customer: stripeCustomer.id,
|
||||
items: subItemsUpdate.map((item) => ({
|
||||
@@ -42,6 +34,8 @@ export const buildStripeSubscriptionCreateAction = ({
|
||||
|
||||
billing_mode: { type: "flexible" },
|
||||
|
||||
collection_method: "charge_automatically",
|
||||
|
||||
payment_behavior: isCustomPaymentMethod
|
||||
? "default_incomplete"
|
||||
: "error_if_incomplete",
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
InternalError,
|
||||
type LineItemContext,
|
||||
orgToCurrency,
|
||||
priceToProrationConfig,
|
||||
sumValues,
|
||||
usagePriceToLineItem,
|
||||
} from "@autumn/shared";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
@@ -52,6 +54,18 @@ export const buildQuantityUpdateLineItems = ({
|
||||
});
|
||||
}
|
||||
|
||||
// Get proration config based on price and direction (upgrade/downgrade)
|
||||
const isUpgrade = quantityDifferenceForEntitlements > 0;
|
||||
const { shouldApplyProration, chargeImmediately, skipLineItems } =
|
||||
priceToProrationConfig({
|
||||
price: customerPrice.price,
|
||||
isUpgrade,
|
||||
});
|
||||
|
||||
if (skipLineItems) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Clone entitlement with updated quantity for the charge line item
|
||||
const newCustomerEntitlement = cloneEntitlementWithUpdatedQuantity({
|
||||
customerEntitlement: prepaidCustomerEntitlement,
|
||||
@@ -76,12 +90,23 @@ export const buildQuantityUpdateLineItems = ({
|
||||
...lineItemContext,
|
||||
direction: "refund",
|
||||
},
|
||||
shouldProrateOverride: shouldApplyProration,
|
||||
chargeImmediatelyOverride: chargeImmediately,
|
||||
});
|
||||
|
||||
const chargeLineItem = usagePriceToLineItem({
|
||||
cusEnt: newCustomerEntitlement,
|
||||
context: lineItemContext,
|
||||
shouldProrateOverride: shouldApplyProration,
|
||||
chargeImmediatelyOverride: chargeImmediately,
|
||||
});
|
||||
|
||||
// Don't return line items if they sum to 0
|
||||
if (
|
||||
sumValues([refundLineItem.finalAmount, chargeLineItem.finalAmount]) === 0
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [refundLineItem, chargeLineItem];
|
||||
};
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
import { getLineItemBillingPeriod } from "@shared/utils/billingUtils/cycleUtils/getLineItemBillingPeriod";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import type { QuantityUpdateDetails } from "@/internal/billing/v2/typesOld";
|
||||
import type { UpdateSubscriptionContext } from "../fetch/updateSubscriptionContextSchema";
|
||||
import type { UpdateSubscriptionBillingContext } from "../../billingContext";
|
||||
import { buildQuantityUpdateLineItems } from "./buildQuantityUpdateLineItems";
|
||||
import { calculateCustomerEntitlementChange } from "./quantityUpdateUtils/calculateCustomerEntitlementChange";
|
||||
import { calculateQuantityDifferences } from "./quantityUpdateUtils/calculateQuantityDifferences";
|
||||
@@ -33,7 +33,7 @@ export const computeQuantityUpdateDetails = ({
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
updatedOptions: FeatureOptions;
|
||||
updateSubscriptionContext: UpdateSubscriptionContext;
|
||||
updateSubscriptionContext: UpdateSubscriptionBillingContext;
|
||||
}): QuantityUpdateDetails => {
|
||||
const { customerProduct, currentEpochMs, billingCycleAnchorMs } =
|
||||
updateSubscriptionContext;
|
||||
@@ -78,7 +78,15 @@ export const computeQuantityUpdateDetails = ({
|
||||
const priceConfiguration = resolvePriceForQuantityUpdate({
|
||||
customerProduct,
|
||||
updatedOptions,
|
||||
isUpgrade: quantityDifferences.isUpgrade,
|
||||
});
|
||||
|
||||
const { customerEntitlementId, customerEntitlementBalanceChange } =
|
||||
calculateCustomerEntitlementChange({
|
||||
quantityDifferenceForEntitlements:
|
||||
quantityDifferences.quantityDifferenceForEntitlements,
|
||||
billingUnitsPerQuantity: priceConfiguration.billingUnitsPerQuantity,
|
||||
customerPrice: priceConfiguration.customerPrice,
|
||||
customerEntitlements: customerProduct.customer_entitlements,
|
||||
});
|
||||
|
||||
const billingPeriod = getLineItemBillingPeriod({
|
||||
@@ -103,15 +111,6 @@ export const computeQuantityUpdateDetails = ({
|
||||
currentEpochMs,
|
||||
});
|
||||
|
||||
const { customerEntitlementId, customerEntitlementBalanceChange } =
|
||||
calculateCustomerEntitlementChange({
|
||||
quantityDifferenceForEntitlements:
|
||||
quantityDifferences.quantityDifferenceForEntitlements,
|
||||
billingUnitsPerQuantity: priceConfiguration.billingUnitsPerQuantity,
|
||||
customerPrice: priceConfiguration.customerPrice,
|
||||
customerEntitlements: customerProduct.customer_entitlements,
|
||||
});
|
||||
|
||||
return {
|
||||
featureId,
|
||||
customerEntitlementId,
|
||||
|
||||
@@ -5,13 +5,13 @@ import {
|
||||
isCustomerProductOneOff,
|
||||
} from "@autumn/shared";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import type { UpdateSubscriptionBillingContext } from "@/internal/billing/v2/billingContext";
|
||||
import { buildAutumnLineItems } from "@/internal/billing/v2/compute/computeAutumnUtils/buildAutumnLineItems";
|
||||
import type { UpdateSubscriptionContext } from "@/internal/billing/v2/subscriptionUpdate/fetch/updateSubscriptionContextSchema";
|
||||
import type {
|
||||
StripeInvoiceAction,
|
||||
StripeSubscriptionAction,
|
||||
} from "@/internal/billing/v2/types/billingPlan";
|
||||
import { lineItemsToStripeLines } from "../../../providers/stripe/utils/invoiceLines/lineItemsToStripeLines";
|
||||
import { lineItemsToInvoiceAddLinesParams } from "../../../providers/stripe/utils/invoiceLines/lineItemsToInvoiceAddLinesParams";
|
||||
|
||||
export const computeInvoiceAction = ({
|
||||
ctx,
|
||||
@@ -21,7 +21,7 @@ export const computeInvoiceAction = ({
|
||||
billingCycleAnchor,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
billingContext: UpdateSubscriptionContext;
|
||||
billingContext: UpdateSubscriptionBillingContext;
|
||||
newCustomerProduct: FullCusProduct;
|
||||
stripeSubscriptionAction?: StripeSubscriptionAction;
|
||||
billingCycleAnchor?: number;
|
||||
@@ -61,7 +61,7 @@ export const computeInvoiceAction = ({
|
||||
testClockFrozenTime: billingContext.testClockFrozenTime,
|
||||
});
|
||||
|
||||
const addLineParams = lineItemsToStripeLines({
|
||||
const addLineParams = lineItemsToInvoiceAddLinesParams({
|
||||
lineItems: autumnLineItems,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import {
|
||||
CusProductStatus,
|
||||
cusProductToProduct,
|
||||
type SubscriptionUpdateV0Params,
|
||||
secondsToMs,
|
||||
type UpdateSubscriptionV0Params,
|
||||
} from "@autumn/shared";
|
||||
import type { AutumnContext } from "@server/honoUtils/HonoEnv";
|
||||
import type { UpdateSubscriptionContext } from "@server/internal/billing/v2/subscriptionUpdate/fetch/updateSubscriptionContextSchema";
|
||||
import type { UpdateSubscriptionBillingContext } from "@server/internal/billing/v2/billingContext";
|
||||
import { buildAutumnLineItems } from "@/internal/billing/v2/compute/computeAutumnUtils/buildAutumnLineItems";
|
||||
import { computeSubscriptionUpdateFreeTrialPlan } from "@/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateFreeTrialPlan";
|
||||
import { computeSubscriptionUpdateNewCustomerProduct } from "@/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateNewCustomerProduct";
|
||||
import type { AutumnBillingPlan } from "@/internal/billing/v2/types/billingPlan";
|
||||
@@ -16,8 +18,8 @@ export const computeSubscriptionUpdateCustomPlan = async ({
|
||||
params,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
updateSubscriptionContext: UpdateSubscriptionContext;
|
||||
params: SubscriptionUpdateV0Params;
|
||||
updateSubscriptionContext: UpdateSubscriptionBillingContext;
|
||||
params: UpdateSubscriptionV0Params;
|
||||
}) => {
|
||||
const { customerProduct, stripeSubscription } = updateSubscriptionContext;
|
||||
|
||||
@@ -36,7 +38,7 @@ export const computeSubscriptionUpdateCustomPlan = async ({
|
||||
customItems: params.items,
|
||||
});
|
||||
|
||||
updateSubscriptionContext.fullProducts.push(customFullProduct);
|
||||
updateSubscriptionContext.fullProducts = [customFullProduct];
|
||||
|
||||
// 2. Compute the custom trial details
|
||||
const { freeTrialPlan, customFreeTrial } =
|
||||
@@ -46,12 +48,10 @@ export const computeSubscriptionUpdateCustomPlan = async ({
|
||||
fullProduct: customFullProduct,
|
||||
});
|
||||
|
||||
const billingCycleAnchor =
|
||||
updateSubscriptionContext.billingCycleAnchorMs =
|
||||
freeTrialPlan.trialEndsAt ??
|
||||
secondsToMs(stripeSubscription?.billing_cycle_anchor);
|
||||
|
||||
const nowMs = updateSubscriptionContext.testClockFrozenTime ?? Date.now();
|
||||
|
||||
// 3. Compute the new customer product
|
||||
const newFullCustomerProduct = computeSubscriptionUpdateNewCustomerProduct({
|
||||
ctx,
|
||||
@@ -59,85 +59,24 @@ export const computeSubscriptionUpdateCustomPlan = async ({
|
||||
params,
|
||||
fullProduct: customFullProduct,
|
||||
freeTrialPlan,
|
||||
billingCycleAnchor,
|
||||
});
|
||||
|
||||
// Line items?
|
||||
const autumnLineItems = buildAutumnLineItems({
|
||||
ctx,
|
||||
newCustomerProducts: [newFullCustomerProduct],
|
||||
deletedCustomerProduct: customerProduct,
|
||||
billingContext: updateSubscriptionContext,
|
||||
});
|
||||
|
||||
return {
|
||||
insertCustomerProducts: [newFullCustomerProduct],
|
||||
// updateCustomerProduct: {
|
||||
// customerProduct: customerProduct,
|
||||
// updates: {
|
||||
// status: CusProductStatus.Expired,
|
||||
// },
|
||||
// },
|
||||
updateCustomerProduct: {
|
||||
...customerProduct,
|
||||
status: CusProductStatus.Expired,
|
||||
},
|
||||
customPrices: customPrices,
|
||||
customEntitlements: customEnts,
|
||||
customFreeTrial: customFreeTrial,
|
||||
autumnLineItems: [],
|
||||
autumnLineItems,
|
||||
} satisfies AutumnBillingPlan;
|
||||
|
||||
// 4. Create stripe prices
|
||||
// const fullCustomer = updateSubscriptionContext.fullCustomer;
|
||||
// await createStripeResourcesForProducts({
|
||||
// ctx,
|
||||
// fullCustomer,
|
||||
// fullProducts: [customFullProduct],
|
||||
// });
|
||||
|
||||
// // 5. Build subscription schedule action
|
||||
// const scheduleAction = buildStripeSubscriptionScheduleAction({
|
||||
// ctx,
|
||||
// billingContext: updateSubscriptionContext,
|
||||
// addCustomerProducts: [newFullCustomerProduct],
|
||||
// removeCustomerProducts: [customerProduct],
|
||||
// trialEndsAt: freeTrialPlan.trialEndsAt,
|
||||
// nowMs,
|
||||
// });
|
||||
|
||||
// // 6. Compute Stripe subscription action
|
||||
// const stripeSubscriptionAction = buildStripeSubscriptionAction({
|
||||
// ctx,
|
||||
// billingContext: updateSubscriptionContext,
|
||||
// newCustomerProduct: newFullCustomerProduct,
|
||||
// stripeSubscriptionScheduleAction: scheduleAction,
|
||||
// freeTrialPlan,
|
||||
// nowMs,
|
||||
// });
|
||||
|
||||
// // 6. Compute subscription schedule action
|
||||
// const stripeInvoiceAction = computeInvoiceAction({
|
||||
// ctx,
|
||||
// billingContext: updateSubscriptionContext,
|
||||
// newCustomerProduct: newFullCustomerProduct,
|
||||
// stripeSubscriptionAction,
|
||||
// billingCycleAnchor,
|
||||
// });
|
||||
|
||||
// const billingPlan: BillingPlan = {
|
||||
// stripe: {
|
||||
// subscriptionAction: stripeSubscriptionAction,
|
||||
// invoiceAction: stripeInvoiceAction,
|
||||
// subscriptionScheduleAction: scheduleAction,
|
||||
// },
|
||||
|
||||
// autumn: {
|
||||
// freeTrialPlan,
|
||||
// insertCustomerProducts: [newFullCustomerProduct],
|
||||
|
||||
// updateCustomerProduct: {
|
||||
// customerProduct: customerProduct,
|
||||
// updates: {
|
||||
// status: CusProductStatus.Expired,
|
||||
// },
|
||||
// },
|
||||
|
||||
// customPrices: customPrices,
|
||||
// customEntitlements: customEnts,
|
||||
// customFreeTrial: customFreeTrial,
|
||||
// },
|
||||
// };
|
||||
|
||||
// return billingPlan;
|
||||
};
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
InternalError,
|
||||
isPrepaidPrice,
|
||||
priceToFeature,
|
||||
type SubscriptionUpdateV0Params,
|
||||
type UpdateSubscriptionV0Params,
|
||||
} from "@autumn/shared";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import { paramsToFeatureOptions } from "@/internal/billing/v2/compute/computeAutumnUtils/paramsToFeatureOptions";
|
||||
@@ -23,7 +23,7 @@ export const computeSubscriptionUpdateFeatureQuantities = ({
|
||||
ctx: AutumnContext;
|
||||
fullProduct: FullProduct;
|
||||
currentCustomerProduct: FullCusProduct;
|
||||
params: SubscriptionUpdateV0Params;
|
||||
params: UpdateSubscriptionV0Params;
|
||||
}) => {
|
||||
const newFeatureQuantities: FeatureOptions[] = [];
|
||||
for (const price of fullProduct.prices) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type {
|
||||
FreeTrial,
|
||||
FullProduct,
|
||||
SubscriptionUpdateV0Params,
|
||||
UpdateSubscriptionV0Params,
|
||||
} from "@autumn/shared";
|
||||
import {
|
||||
addDuration,
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
secondsToMs,
|
||||
} from "@autumn/shared";
|
||||
import { isStripeSubscriptionTrialing } from "@/external/stripe/subscriptions/utils/classifyStripeSubscriptionUtils";
|
||||
import type { UpdateSubscriptionContext } from "@/internal/billing/v2/subscriptionUpdate/fetch/updateSubscriptionContextSchema";
|
||||
import type { UpdateSubscriptionBillingContext } from "@/internal/billing/v2/billingContext";
|
||||
|
||||
interface ComputeSubscriptionUpdateTrialDetailsResult {
|
||||
freeTrialPlan: {
|
||||
@@ -25,8 +25,8 @@ export const computeSubscriptionUpdateFreeTrialPlan = ({
|
||||
params,
|
||||
fullProduct,
|
||||
}: {
|
||||
updateSubscriptionContext: UpdateSubscriptionContext;
|
||||
params: SubscriptionUpdateV0Params;
|
||||
updateSubscriptionContext: UpdateSubscriptionBillingContext;
|
||||
params: UpdateSubscriptionV0Params;
|
||||
fullProduct: FullProduct;
|
||||
}): ComputeSubscriptionUpdateTrialDetailsResult => {
|
||||
const { stripeSubscription, customerProduct, currentEpochMs } =
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import {
|
||||
type FullProduct,
|
||||
InternalError,
|
||||
type SubscriptionUpdateV0Params,
|
||||
} from "@autumn/shared";
|
||||
import type { FullProduct, UpdateSubscriptionV0Params } from "@autumn/shared";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import type { UpdateSubscriptionBillingContext } from "@/internal/billing/v2/billingContext";
|
||||
import { computeSubscriptionUpdateFeatureQuantities } from "@/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateFeatureQuantities";
|
||||
import type { UpdateSubscriptionContext } from "@/internal/billing/v2/subscriptionUpdate/fetch/updateSubscriptionContextSchema";
|
||||
import type { FreeTrialPlan } from "@/internal/billing/v2/types/billingPlan";
|
||||
import { cusProductToExistingRollovers } from "@/internal/billing/v2/utils/handleExistingRollovers/cusProductToExistingRollovers";
|
||||
import { cusProductToExistingUsages } from "@/internal/billing/v2/utils/handleExistingUsages/cusProductToExistingUsages";
|
||||
@@ -17,29 +13,22 @@ export const computeSubscriptionUpdateNewCustomerProduct = ({
|
||||
updateSubscriptionContext,
|
||||
fullProduct,
|
||||
freeTrialPlan,
|
||||
billingCycleAnchor,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
params: SubscriptionUpdateV0Params;
|
||||
updateSubscriptionContext: UpdateSubscriptionContext;
|
||||
params: UpdateSubscriptionV0Params;
|
||||
updateSubscriptionContext: UpdateSubscriptionBillingContext;
|
||||
fullProduct: FullProduct;
|
||||
freeTrialPlan: FreeTrialPlan;
|
||||
billingCycleAnchor?: number;
|
||||
}) => {
|
||||
const {
|
||||
customerProduct,
|
||||
fullCustomer,
|
||||
stripeSubscription,
|
||||
stripeSubscriptionSchedule,
|
||||
billingCycleAnchorMs,
|
||||
currentEpochMs,
|
||||
} = updateSubscriptionContext;
|
||||
|
||||
if (!stripeSubscription) {
|
||||
throw new InternalError({
|
||||
message: `[Subscription Update] Stripe subscription not found`,
|
||||
});
|
||||
}
|
||||
|
||||
// 1. Get feature quantities
|
||||
const existingUsages = cusProductToExistingUsages({
|
||||
cusProduct: customerProduct,
|
||||
@@ -67,7 +56,7 @@ export const computeSubscriptionUpdateNewCustomerProduct = ({
|
||||
featureQuantities,
|
||||
existingUsages,
|
||||
existingRollovers,
|
||||
resetCycleAnchor: billingCycleAnchor ?? "now",
|
||||
resetCycleAnchor: billingCycleAnchorMs ?? "now",
|
||||
now: currentEpochMs,
|
||||
|
||||
freeTrial: freeTrialPlan.freeTrial ?? null,
|
||||
@@ -76,7 +65,7 @@ export const computeSubscriptionUpdateNewCustomerProduct = ({
|
||||
|
||||
initOptions: {
|
||||
isCustom: true,
|
||||
subscriptionId: stripeSubscription.id,
|
||||
subscriptionId: stripeSubscription?.id,
|
||||
subscriptionScheduleId: stripeSubscriptionSchedule?.id,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { SubscriptionUpdateV0Params } from "@shared/index";
|
||||
import type { UpdateSubscriptionV0Params } from "@shared/index";
|
||||
import { SubscriptionUpdateIntentEnum } from "./computeSubscriptionUpdateSchema";
|
||||
|
||||
/**
|
||||
@@ -7,7 +7,7 @@ import { SubscriptionUpdateIntentEnum } from "./computeSubscriptionUpdateSchema"
|
||||
* @returns The intent for the subscription update
|
||||
*/
|
||||
export const computeSubscriptionUpdateIntent = (
|
||||
params: SubscriptionUpdateV0Params,
|
||||
params: UpdateSubscriptionV0Params,
|
||||
): SubscriptionUpdateIntentEnum => {
|
||||
if (params.options?.length && !params.items?.length)
|
||||
return SubscriptionUpdateIntentEnum.UpdateQuantity;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { SubscriptionUpdateV0Params } from "@shared/index";
|
||||
import type { UpdateSubscriptionV0Params } from "@autumn/shared";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import type { UpdateSubscriptionBillingContext } from "@/internal/billing/v2/billingContext";
|
||||
import { computeSubscriptionUpdateCustomPlan } from "@/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateCustomPlan";
|
||||
import { computeSubscriptionUpdateQuantityPlan } from "@/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateQuantityPlan";
|
||||
import { SubscriptionUpdateIntentEnum } from "@/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateSchema";
|
||||
import type { AutumnBillingPlan } from "@/internal/billing/v2/types/billingPlan";
|
||||
import type { UpdateSubscriptionContext } from "../fetch/updateSubscriptionContextSchema";
|
||||
import { computeSubscriptionUpdateIntent } from "./computeSubscriptionUpdateIntent";
|
||||
|
||||
/**
|
||||
@@ -15,12 +15,12 @@ import { computeSubscriptionUpdateIntent } from "./computeSubscriptionUpdateInte
|
||||
*/
|
||||
export const computeSubscriptionUpdatePlan = async ({
|
||||
ctx,
|
||||
updateSubscriptionContext,
|
||||
billingContext,
|
||||
params,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
updateSubscriptionContext: UpdateSubscriptionContext;
|
||||
params: SubscriptionUpdateV0Params;
|
||||
billingContext: UpdateSubscriptionBillingContext;
|
||||
params: UpdateSubscriptionV0Params;
|
||||
}): Promise<AutumnBillingPlan> => {
|
||||
const intent = computeSubscriptionUpdateIntent(params);
|
||||
|
||||
@@ -28,13 +28,13 @@ export const computeSubscriptionUpdatePlan = async ({
|
||||
case SubscriptionUpdateIntentEnum.UpdateQuantity:
|
||||
return computeSubscriptionUpdateQuantityPlan({
|
||||
ctx,
|
||||
updateSubscriptionContext,
|
||||
updateSubscriptionContext: billingContext,
|
||||
params,
|
||||
});
|
||||
case SubscriptionUpdateIntentEnum.UpdatePlan:
|
||||
return await computeSubscriptionUpdateCustomPlan({
|
||||
ctx,
|
||||
updateSubscriptionContext,
|
||||
updateSubscriptionContext: billingContext,
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { InternalError, type SubscriptionUpdateV0Params } from "@autumn/shared";
|
||||
import { InternalError, type UpdateSubscriptionV0Params } from "@autumn/shared";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import type { AutumnBillingPlan } from "../../types/billingPlan";
|
||||
import type { UpdateSubscriptionContext } from "../fetch/updateSubscriptionContextSchema";
|
||||
import type { UpdateSubscriptionBillingContext } from "../../billingContext";
|
||||
import { computeQuantityUpdateDetails } from "./computeQuantityUpdateDetails";
|
||||
|
||||
export const computeSubscriptionUpdateQuantityPlan = ({
|
||||
@@ -10,8 +10,8 @@ export const computeSubscriptionUpdateQuantityPlan = ({
|
||||
params,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
updateSubscriptionContext: UpdateSubscriptionContext;
|
||||
params: SubscriptionUpdateV0Params;
|
||||
updateSubscriptionContext: UpdateSubscriptionBillingContext;
|
||||
params: UpdateSubscriptionV0Params;
|
||||
}): AutumnBillingPlan => {
|
||||
const { customerProduct, stripeSubscription } = updateSubscriptionContext;
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
priceToLineAmount,
|
||||
} from "@autumn/shared";
|
||||
import { Decimal } from "decimal.js";
|
||||
import type { UpdateSubscriptionContext } from "../../fetch/updateSubscriptionContextSchema";
|
||||
import type { UpdateSubscriptionBillingContext } from "../../../billingContext";
|
||||
import type { calculateQuantityDifferences } from "./calculateQuantityDifferences";
|
||||
import type { resolvePriceForQuantityUpdate } from "./resolvePriceForQuantityUpdate";
|
||||
|
||||
@@ -31,7 +31,7 @@ export const calculateProrationAmount = ({
|
||||
quantityDifferences,
|
||||
billingPeriod,
|
||||
}: {
|
||||
updateSubscriptionContext: UpdateSubscriptionContext;
|
||||
updateSubscriptionContext: UpdateSubscriptionBillingContext;
|
||||
previousOptions: FeatureOptions;
|
||||
updatedOptions: FeatureOptions;
|
||||
priceConfiguration: ReturnType<typeof resolvePriceForQuantityUpdate>;
|
||||
|
||||
@@ -4,40 +4,31 @@ import {
|
||||
type FullCustomerPrice,
|
||||
findCusPriceByFeature,
|
||||
InternalError,
|
||||
type OnDecrease,
|
||||
type OnIncrease,
|
||||
type Price,
|
||||
priceToProrationConfig,
|
||||
type UsagePriceConfig,
|
||||
} from "@autumn/shared";
|
||||
|
||||
/**
|
||||
* Extracts and validates price configuration for a quantity update.
|
||||
*
|
||||
* Determines proration behavior (on_increase/on_decrease) based on quantity change direction.
|
||||
* Finds the customer price and extracts billing units per quantity.
|
||||
*
|
||||
* @param updatedOptions - New feature options with target quantity
|
||||
* @param customerProduct - Customer product with associated prices
|
||||
* @param previousOptions - Current feature options
|
||||
* @returns Price config including proration rules and billing units
|
||||
* @returns Price config including billing units
|
||||
* @throws {InternalError} When internal_feature_id is missing or price not found
|
||||
*/
|
||||
export const resolvePriceForQuantityUpdate = ({
|
||||
customerProduct,
|
||||
updatedOptions,
|
||||
isUpgrade,
|
||||
}: {
|
||||
customerProduct: FullCusProduct;
|
||||
updatedOptions: FeatureOptions;
|
||||
isUpgrade: boolean;
|
||||
}): {
|
||||
customerPrice: FullCustomerPrice;
|
||||
price: Price;
|
||||
priceConfig: UsagePriceConfig;
|
||||
billingUnitsPerQuantity: number;
|
||||
prorationBehaviorConfig: OnIncrease | OnDecrease;
|
||||
shouldApplyProration: boolean;
|
||||
shouldFinalizeInvoiceImmediately: boolean;
|
||||
} => {
|
||||
if (!updatedOptions.internal_feature_id) {
|
||||
throw new InternalError({
|
||||
@@ -60,22 +51,10 @@ export const resolvePriceForQuantityUpdate = ({
|
||||
const priceConfig = price.config as UsagePriceConfig;
|
||||
const billingUnitsPerQuantity = priceConfig.billing_units ?? 1;
|
||||
|
||||
const {
|
||||
prorationBehaviorConfig,
|
||||
shouldApplyProration,
|
||||
shouldFinalizeInvoiceImmediately,
|
||||
} = priceToProrationConfig({
|
||||
price,
|
||||
isUpgrade,
|
||||
});
|
||||
|
||||
return {
|
||||
customerPrice,
|
||||
price,
|
||||
priceConfig,
|
||||
billingUnitsPerQuantity,
|
||||
prorationBehaviorConfig,
|
||||
shouldApplyProration,
|
||||
shouldFinalizeInvoiceImmediately,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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");
|
||||
};
|
||||
@@ -1,14 +1,14 @@
|
||||
import {
|
||||
type FullCustomer,
|
||||
isCusProductOnEntity,
|
||||
type SubscriptionUpdateV0Params,
|
||||
type UpdateSubscriptionV0Params,
|
||||
} from "@autumn/shared";
|
||||
|
||||
export const fetchTargetCusProductForUpdate = ({
|
||||
params,
|
||||
fullCustomer,
|
||||
}: {
|
||||
params: SubscriptionUpdateV0Params;
|
||||
params: UpdateSubscriptionV0Params;
|
||||
fullCustomer: FullCustomer;
|
||||
}) => {
|
||||
const cusProducts = fullCustomer.customer_products;
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import {
|
||||
cusProductToProduct,
|
||||
InternalError,
|
||||
type SubscriptionUpdateV0Params,
|
||||
secondsToMs,
|
||||
} from "@shared/index";
|
||||
type UpdateSubscriptionV0Params,
|
||||
} from "@autumn/shared";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import { fetchStripeCustomerForBilling } from "@/internal/billing/v2/providers/stripe/fetch/fetchStripeCustomerForBilling";
|
||||
import { fetchStripeSubscriptionForBilling } from "@/internal/billing/v2/providers/stripe/fetch/fetchStripeSubscriptionForBilling";
|
||||
import { fetchStripeSubscriptionScheduleForBilling } from "@/internal/billing/v2/providers/stripe/fetch/fetchStripeSubscriptionScheduleForBilling";
|
||||
import { CusService } from "../../../../customers/CusService";
|
||||
import type { UpdateSubscriptionBillingContext } from "../../billingContext";
|
||||
import { parseFeatureQuantitiesParams } from "../../utils/parseFeatureQuantitiesParams";
|
||||
import { fetchTargetCusProductForUpdate } from "./fetchTargetCusProductForUpdate";
|
||||
import type { UpdateSubscriptionContext } from "./updateSubscriptionContextSchema";
|
||||
|
||||
/**
|
||||
* Fetch the context for updating a subscription
|
||||
@@ -18,13 +19,13 @@ import type { UpdateSubscriptionContext } from "./updateSubscriptionContextSchem
|
||||
* @param body - The body of the request
|
||||
* @returns The update subscription context
|
||||
*/
|
||||
export const fetchApiSubscriptionUpdateContext = async ({
|
||||
export const fetchUpdateSubscriptionBillingContext = async ({
|
||||
ctx,
|
||||
params,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
params: SubscriptionUpdateV0Params;
|
||||
}): Promise<UpdateSubscriptionContext> => {
|
||||
params: UpdateSubscriptionV0Params;
|
||||
}): Promise<UpdateSubscriptionBillingContext> => {
|
||||
const { db, org, env, features } = ctx;
|
||||
const { customer_id: customerId, product_id: productId } = params;
|
||||
|
||||
@@ -49,6 +50,10 @@ export const fetchApiSubscriptionUpdateContext = async ({
|
||||
});
|
||||
}
|
||||
|
||||
const fullProduct = cusProductToProduct({
|
||||
cusProduct: targetCustomerProduct,
|
||||
});
|
||||
|
||||
const stripeSubscription = await fetchStripeSubscriptionForBilling({
|
||||
ctx,
|
||||
fullCus: fullCustomer,
|
||||
@@ -91,16 +96,26 @@ export const fetchApiSubscriptionUpdateContext = async ({
|
||||
stripeSubscription?.billing_cycle_anchor,
|
||||
);
|
||||
|
||||
// Invoice mode
|
||||
const invoiceMode =
|
||||
params?.invoice === true
|
||||
? {
|
||||
finalizeInvoice: params.finalize_invoice === true,
|
||||
enableProductImmediately: params.enable_product_immediately !== false,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
fullCustomer,
|
||||
fullProducts: [],
|
||||
fullProducts: [fullProduct],
|
||||
customerProduct: targetCustomerProduct,
|
||||
stripeSubscription,
|
||||
stripeSubscriptionSchedule,
|
||||
stripeCustomer,
|
||||
paymentMethod,
|
||||
testClockFrozenTime,
|
||||
|
||||
currentEpochMs,
|
||||
billingCycleAnchorMs,
|
||||
invoiceMode,
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
PriceSchema,
|
||||
} from "@autumn/shared";
|
||||
import { z } from "zod/v4";
|
||||
import type { BillingContext } from "@/internal/billing/v2/billingContext";
|
||||
import type { BillingPlan } from "@/internal/billing/v2/types/billingPlan";
|
||||
import { FullCusProductSchema } from "../../../../../../shared/models/cusProductModels/cusProductModels";
|
||||
|
||||
export const FreeTrialPlanSchema = z.object({
|
||||
@@ -15,13 +17,6 @@ export const FreeTrialPlanSchema = z.object({
|
||||
|
||||
export type FreeTrialPlan = z.infer<typeof FreeTrialPlanSchema>;
|
||||
|
||||
export const InvoiceModeSchema = z.object({
|
||||
finalizeInvoice: z.boolean().default(false),
|
||||
enableProductImmediately: z.boolean().default(true),
|
||||
});
|
||||
|
||||
export type InvoiceMode = z.infer<typeof InvoiceModeSchema>;
|
||||
|
||||
export const UpdateCustomerEntitlementSchema = z.object({
|
||||
customerEntitlementId: z.string(),
|
||||
balanceChange: z.number(),
|
||||
@@ -50,5 +45,6 @@ export type AutumnBillingPlan = z.infer<typeof AutumnBillingPlanSchema>;
|
||||
export type DeferredAutumnBillingPlanData = {
|
||||
orgId: string;
|
||||
env: AppEnv;
|
||||
autumnBillingPlan: AutumnBillingPlan;
|
||||
billingPlan: BillingPlan;
|
||||
billingContext: BillingContext;
|
||||
};
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
StripeBillingPlanSchema,
|
||||
type StripeInvoiceAction,
|
||||
StripeInvoiceActionSchema,
|
||||
type StripeInvoiceItemsAction,
|
||||
StripeInvoiceItemsActionSchema,
|
||||
type StripeInvoiceMetadata,
|
||||
type StripeSubscriptionAction,
|
||||
StripeSubscriptionActionSchema,
|
||||
@@ -24,6 +26,7 @@ export {
|
||||
InvoiceModeSchema,
|
||||
StripeBillingPlanSchema,
|
||||
StripeInvoiceActionSchema,
|
||||
StripeInvoiceItemsActionSchema,
|
||||
StripeSubscriptionActionSchema,
|
||||
StripeSubscriptionScheduleActionSchema,
|
||||
type AutumnBillingPlan,
|
||||
@@ -32,6 +35,7 @@ export {
|
||||
type InvoiceMode,
|
||||
type StripeBillingPlan,
|
||||
type StripeInvoiceAction,
|
||||
type StripeInvoiceItemsAction,
|
||||
type StripeInvoiceMetadata,
|
||||
type StripeSubscriptionAction,
|
||||
type StripeSubscriptionScheduleAction,
|
||||
|
||||
@@ -1,22 +1,28 @@
|
||||
import { z } from "zod/v4";
|
||||
import {
|
||||
StripeInvoiceActionSchema,
|
||||
type StripeInvoiceAction,
|
||||
StripeInvoiceActionSchema,
|
||||
} from "./stripeInvoiceAction";
|
||||
import {
|
||||
StripeSubscriptionActionSchema,
|
||||
type StripeInvoiceItemsAction,
|
||||
StripeInvoiceItemsActionSchema,
|
||||
} from "./stripeInvoiceItemsAction";
|
||||
import {
|
||||
type StripeSubscriptionAction,
|
||||
StripeSubscriptionActionSchema,
|
||||
} from "./stripeSubscriptionAction";
|
||||
import {
|
||||
StripeSubscriptionScheduleActionSchema,
|
||||
type StripeSubscriptionScheduleAction,
|
||||
StripeSubscriptionScheduleActionSchema,
|
||||
} from "./stripeSubscriptionScheduleAction";
|
||||
|
||||
export {
|
||||
StripeInvoiceActionSchema,
|
||||
StripeInvoiceItemsActionSchema,
|
||||
StripeSubscriptionActionSchema,
|
||||
StripeSubscriptionScheduleActionSchema,
|
||||
type StripeInvoiceAction,
|
||||
type StripeInvoiceItemsAction,
|
||||
type StripeSubscriptionAction,
|
||||
type StripeSubscriptionScheduleAction,
|
||||
};
|
||||
@@ -25,6 +31,7 @@ export const StripeBillingPlanSchema = z.object({
|
||||
subscriptionAction: StripeSubscriptionActionSchema.optional(),
|
||||
subscriptionScheduleAction: StripeSubscriptionScheduleActionSchema.optional(),
|
||||
invoiceAction: StripeInvoiceActionSchema.optional(),
|
||||
invoiceItemsAction: StripeInvoiceItemsActionSchema.optional(),
|
||||
});
|
||||
|
||||
export type StripeBillingPlan = z.infer<typeof StripeBillingPlanSchema>;
|
||||
@@ -32,4 +39,3 @@ export type StripeBillingPlan = z.infer<typeof StripeBillingPlanSchema>;
|
||||
export type StripeInvoiceMetadata = {
|
||||
autumn_metadata_id: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { z } from "zod/v4";
|
||||
import { InvoiceModeSchema } from "../autumnBillingPlan";
|
||||
|
||||
export const StripeInvoiceActionSchema = z.object({
|
||||
addLineParams: z.custom<import("stripe").Stripe.InvoiceAddLinesParams>(),
|
||||
invoiceMode: InvoiceModeSchema.optional(),
|
||||
});
|
||||
|
||||
export type StripeInvoiceAction = z.infer<typeof StripeInvoiceActionSchema>;
|
||||
|
||||
|
||||
@@ -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
|
||||
>;
|
||||
@@ -0,0 +1,7 @@
|
||||
import type Stripe from "stripe";
|
||||
|
||||
export interface StripeBillingPlanResult {
|
||||
deferred?: boolean;
|
||||
stripeInvoice?: Stripe.Invoice;
|
||||
stripeSubscription?: Stripe.Subscription;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -7,9 +7,14 @@ import {
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import type { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
|
||||
/**
|
||||
* MetadataService handles CRUD operations for the metadata table.
|
||||
*/
|
||||
export class MetadataService {
|
||||
static async insert({ db, data }: { db: DrizzleCli; data: MetadataInsert }) {
|
||||
await db.insert(metadata).values(data);
|
||||
const insertedMetadata = await db.insert(metadata).values(data).returning();
|
||||
|
||||
return insertedMetadata[0] as Metadata;
|
||||
}
|
||||
|
||||
static async get({ db, id }: { db: DrizzleCli; id: string }) {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -41,7 +41,6 @@ const parseFreeTrials = ({
|
||||
return product;
|
||||
};
|
||||
|
||||
// biome-ignore lint/complexity/noStaticOnlyClass: no thanks m8
|
||||
export class ProductService {
|
||||
static async getByFeature({
|
||||
db,
|
||||
|
||||
@@ -56,7 +56,7 @@ export const checkForMisingBalance = async ({
|
||||
const feature = cusEnt.entitlement.feature;
|
||||
|
||||
// 1. If unlimited or boolean feature, skip
|
||||
if (isUnlimitedCusEnt({ cusEnt }) || isBooleanCusEnt({ cusEnt })) continue;
|
||||
if (isUnlimitedCusEnt(cusEnt) || isBooleanCusEnt({ cusEnt })) continue;
|
||||
|
||||
if (isContUseFeature({ feature })) continue;
|
||||
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import { beforeAll, describe, expect, test } from "bun:test";
|
||||
import { type ApiCustomer, ApiVersion } from "@autumn/shared";
|
||||
import {
|
||||
type ApiCustomer,
|
||||
ApiVersion,
|
||||
findPriceByFeatureId,
|
||||
type Price,
|
||||
priceToLineAmount,
|
||||
} from "@autumn/shared";
|
||||
import { expectLatestInvoiceCorrect } from "@tests/billing/utils/expectLatestInvoiceCorrect";
|
||||
import { TestFeature } from "@tests/setup/v2Features.js";
|
||||
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
|
||||
import chalk from "chalk";
|
||||
import { AutumnInt } from "@/external/autumn/autumnCli.js";
|
||||
import { ProductService } from "@/internal/products/ProductService";
|
||||
import { constructPrepaidItem } from "@/utils/scriptUtils/constructItem.js";
|
||||
import { constructRawProduct } from "@/utils/scriptUtils/createTestProducts.js";
|
||||
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
|
||||
@@ -30,17 +38,19 @@ describe(`${chalk.yellowBright("subscription-update: basic quantity updates")}`,
|
||||
const customerId = "sub-update-basic";
|
||||
const autumnV1 = new AutumnInt({ version: ApiVersion.V1_2 });
|
||||
|
||||
const prepaidProduct = constructRawProduct({
|
||||
id: "prepaid_messages",
|
||||
items: [
|
||||
constructPrepaidItem({
|
||||
const prepaidMessages = constructPrepaidItem({
|
||||
featureId: TestFeature.Messages,
|
||||
billingUnits,
|
||||
price: pricePerUnit,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const prepaidProduct = constructRawProduct({
|
||||
id: "prepaid_messages",
|
||||
items: [prepaidMessages],
|
||||
});
|
||||
|
||||
let prepaidMessagesPrice: Price | undefined;
|
||||
|
||||
beforeAll(async () => {
|
||||
await initCustomerV3({
|
||||
ctx,
|
||||
@@ -55,6 +65,18 @@ describe(`${chalk.yellowBright("subscription-update: basic quantity updates")}`,
|
||||
prefix: customerId,
|
||||
});
|
||||
|
||||
const fullProduct = await ProductService.getFull({
|
||||
db: ctx.db,
|
||||
idOrInternalId: prepaidProduct.id,
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
});
|
||||
|
||||
prepaidMessagesPrice = findPriceByFeatureId({
|
||||
prices: fullProduct.prices,
|
||||
featureId: TestFeature.Messages,
|
||||
});
|
||||
|
||||
await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
product_id: prepaidProduct.id,
|
||||
@@ -85,6 +107,16 @@ describe(`${chalk.yellowBright("subscription-update: basic quantity updates")}`,
|
||||
// Should have 240 messages (20 units × 12 billing_units)
|
||||
expect(balance?.purchased_balance).toBe(240);
|
||||
expect(balance?.current_balance).toBe(240);
|
||||
|
||||
// Expect invoices to be created
|
||||
expectLatestInvoiceCorrect({
|
||||
customer,
|
||||
productId: prepaidProduct.id,
|
||||
amount: priceToLineAmount({
|
||||
price: prepaidMessagesPrice!,
|
||||
overage: 10 * billingUnits,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
test("should downgrade quantity from 20 to 5 units", async () => {
|
||||
@@ -100,15 +132,29 @@ describe(`${chalk.yellowBright("subscription-update: basic quantity updates")}`,
|
||||
});
|
||||
|
||||
const customer = await autumnV1.customers.get<ApiCustomer>(customerId);
|
||||
|
||||
const balance = customer.balances?.[TestFeature.Messages];
|
||||
|
||||
// Should have 60 messages (5 units × 12 billing_units)
|
||||
expect(balance?.purchased_balance).toBe(60);
|
||||
expect(balance?.current_balance).toBe(60);
|
||||
|
||||
// Expect credit invoice for downgrade (20 -> 5 = -15 units)
|
||||
const expectedAmount = priceToLineAmount({
|
||||
price: prepaidMessagesPrice!,
|
||||
overage: -15 * billingUnits,
|
||||
});
|
||||
|
||||
expectLatestInvoiceCorrect({
|
||||
customer,
|
||||
productId: prepaidProduct.id,
|
||||
amount: expectedAmount,
|
||||
});
|
||||
});
|
||||
|
||||
test("should update to same quantity (no-op)", async () => {
|
||||
const beforeUpdate = await autumnV1.customers.get<ApiCustomer>(customerId);
|
||||
const beforeBalance = beforeUpdate.balances?.[TestFeature.Messages];
|
||||
const beforeInvoiceCount = beforeUpdate.invoices?.length ?? 0;
|
||||
|
||||
await autumnV1.subscriptionUpdate({
|
||||
customer_id: customerId,
|
||||
@@ -123,10 +169,12 @@ describe(`${chalk.yellowBright("subscription-update: basic quantity updates")}`,
|
||||
|
||||
const afterUpdate = await autumnV1.customers.get<ApiCustomer>(customerId);
|
||||
const afterBalance = afterUpdate.balances?.[TestFeature.Messages];
|
||||
const afterInvoiceCount = afterUpdate.invoices?.length ?? 0;
|
||||
|
||||
expect(afterBalance?.purchased_balance).toBe(
|
||||
beforeBalance?.purchased_balance,
|
||||
);
|
||||
expect(afterBalance?.current_balance).toBe(beforeBalance?.current_balance);
|
||||
|
||||
// No-op should not create a new invoice
|
||||
expect(afterInvoiceCount).toBe(beforeInvoiceCount);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -134,22 +182,28 @@ describe(`${chalk.yellowBright("subscription-update: multiple features")}`, () =
|
||||
const customerId = "sub-update-multi";
|
||||
const autumnV1 = new AutumnInt({ version: ApiVersion.V1_2 });
|
||||
|
||||
const messagesBillingUnits = 10;
|
||||
const wordsBillingUnits = 100;
|
||||
|
||||
const multiFeatureProduct = constructRawProduct({
|
||||
id: "multi_feature_product",
|
||||
items: [
|
||||
constructPrepaidItem({
|
||||
featureId: TestFeature.Messages,
|
||||
billingUnits: 10,
|
||||
billingUnits: messagesBillingUnits,
|
||||
price: 5,
|
||||
}),
|
||||
constructPrepaidItem({
|
||||
featureId: TestFeature.Words,
|
||||
billingUnits: 100,
|
||||
billingUnits: wordsBillingUnits,
|
||||
price: 10,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
let messagesPrice: Price | undefined;
|
||||
let wordsPrice: Price | undefined;
|
||||
|
||||
beforeAll(async () => {
|
||||
await initCustomerV3({
|
||||
ctx,
|
||||
@@ -164,17 +218,34 @@ describe(`${chalk.yellowBright("subscription-update: multiple features")}`, () =
|
||||
prefix: customerId,
|
||||
});
|
||||
|
||||
const fullProduct = await ProductService.getFull({
|
||||
db: ctx.db,
|
||||
idOrInternalId: multiFeatureProduct.id,
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
});
|
||||
|
||||
messagesPrice = findPriceByFeatureId({
|
||||
prices: fullProduct.prices,
|
||||
featureId: TestFeature.Messages,
|
||||
});
|
||||
|
||||
wordsPrice = findPriceByFeatureId({
|
||||
prices: fullProduct.prices,
|
||||
featureId: TestFeature.Words,
|
||||
});
|
||||
|
||||
await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
product_id: multiFeatureProduct.id,
|
||||
options: [
|
||||
{
|
||||
feature_id: TestFeature.Messages,
|
||||
quantity: 5 * 10, // 50 messages
|
||||
quantity: 5 * messagesBillingUnits, // 50 messages
|
||||
},
|
||||
{
|
||||
feature_id: TestFeature.Words,
|
||||
quantity: 2 * 100, // 200 words
|
||||
quantity: 2 * wordsBillingUnits, // 200 words
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -187,11 +258,11 @@ describe(`${chalk.yellowBright("subscription-update: multiple features")}`, () =
|
||||
options: [
|
||||
{
|
||||
feature_id: TestFeature.Messages,
|
||||
quantity: 10 * 10, // 100 messages
|
||||
quantity: 10 * messagesBillingUnits, // 100 messages
|
||||
},
|
||||
{
|
||||
feature_id: TestFeature.Words,
|
||||
quantity: 5 * 100, // 500 words
|
||||
quantity: 5 * wordsBillingUnits, // 500 words
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -203,6 +274,23 @@ describe(`${chalk.yellowBright("subscription-update: multiple features")}`, () =
|
||||
|
||||
const wordsBalance = customer.balances?.[TestFeature.Words];
|
||||
expect(wordsBalance?.purchased_balance).toBe(500);
|
||||
|
||||
// Invoice total: Messages (5->10 units = +5) + Words (2->5 units = +3)
|
||||
const expectedAmount =
|
||||
priceToLineAmount({
|
||||
price: messagesPrice!,
|
||||
overage: 5 * messagesBillingUnits,
|
||||
}) +
|
||||
priceToLineAmount({
|
||||
price: wordsPrice!,
|
||||
overage: 3 * wordsBillingUnits,
|
||||
});
|
||||
|
||||
expectLatestInvoiceCorrect({
|
||||
customer,
|
||||
productId: multiFeatureProduct.id,
|
||||
amount: expectedAmount,
|
||||
});
|
||||
});
|
||||
|
||||
test("should update only one feature while keeping the other unchanged", async () => {
|
||||
@@ -212,11 +300,11 @@ describe(`${chalk.yellowBright("subscription-update: multiple features")}`, () =
|
||||
options: [
|
||||
{
|
||||
feature_id: TestFeature.Messages,
|
||||
quantity: 15 * 10, // 150 messages (changed)
|
||||
quantity: 15 * messagesBillingUnits, // 150 messages (changed)
|
||||
},
|
||||
{
|
||||
feature_id: TestFeature.Words,
|
||||
quantity: 5 * 100, // 500 words (unchanged)
|
||||
quantity: 5 * wordsBillingUnits, // 500 words (unchanged)
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -228,5 +316,25 @@ describe(`${chalk.yellowBright("subscription-update: multiple features")}`, () =
|
||||
|
||||
const wordsBalance = customer.balances?.[TestFeature.Words];
|
||||
expect(wordsBalance?.purchased_balance).toBe(500);
|
||||
|
||||
// Invoice total: Messages (10->15 units = +5), Words unchanged
|
||||
const expectedAmount = priceToLineAmount({
|
||||
price: messagesPrice!,
|
||||
overage: 5 * messagesBillingUnits,
|
||||
});
|
||||
|
||||
expectLatestInvoiceCorrect({
|
||||
customer,
|
||||
productId: multiFeatureProduct.id,
|
||||
amount: expectedAmount,
|
||||
});
|
||||
|
||||
// Verify Stripe invoice has only 2 line items
|
||||
const stripeInvoices = await ctx.stripeCli.invoices.list({
|
||||
customer: customer.stripe_id!,
|
||||
});
|
||||
|
||||
const latestInvoice = stripeInvoices.data[0];
|
||||
expect(latestInvoice.lines.data.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
17
server/tests/billing/utils/expectLatestInvoiceCorrect.ts
Normal file
17
server/tests/billing/utils/expectLatestInvoiceCorrect.ts
Normal 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);
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { SubscriptionUpdateV0Params } from "@autumn/shared";
|
||||
import type { UpdateSubscriptionV0Params } from "@autumn/shared";
|
||||
import { createMockCtx } from "@tests/utils/mockUtils/contextMocks";
|
||||
import { createMockCustomerProduct } from "@tests/utils/mockUtils/cusProductMocks";
|
||||
import { createMockFeature } from "@tests/utils/mockUtils/featureMocks";
|
||||
@@ -41,7 +41,7 @@ describe(
|
||||
customerPrices: [createMockCustomerPrice({ price })],
|
||||
});
|
||||
|
||||
const params: SubscriptionUpdateV0Params = {
|
||||
const params: UpdateSubscriptionV0Params = {
|
||||
customer_id: "cus_test",
|
||||
product_id: "prod_test",
|
||||
// No options provided
|
||||
@@ -75,7 +75,7 @@ describe(
|
||||
const fullProduct = createMockFullProduct({ prices: [price] });
|
||||
const cusProduct = createMockCustomerProduct({ options: [] });
|
||||
|
||||
const params: SubscriptionUpdateV0Params = {
|
||||
const params: UpdateSubscriptionV0Params = {
|
||||
customer_id: "cus_test",
|
||||
product_id: "prod_test",
|
||||
options: [{ feature_id: "credits", quantity: 50 }],
|
||||
@@ -117,7 +117,7 @@ describe(
|
||||
],
|
||||
});
|
||||
|
||||
const params: SubscriptionUpdateV0Params = {
|
||||
const params: UpdateSubscriptionV0Params = {
|
||||
customer_id: "cus_test",
|
||||
product_id: "prod_test",
|
||||
options: [{ feature_id: "credits", quantity: 200 }],
|
||||
@@ -195,7 +195,7 @@ describe(
|
||||
],
|
||||
});
|
||||
|
||||
const params: SubscriptionUpdateV0Params = {
|
||||
const params: UpdateSubscriptionV0Params = {
|
||||
customer_id: "cus_test",
|
||||
product_id: "prod_test",
|
||||
options: [{ feature_id: "seats", quantity: 10 }], // Only updating seats
|
||||
@@ -240,7 +240,7 @@ describe(
|
||||
const fullProduct = createMockFullProduct({ prices: [price] });
|
||||
const cusProduct = createMockCustomerProduct({ options: [] });
|
||||
|
||||
const params: SubscriptionUpdateV0Params = {
|
||||
const params: UpdateSubscriptionV0Params = {
|
||||
customer_id: "cus_test",
|
||||
product_id: "prod_test",
|
||||
options: [{ feature_id: "credits", quantity: 150 }], // Should round up to 200
|
||||
@@ -276,7 +276,7 @@ describe(
|
||||
const fullProduct = createMockFullProduct({ prices: [price] });
|
||||
const cusProduct = createMockCustomerProduct({ options: [] });
|
||||
|
||||
const params: SubscriptionUpdateV0Params = {
|
||||
const params: UpdateSubscriptionV0Params = {
|
||||
customer_id: "cus_test",
|
||||
product_id: "prod_test",
|
||||
options: [{ feature_id: "credits", quantity: 200 }], // Exact multiple
|
||||
@@ -311,7 +311,7 @@ describe(
|
||||
const fullProduct = createMockFullProduct({ prices: [price] });
|
||||
const cusProduct = createMockCustomerProduct({ options: [] });
|
||||
|
||||
const params: SubscriptionUpdateV0Params = {
|
||||
const params: UpdateSubscriptionV0Params = {
|
||||
customer_id: "cus_test",
|
||||
product_id: "prod_test",
|
||||
options: [{ feature_id: "credits", quantity: 1 }], // Should round to 1000
|
||||
@@ -367,7 +367,7 @@ describe(
|
||||
customerPrices: [createMockCustomerPrice({ price: oldPrice })],
|
||||
});
|
||||
|
||||
const params: SubscriptionUpdateV0Params = {
|
||||
const params: UpdateSubscriptionV0Params = {
|
||||
customer_id: "cus_test",
|
||||
product_id: "prod_test",
|
||||
// No options - should inherit from current
|
||||
@@ -424,7 +424,7 @@ describe(
|
||||
customerPrices: [createMockCustomerPrice({ price: oldPrice })],
|
||||
});
|
||||
|
||||
const params: SubscriptionUpdateV0Params = {
|
||||
const params: UpdateSubscriptionV0Params = {
|
||||
customer_id: "cus_test",
|
||||
product_id: "prod_test",
|
||||
};
|
||||
@@ -472,7 +472,7 @@ describe(
|
||||
// No customerPrices - can't interpret stored quantity
|
||||
});
|
||||
|
||||
const params: SubscriptionUpdateV0Params = {
|
||||
const params: UpdateSubscriptionV0Params = {
|
||||
customer_id: "cus_test",
|
||||
product_id: "prod_test",
|
||||
};
|
||||
@@ -511,7 +511,7 @@ describe(
|
||||
|
||||
const cusProduct = createMockCustomerProduct({ options: [] });
|
||||
|
||||
const params: SubscriptionUpdateV0Params = {
|
||||
const params: UpdateSubscriptionV0Params = {
|
||||
customer_id: "cus_test",
|
||||
product_id: "prod_test",
|
||||
options: [{ feature_id: "credits", quantity: 50 }],
|
||||
@@ -537,7 +537,7 @@ describe(
|
||||
const fullProduct = createMockFullProduct({ prices: [] });
|
||||
const cusProduct = createMockCustomerProduct({ options: [] });
|
||||
|
||||
const params: SubscriptionUpdateV0Params = {
|
||||
const params: UpdateSubscriptionV0Params = {
|
||||
customer_id: "cus_test",
|
||||
product_id: "prod_test",
|
||||
};
|
||||
@@ -563,7 +563,7 @@ describe(
|
||||
const fullProduct = createMockFullProduct({ prices: [price] });
|
||||
const cusProduct = createMockCustomerProduct({ options: [] });
|
||||
|
||||
const params: SubscriptionUpdateV0Params = {
|
||||
const params: UpdateSubscriptionV0Params = {
|
||||
customer_id: "cus_test",
|
||||
product_id: "prod_test",
|
||||
};
|
||||
@@ -595,7 +595,7 @@ describe(
|
||||
const fullProduct = createMockFullProduct({ prices: [price] });
|
||||
const cusProduct = createMockCustomerProduct({ options: [] }); // No current options
|
||||
|
||||
const params: SubscriptionUpdateV0Params = {
|
||||
const params: UpdateSubscriptionV0Params = {
|
||||
customer_id: "cus_test",
|
||||
product_id: "prod_test",
|
||||
// No options in params either
|
||||
@@ -637,7 +637,7 @@ describe(
|
||||
customerPrices: [createMockCustomerPrice({ price })],
|
||||
});
|
||||
|
||||
const params: SubscriptionUpdateV0Params = {
|
||||
const params: UpdateSubscriptionV0Params = {
|
||||
customer_id: "cus_test",
|
||||
product_id: "prod_test",
|
||||
options: [], // Explicitly empty
|
||||
@@ -680,7 +680,7 @@ describe(
|
||||
customerPrices: [createMockCustomerPrice({ price })],
|
||||
});
|
||||
|
||||
const params: SubscriptionUpdateV0Params = {
|
||||
const params: UpdateSubscriptionV0Params = {
|
||||
customer_id: "cus_test",
|
||||
product_id: "prod_test",
|
||||
options: [{ feature_id: "credits", quantity: 0 }],
|
||||
@@ -726,7 +726,7 @@ describe(
|
||||
customerPrices: [createMockCustomerPrice({ price })],
|
||||
});
|
||||
|
||||
const params: SubscriptionUpdateV0Params = {
|
||||
const params: UpdateSubscriptionV0Params = {
|
||||
customer_id: "cus_test",
|
||||
product_id: "prod_test",
|
||||
};
|
||||
|
||||
@@ -4,8 +4,8 @@ import {
|
||||
AffectedResource,
|
||||
defineVersionChange,
|
||||
} from "../../../versionUtils/versionChangeUtils/VersionChange";
|
||||
import { SubscriptionUpdateV0ParamsSchema } from "../subscriptionUpdateV0Params";
|
||||
import { SubscriptionUpdateV1ParamsSchema } from "../subscriptionUpdateV1Params";
|
||||
import { UpdateSubscriptionV0ParamsSchema } from "../UpdateSubscriptionV0Params";
|
||||
|
||||
/**
|
||||
* V2_0_SubscriptionUpdateChange: Transforms subscription update params from V2.0 to V2.1 format
|
||||
@@ -18,7 +18,7 @@ import { SubscriptionUpdateV1ParamsSchema } from "../subscriptionUpdateV1Params"
|
||||
* 2. Removed fields: `entity_id`, `customer_data`, `entity_data`, `options`, invoice settings
|
||||
* 3. Added field: `plan_override` for customizations
|
||||
*
|
||||
* Input: SubscriptionUpdateV0Params (V2.0 format)
|
||||
* Input: UpdateSubscriptionV0Params (V2.0 format)
|
||||
* Output: SubscriptionUpdateV1Params (V2.1 format)
|
||||
*/
|
||||
|
||||
@@ -31,15 +31,15 @@ export const V2_0_SubscriptionUpdateChange = defineVersionChange({
|
||||
],
|
||||
affectedResources: [AffectedResource.ApiSubscriptionUpdate],
|
||||
newSchema: SubscriptionUpdateV1ParamsSchema,
|
||||
oldSchema: SubscriptionUpdateV0ParamsSchema,
|
||||
oldSchema: UpdateSubscriptionV0ParamsSchema,
|
||||
affectsRequest: true,
|
||||
affectsResponse: false,
|
||||
|
||||
// Request: V0 (SubscriptionUpdateV0Params) → V1 (SubscriptionUpdateV1Params)
|
||||
// Request: V0 (UpdateSubscriptionV0Params) → V1 (SubscriptionUpdateV1Params)
|
||||
transformRequest: ({
|
||||
input,
|
||||
}: {
|
||||
input: z.infer<typeof SubscriptionUpdateV0ParamsSchema>;
|
||||
input: z.infer<typeof UpdateSubscriptionV0ParamsSchema>;
|
||||
}): z.infer<typeof SubscriptionUpdateV1ParamsSchema> => {
|
||||
const planId = input.product_id;
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { ProductItemSchema } from "../../../models/productV2Models/productItemMo
|
||||
import { CustomerDataSchema } from "../../common/customerData";
|
||||
import { EntityDataSchema } from "../../models";
|
||||
|
||||
export const ExtSubscriptionUpdateV0ParamsSchema = z.object({
|
||||
export const ExtUpdateSubscriptionV0ParamsSchema = z.object({
|
||||
// Customer / Entity Info
|
||||
customer_id: z.string(),
|
||||
product_id: z.string().nullish(),
|
||||
@@ -33,17 +33,17 @@ export const ExtSubscriptionUpdateV0ParamsSchema = z.object({
|
||||
prorate_billing: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const SubscriptionUpdateV0ParamsSchema =
|
||||
ExtSubscriptionUpdateV0ParamsSchema.extend({
|
||||
export const UpdateSubscriptionV0ParamsSchema =
|
||||
ExtUpdateSubscriptionV0ParamsSchema.extend({
|
||||
customer_product_id: z.string().optional(),
|
||||
});
|
||||
|
||||
export type ExtSubscriptionUpdateV0Params = z.infer<
|
||||
typeof ExtSubscriptionUpdateV0ParamsSchema
|
||||
export type ExtUpdateSubscriptionV0Params = z.infer<
|
||||
typeof ExtUpdateSubscriptionV0ParamsSchema
|
||||
>;
|
||||
|
||||
export type SubscriptionUpdateV0Params = z.infer<
|
||||
typeof SubscriptionUpdateV0ParamsSchema
|
||||
export type UpdateSubscriptionV0Params = z.infer<
|
||||
typeof UpdateSubscriptionV0ParamsSchema
|
||||
>;
|
||||
|
||||
// keep_existing_plan: true, //disable_plan_switch
|
||||
@@ -76,9 +76,9 @@ export * from "./billing/checkout/checkoutParamsV1.js";
|
||||
export * from "./billing/checkout/prevVersions/checkoutParamsV0.js";
|
||||
export * from "./billing/checkout/prevVersions/checkoutParamsV0.js";
|
||||
export * from "./billing/checkout/prevVersions/checkoutResponseV0.js";
|
||||
export * from "./billing/subscriptionUpdate/subscriptionUpdateV0Params.js";
|
||||
export * from "./billing/subscriptionUpdate/subscriptionUpdateV0Params.js";
|
||||
export * from "./billing/subscriptionUpdate/subscriptionUpdateV1Params.js";
|
||||
export * from "./billing/subscriptionUpdate/updateSubscriptionV0Params.js";
|
||||
|
||||
export * from "./common/customerData.js";
|
||||
export * from "./common/entityData.js";
|
||||
export * from "./common/pagePaginationSchemas.js";
|
||||
|
||||
@@ -20,6 +20,9 @@ export const LineItemSchema = z
|
||||
|
||||
stripePriceId: z.string().optional(),
|
||||
stripeProductId: z.string().optional(),
|
||||
|
||||
// Optional - for testing
|
||||
chargeImmediately: z.boolean().default(true),
|
||||
})
|
||||
.transform((data) => {
|
||||
return {
|
||||
|
||||
@@ -7,6 +7,9 @@ export enum MetadataType {
|
||||
InvoiceCheckout = "invoice_checkout",
|
||||
CheckoutSessionCompleted = "checkout_session_completed",
|
||||
DeferredAutumnBillingPlan = "deferred_autumn_billing_plan",
|
||||
|
||||
InvoiceActionRequiredV2 = "invoice_action_required_v2",
|
||||
InvoiceCheckoutV2 = "invoice_checkout_v2",
|
||||
}
|
||||
|
||||
export const metadata = pgTable("metadata", {
|
||||
|
||||
@@ -15,6 +15,7 @@ export const buildLineItem = ({
|
||||
stripePriceId,
|
||||
stripeProductId,
|
||||
shouldProrate = true,
|
||||
chargeImmediately = true,
|
||||
}: {
|
||||
context: LineItemContext;
|
||||
amount: number;
|
||||
@@ -22,6 +23,7 @@ export const buildLineItem = ({
|
||||
stripePriceId?: string;
|
||||
stripeProductId?: string;
|
||||
shouldProrate?: boolean;
|
||||
chargeImmediately?: boolean;
|
||||
}): LineItem => {
|
||||
// 1. Apply proration if needed
|
||||
if (shouldProrate && context.billingPeriod) {
|
||||
@@ -44,5 +46,6 @@ export const buildLineItem = ({
|
||||
context,
|
||||
stripePriceId,
|
||||
stripeProductId,
|
||||
chargeImmediately,
|
||||
} satisfies LineItemCreate);
|
||||
};
|
||||
|
||||
@@ -17,9 +17,13 @@ import { buildLineItem } from "./buildLineItem";
|
||||
export const usagePriceToLineItem = ({
|
||||
cusEnt,
|
||||
context,
|
||||
shouldProrateOverride,
|
||||
chargeImmediatelyOverride,
|
||||
}: {
|
||||
cusEnt: FullCusEntWithFullCusProduct;
|
||||
context: LineItemContext;
|
||||
shouldProrateOverride?: boolean;
|
||||
chargeImmediatelyOverride?: boolean;
|
||||
}) => {
|
||||
const cusPrice = cusEntToCusPrice({ cusEnt });
|
||||
const { feature } = context;
|
||||
@@ -75,8 +79,8 @@ export const usagePriceToLineItem = ({
|
||||
// 5. Get stripe price / product IDs
|
||||
const { stripePriceId, stripeProductId } = cusEntToStripeIds({ cusEnt });
|
||||
|
||||
// 6. Should prorate: don't if consumable price
|
||||
const shouldProrate = !isConsumablePrice(price);
|
||||
// 6. Should prorate: don't if consumable price (unless override provided)
|
||||
const shouldProrate = shouldProrateOverride ?? !isConsumablePrice(price);
|
||||
|
||||
return buildLineItem({
|
||||
context,
|
||||
@@ -87,5 +91,6 @@ export const usagePriceToLineItem = ({
|
||||
stripeProductId,
|
||||
|
||||
shouldProrate,
|
||||
chargeImmediately: chargeImmediatelyOverride,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -13,8 +13,11 @@ export const tiersToLineAmount = ({
|
||||
overage: number;
|
||||
billingUnits?: number;
|
||||
}): number => {
|
||||
const isNegative = overage < 0;
|
||||
const absoluteOverage = Math.abs(overage);
|
||||
|
||||
const roundedOverage = roundUsageToNearestBillingUnit({
|
||||
usage: overage,
|
||||
usage: absoluteOverage,
|
||||
billingUnits,
|
||||
});
|
||||
|
||||
@@ -47,5 +50,6 @@ export const tiersToLineAmount = ({
|
||||
}
|
||||
}
|
||||
|
||||
return amount.toDecimalPlaces(10).toNumber();
|
||||
const finalAmount = amount.toDecimalPlaces(10).toNumber();
|
||||
return isNegative ? -finalAmount : finalAmount;
|
||||
};
|
||||
|
||||
@@ -54,18 +54,33 @@ export const shouldProrateDowngradeNow = ({
|
||||
|
||||
/**
|
||||
* Determines if a quantity upgrade or downgrade should be prorated.
|
||||
* @param onIncrease - The proration behavior configuration for the quantity increase.
|
||||
* @returns True if a quantity upgrade or downgrade should be prorated, false otherwise.
|
||||
* @param prorationConfig - The proration behavior configuration.
|
||||
* @returns True if proration should be applied, false otherwise.
|
||||
*/
|
||||
export const shouldProrate = (onIncrease?: OnIncrease | OnDecrease) => {
|
||||
if (!onIncrease) {
|
||||
export const shouldProrate = (prorationConfig?: OnIncrease | OnDecrease) => {
|
||||
if (!prorationConfig) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
onIncrease === OnIncrease.ProrateNextCycle ||
|
||||
onIncrease === OnIncrease.ProrateImmediately ||
|
||||
onIncrease === OnDecrease.ProrateImmediately ||
|
||||
onIncrease === OnDecrease.ProrateNextCycle
|
||||
prorationConfig === OnIncrease.ProrateNextCycle ||
|
||||
prorationConfig === OnIncrease.ProrateImmediately ||
|
||||
prorationConfig === OnDecrease.ProrateImmediately ||
|
||||
prorationConfig === OnDecrease.ProrateNextCycle
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Determines if line items should be skipped entirely (no charge or refund).
|
||||
* @param prorationConfig - The proration behavior configuration.
|
||||
* @returns True if line items should be skipped, false otherwise.
|
||||
*/
|
||||
export const shouldSkipLineItems = (
|
||||
prorationConfig: OnIncrease | OnDecrease,
|
||||
) => {
|
||||
return (
|
||||
prorationConfig === OnDecrease.NoProrations ||
|
||||
prorationConfig === OnDecrease.None ||
|
||||
prorationConfig === OnIncrease.BillNextCycle
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { CusProductStatus, type FullCustomer } from "../../index.js";
|
||||
import {
|
||||
type FullCustomer,
|
||||
filterCustomerProductsByActiveStatuses,
|
||||
} from "../../index.js";
|
||||
import type { Entity } from "../../models/cusModels/entityModels/entityModels.js";
|
||||
import type { FullCustomerEntitlement } from "../../models/cusProductModels/cusEntModels/cusEntModels.js";
|
||||
import type { FullCusProduct } from "../../models/cusProductModels/cusProductModels.js";
|
||||
@@ -96,16 +99,6 @@ export const filterOutEntitiesFromCusProducts = ({
|
||||
return finalCusProducts;
|
||||
};
|
||||
|
||||
export const getActiveCusProducts = ({
|
||||
customer,
|
||||
}: {
|
||||
customer: FullCustomer;
|
||||
}): FullCusProduct[] => {
|
||||
return customer.customer_products.filter(
|
||||
(p: FullCusProduct) => p.status === CusProductStatus.Active,
|
||||
);
|
||||
};
|
||||
|
||||
export const isProductAlreadyEnabled = ({
|
||||
productId,
|
||||
customer,
|
||||
@@ -115,7 +108,9 @@ export const isProductAlreadyEnabled = ({
|
||||
customer: FullCustomer;
|
||||
entityId?: string;
|
||||
}) => {
|
||||
return getActiveCusProducts({ customer }).some((cp: FullCusProduct) => {
|
||||
return filterCustomerProductsByActiveStatuses({
|
||||
customerProducts: customer.customer_products,
|
||||
}).some((cp: FullCusProduct) => {
|
||||
// Check if product matches and is not an add-on
|
||||
if (cp.product_id !== productId || cp.product.is_add_on) {
|
||||
return false;
|
||||
@@ -142,17 +137,3 @@ export const isProductAlreadyEnabled = ({
|
||||
return false;
|
||||
});
|
||||
};
|
||||
|
||||
export const filterCusProductsBySubId = ({
|
||||
cusProducts,
|
||||
subId,
|
||||
}: {
|
||||
cusProducts: FullCusProduct[];
|
||||
subId?: string;
|
||||
}): FullCusProduct[] => {
|
||||
if (!subId) return [];
|
||||
|
||||
return cusProducts.filter((cp: FullCusProduct) =>
|
||||
cp.subscription_ids?.includes(subId),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
};
|
||||
@@ -39,7 +39,9 @@ export * from "./cusProductUtils/cusProductConstants.js";
|
||||
export * from "./cusProductUtils/cusProductUtils.js";
|
||||
export * from "./cusProductUtils/featureOptionUtils/findFeatureOptions.js";
|
||||
export * from "./cusProductUtils/filterCusProductUtils.js";
|
||||
export * from "./cusProductUtils/filterCusProductUtils.js";
|
||||
export * from "./cusProductUtils/filterCustomerProducts/filterCustomerProductsByActiveStatuses.js";
|
||||
export * from "./cusProductUtils/filterCustomerProducts/filterCustomerProductsByStripeSubscriptionId.js";
|
||||
|
||||
export * from "./cusProductUtils/getCusProductFromCustomer.js";
|
||||
export * from "./cusProductUtils/productIdToCusProduct.js";
|
||||
// Cus utils
|
||||
@@ -67,10 +69,7 @@ export * from "./productUtils/entUtils/formatEntUtils.js";
|
||||
export * from "./productUtils/freeTrialUtils/initFreeTrial.js";
|
||||
export * from "./productUtils/freeTrialUtils.js";
|
||||
export * from "./productUtils/isProductUpgrade.js";
|
||||
export * from "./productUtils/priceUtils/classifyPriceUtils.js";
|
||||
export * from "./productUtils/priceUtils/convertAmountUtils.js";
|
||||
export * from "./productUtils/priceUtils/convertPriceUtils.js";
|
||||
export * from "./productUtils/priceUtils/formatPriceUtils.js";
|
||||
export * from "./productUtils/priceUtils/index.js";
|
||||
export * from "./productUtils/priceUtils.js";
|
||||
export * from "./productV2Utils/mapToProductV2.js";
|
||||
export * from "./productV2Utils/productItemUtils/classifyItemUtils.js";
|
||||
|
||||
@@ -6,7 +6,11 @@ import {
|
||||
OnDecrease,
|
||||
OnIncrease,
|
||||
} from "@models/productV2Models/productItemModels/productItemEnums";
|
||||
import { shouldBillNow, shouldProrate } from "@utils/billingUtils";
|
||||
import {
|
||||
shouldBillNow,
|
||||
shouldProrate,
|
||||
shouldSkipLineItems,
|
||||
} from "@utils/billingUtils";
|
||||
import { priceToEnt } from "@utils/productUtils/convertProductUtils";
|
||||
|
||||
export const priceToFeature = ({
|
||||
@@ -43,20 +47,17 @@ export const priceToProrationConfig = ({
|
||||
}): {
|
||||
prorationBehaviorConfig: OnIncrease | OnDecrease;
|
||||
shouldApplyProration: boolean;
|
||||
shouldFinalizeInvoiceImmediately: boolean;
|
||||
chargeImmediately: boolean;
|
||||
skipLineItems: boolean;
|
||||
} => {
|
||||
const prorationBehaviorConfig = isUpgrade
|
||||
? (price.proration_config?.on_increase ?? OnIncrease.ProrateImmediately)
|
||||
: (price.proration_config?.on_decrease ?? OnDecrease.ProrateImmediately);
|
||||
|
||||
const shouldApplyProration = shouldProrate(prorationBehaviorConfig);
|
||||
const shouldFinalizeInvoiceImmediately = shouldBillNow(
|
||||
prorationBehaviorConfig,
|
||||
);
|
||||
|
||||
return {
|
||||
prorationBehaviorConfig,
|
||||
shouldApplyProration,
|
||||
shouldFinalizeInvoiceImmediately,
|
||||
shouldApplyProration: shouldProrate(prorationBehaviorConfig),
|
||||
chargeImmediately: shouldBillNow(prorationBehaviorConfig),
|
||||
skipLineItems: shouldSkipLineItems(prorationBehaviorConfig),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
5
shared/utils/productUtils/priceUtils/index.ts
Normal file
5
shared/utils/productUtils/priceUtils/index.ts
Normal 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";
|
||||
@@ -7,23 +7,32 @@ import {
|
||||
getProductItemDisplay,
|
||||
type ProductItem,
|
||||
type ProductV2,
|
||||
type SubscriptionUpdateV0Params,
|
||||
stripeToAtmnAmount,
|
||||
type UpdateSubscriptionV0Params,
|
||||
} from "@autumn/shared";
|
||||
import { Check, Copy, PencilSimple } from "@phosphor-icons/react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { DateInputUnix } from "@/components/general/DateInputUnix";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Button } from "@/components/v2/buttons/Button";
|
||||
import { IconButton } from "@/components/v2/buttons/IconButton";
|
||||
import { SheetHeader } from "@/components/v2/sheets/InlineSheet";
|
||||
import { useOrgStripeQuery } from "@/hooks/queries/useOrgStripeQuery";
|
||||
import { usePrepaidItems } from "@/hooks/stores/useProductStore";
|
||||
import { useSheetStore } from "@/hooks/stores/useSheetStore";
|
||||
import { useSubscriptionById } from "@/hooks/stores/useSubscriptionStore";
|
||||
import { useAxiosInstance } from "@/services/useAxiosInstance";
|
||||
import { useEnv } from "@/utils/envUtils";
|
||||
import { formatUnixToDateTime } from "@/utils/formatUtils/formatDateUtils";
|
||||
import { pushPage } from "@/utils/genUtils";
|
||||
import { getStripeInvoiceLink } from "@/utils/linkUtils";
|
||||
import { useCusQuery } from "@/views/customers/customer/hooks/useCusQuery";
|
||||
|
||||
/**
|
||||
@@ -41,6 +50,7 @@ interface PrepaidEditorProps {
|
||||
prepaidItems: Array<{
|
||||
feature_id?: string | null;
|
||||
feature?: { internal_id: string } | undefined;
|
||||
billing_units?: number | null;
|
||||
}>;
|
||||
prepaidOptions: Record<string, number>;
|
||||
onPrepaidChange: (featureId: string, quantity: number) => void;
|
||||
@@ -61,15 +71,18 @@ function PrepaidEditor({
|
||||
<div className="px-4 py-3 space-y-2">
|
||||
{prepaidItems.map((item) => {
|
||||
const featureId = item.feature_id ?? item.feature?.internal_id ?? "";
|
||||
const billingUnits = item.billing_units ?? 1;
|
||||
const inputId = `prepaid-${featureId}`;
|
||||
return (
|
||||
<div key={featureId} className="flex items-center gap-3">
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
className="text-sm text-t-secondary flex-1"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<label htmlFor={inputId} className="text-sm text-t-secondary">
|
||||
{featureId}
|
||||
</label>
|
||||
<span className="text-xs text-t-secondary ml-2">
|
||||
(billing_units: {billingUnits})
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
id={inputId}
|
||||
type="number"
|
||||
@@ -158,6 +171,14 @@ interface BillingPlanData {
|
||||
}>;
|
||||
};
|
||||
};
|
||||
invoiceItemsAction?: {
|
||||
createInvoiceItems?: Array<{
|
||||
description?: string;
|
||||
amount?: number;
|
||||
customer?: string;
|
||||
subscription?: string;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -626,10 +647,16 @@ function PreviewResult({ data, isLoading, error }: PreviewResultProps) {
|
||||
<h4 className="text-xs font-semibold text-purple-400 mb-1">
|
||||
🧾 Stripe Invoice Action
|
||||
</h4>
|
||||
|
||||
{/* Immediate Line Items (addLineParams) */}
|
||||
{billingPlan.stripe.invoiceAction.addLineParams?.lines &&
|
||||
billingPlan.stripe.invoiceAction.addLineParams.lines.length >
|
||||
0 ? (
|
||||
<div className="space-y-1">
|
||||
<div className="mb-2">
|
||||
<div className="text-xs text-t-secondary font-medium mb-1">
|
||||
Immediate charges:
|
||||
</div>
|
||||
<div className="space-y-1 pl-2 border-l border-purple-500/30">
|
||||
{billingPlan.stripe.invoiceAction.addLineParams.lines.map(
|
||||
(
|
||||
line: {
|
||||
@@ -664,9 +691,13 @@ function PreviewResult({ data, isLoading, error }: PreviewResultProps) {
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-t-secondary">No line items</div>
|
||||
<div className="text-xs text-t-secondary mb-2">
|
||||
No immediate line items
|
||||
</div>
|
||||
)}
|
||||
|
||||
<details className="mt-1">
|
||||
<summary className="text-xs cursor-pointer text-t-secondary hover:text-t-primary">
|
||||
View raw params
|
||||
@@ -678,10 +709,71 @@ function PreviewResult({ data, isLoading, error }: PreviewResultProps) {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Stripe Invoice Items Action - Deferred charges added to next cycle */}
|
||||
{billingPlan.stripe?.invoiceItemsAction?.createInvoiceItems &&
|
||||
billingPlan.stripe.invoiceItemsAction.createInvoiceItems.length >
|
||||
0 ? (
|
||||
<div className="px-4 py-3 border-l-2 border-l-amber-500">
|
||||
<h4 className="text-xs font-semibold text-amber-400 mb-1">
|
||||
⏱️ Stripe Invoice Items Action (Added to next cycle)
|
||||
</h4>
|
||||
<div className="space-y-1 pl-2 border-l border-amber-500/30">
|
||||
{billingPlan.stripe.invoiceItemsAction.createInvoiceItems.map(
|
||||
(
|
||||
item: {
|
||||
description?: string;
|
||||
amount?: number;
|
||||
customer?: string;
|
||||
subscription?: string;
|
||||
},
|
||||
index: number,
|
||||
) => {
|
||||
const amount = item.amount
|
||||
? stripeToAtmnAmount({
|
||||
amount: item.amount,
|
||||
currency: "usd",
|
||||
})
|
||||
: 0;
|
||||
return (
|
||||
<div key={index} className="flex justify-between text-xs">
|
||||
<span className="text-t-primary">
|
||||
{item.description || "Invoice item"}
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
amount >= 0 ? "text-amber-400" : "text-red-400"
|
||||
}
|
||||
>
|
||||
${amount.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-t-secondary mt-1 italic">
|
||||
These items will appear on the customer's next invoice
|
||||
</div>
|
||||
<details className="mt-1">
|
||||
<summary className="text-xs cursor-pointer text-t-secondary hover:text-t-primary">
|
||||
View raw params
|
||||
</summary>
|
||||
<pre className="text-xs bg-t-50 p-2 rounded mt-1 overflow-auto max-h-32">
|
||||
{JSON.stringify(
|
||||
billingPlan.stripe.invoiceItemsAction,
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
</pre>
|
||||
</details>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Empty Stripe section indicator */}
|
||||
{billingPlan.stripe &&
|
||||
!billingPlan.stripe.subscriptionAction &&
|
||||
!billingPlan.stripe.invoiceAction ? (
|
||||
!billingPlan.stripe.invoiceAction &&
|
||||
!billingPlan.stripe.invoiceItemsAction ? (
|
||||
<div className="px-4 py-2 text-xs text-t-secondary">
|
||||
No Stripe actions required
|
||||
</div>
|
||||
@@ -740,7 +832,7 @@ function useSubscriptionUpdatePreview({
|
||||
body,
|
||||
enabled,
|
||||
}: {
|
||||
body: SubscriptionUpdateV0Params | null;
|
||||
body: UpdateSubscriptionV0Params | null;
|
||||
enabled: boolean;
|
||||
}) {
|
||||
const axiosInstance = useAxiosInstance();
|
||||
@@ -780,17 +872,64 @@ function useSubscriptionUpdatePreview({
|
||||
};
|
||||
}
|
||||
|
||||
function useSubscriptionUpdate() {
|
||||
interface SubscriptionUpdateParams {
|
||||
body: UpdateSubscriptionV0Params;
|
||||
useInvoice?: boolean;
|
||||
enableProductImmediately?: boolean;
|
||||
}
|
||||
|
||||
function useSubscriptionUpdate({
|
||||
customerId,
|
||||
onInvoiceCreated,
|
||||
}: {
|
||||
customerId?: string;
|
||||
onInvoiceCreated?: (invoiceLink: string) => void;
|
||||
}) {
|
||||
const axiosInstance = useAxiosInstance();
|
||||
const queryClient = useQueryClient();
|
||||
const { closeSheet } = useSheetStore();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (body: SubscriptionUpdateV0Params) => {
|
||||
mutationFn: async ({
|
||||
body,
|
||||
useInvoice,
|
||||
enableProductImmediately,
|
||||
}: SubscriptionUpdateParams) => {
|
||||
const requestBody = {
|
||||
...body,
|
||||
invoice: useInvoice,
|
||||
enable_product_immediately: useInvoice
|
||||
? enableProductImmediately
|
||||
: undefined,
|
||||
finalize_invoice: useInvoice ? false : undefined,
|
||||
force_checkout:
|
||||
useInvoice && enableProductImmediately === false ? true : undefined,
|
||||
};
|
||||
|
||||
const response = await axiosInstance.post(
|
||||
"/v1/subscriptions/update",
|
||||
body,
|
||||
requestBody,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
if (data?.invoice) {
|
||||
onInvoiceCreated?.(data.invoice);
|
||||
toast.success("Invoice created successfully");
|
||||
} else if (data?.checkout_url) {
|
||||
toast.success("Redirecting to checkout...");
|
||||
window.open(data.checkout_url, "_blank");
|
||||
} else {
|
||||
toast.success("Subscription updated successfully");
|
||||
}
|
||||
closeSheet();
|
||||
if (customerId) {
|
||||
queryClient.invalidateQueries({ queryKey: ["customer", customerId] });
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Update failed:", error);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -808,6 +947,10 @@ function SheetContent({
|
||||
const customerId = customer?.id ?? customer?.internal_id;
|
||||
const entityId = cusProduct?.entity_id ?? undefined;
|
||||
|
||||
// Stripe + invoice handling
|
||||
const { stripeAccount } = useOrgStripeQuery();
|
||||
const env = useEnv();
|
||||
|
||||
const product = customizedProduct?.id ? customizedProduct : productV2;
|
||||
const { prepaidItems } = usePrepaidItems({ product });
|
||||
|
||||
@@ -874,23 +1017,30 @@ function SheetContent({
|
||||
};
|
||||
|
||||
// Build the request body
|
||||
const requestBody = useMemo<SubscriptionUpdateV0Params | null>(() => {
|
||||
const requestBody = useMemo<UpdateSubscriptionV0Params | null>(() => {
|
||||
if (!customerId) return null;
|
||||
|
||||
const body: SubscriptionUpdateV0Params = {
|
||||
const body: UpdateSubscriptionV0Params = {
|
||||
customer_id: customerId,
|
||||
product_id: product?.id,
|
||||
entity_id: entityId,
|
||||
customer_product_id: cusProduct.id ?? cusProduct.internal_product_id,
|
||||
};
|
||||
|
||||
// Add options if there are prepaid items with quantities set
|
||||
// Add options only if they have changed from initial values
|
||||
if (prepaidItems.length > 0) {
|
||||
const options = prepaidItems
|
||||
.map((item) => {
|
||||
const featureId = item.feature_id ?? item.feature?.internal_id ?? "";
|
||||
const quantity = prepaidOptions[featureId];
|
||||
if (quantity !== undefined && quantity !== null && featureId) {
|
||||
const initialQuantity = initialPrepaidOptions[featureId];
|
||||
// Only include if changed from initial value
|
||||
if (
|
||||
quantity !== undefined &&
|
||||
quantity !== null &&
|
||||
featureId &&
|
||||
quantity !== initialQuantity
|
||||
) {
|
||||
return { feature_id: featureId, quantity };
|
||||
}
|
||||
return null;
|
||||
@@ -935,6 +1085,7 @@ function SheetContent({
|
||||
cusProduct.internal_product_id,
|
||||
prepaidItems,
|
||||
prepaidOptions,
|
||||
initialPrepaidOptions,
|
||||
customizedProduct?.items,
|
||||
customizedProduct?.free_trial,
|
||||
planCustomStartDate,
|
||||
@@ -948,12 +1099,35 @@ function SheetContent({
|
||||
enabled: !!requestBody,
|
||||
});
|
||||
|
||||
// Update mutation
|
||||
const updateMutation = useSubscriptionUpdate();
|
||||
// Update mutation with invoice handling
|
||||
const updateMutation = useSubscriptionUpdate({
|
||||
customerId,
|
||||
onInvoiceCreated: (stripeInvoice) => {
|
||||
const invoiceLink = getStripeInvoiceLink({
|
||||
stripeInvoice,
|
||||
env,
|
||||
accountId: stripeAccount?.id,
|
||||
});
|
||||
window.open(invoiceLink, "_blank");
|
||||
},
|
||||
});
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!requestBody) return;
|
||||
updateMutation.mutate(requestBody);
|
||||
updateMutation.mutate({ body: requestBody, useInvoice: false });
|
||||
};
|
||||
|
||||
const handleInvoiceUpdate = ({
|
||||
enableProductImmediately,
|
||||
}: {
|
||||
enableProductImmediately: boolean;
|
||||
}) => {
|
||||
if (!requestBody) return;
|
||||
updateMutation.mutate({
|
||||
body: requestBody,
|
||||
useInvoice: true,
|
||||
enableProductImmediately,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -1181,16 +1355,64 @@ function SheetContent({
|
||||
</div>
|
||||
|
||||
{/* Footer Actions */}
|
||||
<div className="p-4 border-t flex gap-3">
|
||||
<div className="p-4 border-t flex flex-col gap-2">
|
||||
{/* Send an Invoice Button with Dropdown */}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
disabled={!requestBody || updateMutation.isPending}
|
||||
>
|
||||
Send an Invoice
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80 p-0" align="start">
|
||||
<div className="flex flex-col">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleInvoiceUpdate({ enableProductImmediately: true })
|
||||
}
|
||||
className="px-4 py-3 text-left text-sm hover:bg-accent"
|
||||
>
|
||||
<div className="font-medium">Enable plan immediately</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Enable the plan immediately and redirect to Stripe to finalize
|
||||
the invoice
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleInvoiceUpdate({ enableProductImmediately: false })
|
||||
}
|
||||
className="px-4 py-3 text-left text-sm hover:bg-accent border-t"
|
||||
>
|
||||
<div className="font-medium">Enable plan after payment</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Generate an invoice link for the customer. The plan will be
|
||||
enabled after they pay the invoice
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Confirm Update Button */}
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
onClick={handleConfirm}
|
||||
disabled={!requestBody || updateMutation.isPending}
|
||||
>
|
||||
{updateMutation.isPending ? "Updating..." : "Confirm Update"}
|
||||
</Button>
|
||||
|
||||
{/* Refresh Preview Button */}
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
onClick={() => previewQuery.refetch()}
|
||||
disabled={!requestBody || previewQuery.isLoading}
|
||||
>
|
||||
|
||||
@@ -54,6 +54,7 @@ export default defineConfig({
|
||||
"drizzle-orm",
|
||||
"@date-fns/utc",
|
||||
"date-fns",
|
||||
"@owpz/ksuid",
|
||||
],
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user