chore(wip): initial logic for subscription/update

This commit is contained in:
Charlie Lamb
2025-12-19 16:58:58 +00:00
parent 6ec7c0f011
commit 2e253b9bcd
22 changed files with 888 additions and 150 deletions

View File

@@ -10,6 +10,11 @@ export const applyOngoingCusProductAction = async ({
ongoingCusProductAction: OngoingCusProductAction; ongoingCusProductAction: OngoingCusProductAction;
}) => { }) => {
const { action, cusProduct } = ongoingCusProductAction; const { action, cusProduct } = ongoingCusProductAction;
if (action === "update") {
return;
}
if (action === "expire") { if (action === "expire") {
return await CusProductService.update({ return await CusProductService.update({
db: ctx.db, db: ctx.db,

View File

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

View File

@@ -0,0 +1,65 @@
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService";
import type { QuantityUpdateDetails } from "../../types";
/**
* Update customer entitlement balances based on quantity changes.
*
* Extracted from:
* - handleQuantityUpgrade.ts:191-206
* - handleQuantityDowngrade.ts:182-198
*/
export const updateCustomerEntitlements = async ({
ctx,
quantityUpdateDetails,
}: {
ctx: AutumnContext;
quantityUpdateDetails: QuantityUpdateDetails[];
}) => {
const { db, logger } = ctx;
for (const updateDetail of quantityUpdateDetails) {
if (!updateDetail.customerEntitlementId) {
logger.info(
`No entitlement found for feature ${updateDetail.featureId}, skipping entitlement update`,
);
continue;
}
const {
customerEntitlementBalanceChange,
customerEntitlementId,
featureId,
} = updateDetail;
if (customerEntitlementBalanceChange > 0) {
logger.info(
`Incrementing entitlement for feature ${featureId} by ${customerEntitlementBalanceChange} units`,
);
await CusEntService.increment({
db,
id: customerEntitlementId,
amount: customerEntitlementBalanceChange,
});
} else if (customerEntitlementBalanceChange < 0) {
const absoluteDecrement = Math.abs(customerEntitlementBalanceChange);
logger.info(
`Decrementing entitlement for feature ${featureId} by ${absoluteDecrement} units`,
);
await CusEntService.decrement({
db,
id: customerEntitlementId,
amount: absoluteDecrement,
});
} else {
logger.info(
`No entitlement balance change required for feature ${featureId}`,
);
}
}
logger.info("Successfully updated all customer entitlements");
};

View File

@@ -0,0 +1,33 @@
import type { FeatureOptions } from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService";
/**
* Update customer product options with new feature quantities.
*
* Extracted from:
* - updateQuantityFlow.ts:55-59
*/
export const updateCustomerProductOptions = async ({
ctx,
customerProductId,
updatedFeatureOptions,
}: {
ctx: AutumnContext;
customerProductId: string;
updatedFeatureOptions: FeatureOptions[];
}) => {
const { db, logger } = ctx;
logger.info(
`Updating customer product ${customerProductId} with ${updatedFeatureOptions.length} feature options`,
);
await CusProductService.update({
db,
cusProductId: customerProductId,
updates: { options: updatedFeatureOptions },
});
logger.info("Successfully updated customer product options");
};

View File

@@ -0,0 +1,103 @@
import type { FullCusProduct } from "@autumn/shared";
import { msToSeconds, orgToCurrency } from "@autumn/shared";
import { createStripeCli } from "@/external/connect/createStripeCli";
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 { SubscriptionUpdateInvoiceAction } from "../types";
/**
* Execute invoice creation and finalization for subscription updates.
*
* Extracted from:
* - handleQuantityUpgrade.ts:130-164
* - handleQuantityDowngrade.ts:130-165
*/
export const executeInvoiceAction = async ({
ctx,
invoiceAction,
stripeCustomerId,
stripeSubscriptionId,
customerProduct,
}: {
ctx: AutumnContext;
invoiceAction: SubscriptionUpdateInvoiceAction;
stripeCustomerId: string;
stripeSubscriptionId: string;
customerProduct: FullCusProduct;
}) => {
if (!invoiceAction.shouldCreateInvoice) {
ctx.logger.info("No invoice creation required");
return null;
}
const { db, org, logger, env } = ctx;
const stripeClient = createStripeCli({ org, env });
logger.info(`Creating ${invoiceAction.invoiceItems.length} invoice items`);
for (const invoiceItem of invoiceAction.invoiceItems) {
const amountCents = Math.round(invoiceItem.amountDollars * 100);
logger.info(
`Creating invoice item: ${invoiceItem.description} - $${invoiceItem.amountDollars} (${amountCents} cents)`,
);
await stripeClient.invoiceItems.create({
customer: stripeCustomerId,
amount: amountCents,
currency: orgToCurrency({ org }),
description: invoiceItem.description,
subscription: stripeSubscriptionId,
period: {
start: msToSeconds(invoiceItem.periodStartEpochMs),
end: msToSeconds(invoiceItem.periodEndEpochMs),
},
});
}
if (invoiceAction.shouldChargeImmediately) {
logger.info("Finalizing and charging invoice immediately");
const { invoice: finalizedStripeInvoice } = await createAndFinalizeInvoice({
stripeCli: stripeClient,
stripeCusId: stripeCustomerId,
stripeSubId: stripeSubscriptionId,
paymentMethod: invoiceAction.paymentMethod || null,
chargeAutomatically: true,
logger,
});
try {
const parsedInvoiceItems = await getInvoiceItems({
stripeInvoice: finalizedStripeInvoice,
prices: invoiceAction.customerPrices.map(
(customerPrice) => customerPrice.price,
),
logger,
});
await InvoiceService.createInvoiceFromStripe({
db,
stripeInvoice: finalizedStripeInvoice,
internalCustomerId: customerProduct.internal_customer_id!,
internalEntityId: customerProduct.internal_entity_id,
productIds: [customerProduct.product_id],
internalProductIds: [customerProduct.internal_product_id],
org,
sendRevenueEvent: true,
items: parsedInvoiceItems,
});
logger.info("Successfully created internal invoice record");
} catch (error) {
logger.error(`Failed to create internal invoice record: ${error}`);
}
return finalizedStripeInvoice;
}
logger.info("Invoice items created, finalization skipped");
return null;
};

View File

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

View File

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

View File

@@ -10,17 +10,19 @@ export const handleApiSubscriptionUpdate = createRoute({
const ctx = c.get("ctx"); const ctx = c.get("ctx");
const body = c.req.valid("json"); const body = c.req.valid("json");
const updateSubscriptionContext = await fetchApiSubscriptionUpdateContext( const updateSubscriptionContext = await fetchApiSubscriptionUpdateContext({
ctx, ctx,
body, params: body,
); });
const subscriptionUpdatePlan = computeSubscriptionUpdatePlan(ctx, { const subscriptionUpdatePlan = computeSubscriptionUpdatePlan({
ctx,
updateSubscriptionContext, updateSubscriptionContext,
params: body, params: body,
}); });
await executeSubscriptionUpdate(ctx, { await executeSubscriptionUpdate({
ctx,
params: body, params: body,
updateSubscriptionContext, updateSubscriptionContext,
subscriptionUpdatePlan, subscriptionUpdatePlan,

View File

@@ -0,0 +1,71 @@
import type Stripe from "stripe";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import type {
QuantityUpdateDetails,
SubscriptionUpdateInvoiceAction,
} from "../../types";
/**
* Aggregate invoice items and determine invoice creation strategy.
* PURE FUNCTION - no side effects, only calculations.
*
* Extracted from:
* - handleQuantityUpgrade.ts:79-164
* - handleQuantityDowngrade.ts:78-165
*/
export const computeInvoiceAction = ({
ctx,
quantityUpdateDetails,
stripeSubscription,
stripeCustomerId,
paymentMethod,
shouldGenerateInvoiceOnly,
}: {
ctx: AutumnContext;
quantityUpdateDetails: QuantityUpdateDetails[];
stripeSubscription: Stripe.Subscription;
stripeCustomerId: string;
paymentMethod?: Stripe.PaymentMethod;
shouldGenerateInvoiceOnly?: boolean;
}): SubscriptionUpdateInvoiceAction | undefined => {
if (stripeSubscription.status === "trialing") {
return undefined;
}
const detailsRequiringInvoiceItems = quantityUpdateDetails.filter(
(
detail,
): detail is typeof detail & { calculatedProrationAmountDollars: number } =>
detail.shouldApplyProration &&
detail.calculatedProrationAmountDollars !== undefined,
);
if (detailsRequiringInvoiceItems.length === 0) {
return undefined;
}
const invoiceItems = detailsRequiringInvoiceItems.map((detail) => ({
description: detail.stripeInvoiceItemDescription,
amountDollars: detail.calculatedProrationAmountDollars,
stripePriceId: detail.stripePriceId,
periodStartEpochMs: detail.subscriptionPeriodStartEpochMs,
periodEndEpochMs: detail.subscriptionPeriodEndEpochMs,
}));
const shouldChargeImmediately = quantityUpdateDetails.some(
(detail) => detail.shouldFinalizeInvoiceImmediately,
);
const customerPrices = quantityUpdateDetails.map(
(detail) => detail.customerPrice,
);
return {
shouldCreateInvoice: true,
invoiceItems,
shouldChargeImmediately:
shouldChargeImmediately && !shouldGenerateInvoiceOnly,
paymentMethod,
customerPrices,
};
};

View File

@@ -0,0 +1,228 @@
import {
cusProductToProduct,
type Feature,
type FeatureOptions,
type FullCusProduct,
findCusPriceByFeature,
getFeatureInvoiceDescription,
InternalError,
OnDecrease,
OnIncrease,
priceToInvoiceAmount,
secondsToMs,
type UsagePriceConfig,
} from "@autumn/shared";
import { Decimal } from "decimal.js";
import type Stripe from "stripe";
import { subToPeriodStartEnd } from "@/external/stripe/stripeSubUtils/convertSubUtils";
import { findStripeItemForPrice } from "@/external/stripe/stripeSubUtils/stripeSubItemUtils";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { getRelatedCusEnt } from "@/internal/customers/cusProducts/cusPrices/cusPriceUtils";
import {
shouldBillNow,
shouldProrate,
} from "@/internal/products/prices/priceUtils/prorationConfigUtils";
import { notNullish } from "@/utils/genUtils";
import type { QuantityUpdateDetails } from "../../types";
/**
* Compute all details for a single feature quantity update.
* PURE FUNCTION - no side effects, only calculations.
*
* Extracted from:
* - handleQuantityUpgrade.ts:58-206
* - handleQuantityDowngrade.ts:53-198
*/
export const computeQuantityUpdateDetails = ({
ctx,
previousOptions,
updatedOptions,
customerProduct,
stripeSubscription,
currentEpochMs,
}: {
ctx: AutumnContext;
previousOptions: FeatureOptions;
updatedOptions: FeatureOptions;
customerProduct: FullCusProduct;
stripeSubscription: Stripe.Subscription;
currentEpochMs: number;
}): QuantityUpdateDetails => {
const { features } = ctx;
if (!updatedOptions.internal_feature_id) {
throw new InternalError({
message: `[Quantity Update] Missing internal_feature_id for feature: ${updatedOptions.feature_id}`,
});
}
const customerPrice = findCusPriceByFeature({
internalFeatureId: updatedOptions.internal_feature_id,
cusPrices: customerProduct.customer_prices,
});
if (!customerPrice) {
throw new InternalError({
message: `[Quantity Update] Customer price not found for internal_feature_id: ${updatedOptions.internal_feature_id}`,
});
}
const price = customerPrice.price;
const priceConfig = price.config as UsagePriceConfig;
const billingUnitsPerQuantity = priceConfig.billing_units || 1;
const isUpgrade = updatedOptions.quantity > previousOptions.quantity;
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,
);
const quantityDifferenceForEntitlements = new Decimal(updatedOptions.quantity)
.minus(previousOptions.quantity)
.toNumber();
const upcomingQuantityToConsider = notNullish(
previousOptions.upcoming_quantity,
)
? previousOptions.upcoming_quantity
: previousOptions.quantity;
const stripeSubscriptionItemQuantityDifference = new Decimal(
updatedOptions.quantity,
)
.minus(upcomingQuantityToConsider)
.toNumber();
const { start: periodStartSeconds, end: periodEndSeconds } =
subToPeriodStartEnd({
sub: stripeSubscription,
});
const periodStartMs = secondsToMs(periodStartSeconds);
const periodEndMs = secondsToMs(periodEndSeconds);
if (!periodStartMs || !periodEndMs) {
throw new InternalError({
message: `[Quantity Update] Invalid subscription period: start=${periodStartSeconds}, end=${periodEndSeconds}`,
});
}
const subscriptionPeriodStartEpochMs: number = periodStartMs;
const subscriptionPeriodEndEpochMs: number = periodEndMs;
let calculatedProrationAmountDollars: number | undefined;
if (shouldApplyProration && stripeSubscription.status !== "trialing") {
const previousQuantityActual = new Decimal(previousOptions.quantity)
.mul(billingUnitsPerQuantity)
.toNumber();
const updatedQuantityActual = new Decimal(updatedOptions.quantity)
.mul(billingUnitsPerQuantity)
.toNumber();
const previousAmountDollars = priceToInvoiceAmount({
price,
quantity: previousQuantityActual,
});
const updatedAmountDollars = priceToInvoiceAmount({
price,
quantity: updatedQuantityActual,
});
const amountDifferenceDollars = new Decimal(updatedAmountDollars).minus(
previousAmountDollars,
);
const timeRemainingMs = new Decimal(subscriptionPeriodEndEpochMs).minus(
currentEpochMs,
);
const totalPeriodMs = new Decimal(subscriptionPeriodEndEpochMs).minus(
subscriptionPeriodStartEpochMs,
);
const proratedAmountDollars = timeRemainingMs
.div(totalPeriodMs)
.mul(amountDifferenceDollars);
if (proratedAmountDollars.lte(0) && isUpgrade) {
calculatedProrationAmountDollars = 0;
} else {
calculatedProrationAmountDollars = proratedAmountDollars.toNumber();
}
}
const feature = features.find(
(f: Feature) => f.internal_id === updatedOptions.internal_feature_id,
);
if (!feature) {
throw new InternalError({
message: `[Quantity Update] Feature not found for internal_id: ${updatedOptions.internal_feature_id}`,
});
}
const product = cusProductToProduct({ cusProduct: customerProduct });
const stripeInvoiceItemDescription = getFeatureInvoiceDescription({
feature,
usage: updatedOptions.quantity,
billingUnits: billingUnitsPerQuantity,
prodName: product.name,
isPrepaid: true,
fromUnix: currentEpochMs,
});
const existingStripeSubscriptionItem = findStripeItemForPrice({
price,
stripeItems: stripeSubscription.items.data,
}) as Stripe.SubscriptionItem | undefined;
const customerEntitlement = getRelatedCusEnt({
cusPrice: customerPrice,
cusEnts: customerProduct.customer_entitlements,
});
const customerEntitlementBalanceChange = new Decimal(
quantityDifferenceForEntitlements,
)
.mul(billingUnitsPerQuantity)
.toNumber();
if (!price.config.stripe_price_id) {
throw new InternalError({
message: `[Quantity Update] Stripe price ID not found for price: ${price.id}`,
});
}
return {
featureId: updatedOptions.feature_id,
internalFeatureId: updatedOptions.internal_feature_id,
previousFeatureQuantity: previousOptions.quantity,
updatedFeatureQuantity: updatedOptions.quantity,
quantityDifferenceForEntitlements,
stripeSubscriptionItemQuantityDifference,
shouldApplyProration,
shouldFinalizeInvoiceImmediately,
billingUnitsPerQuantity,
calculatedProrationAmountDollars,
subscriptionPeriodStartEpochMs,
subscriptionPeriodEndEpochMs,
stripeInvoiceItemDescription,
customerPrice,
stripePriceId: price.config.stripe_price_id,
existingStripeSubscriptionItem,
customerEntitlementId: customerEntitlement?.id,
customerEntitlementBalanceChange,
};
};

View File

@@ -3,7 +3,7 @@ import type { AutumnContext } from "@/honoUtils/HonoEnv";
import type { SubscriptionUpdatePlan } from "../../types"; import type { SubscriptionUpdatePlan } from "../../types";
import type { UpdateSubscriptionContext } from "../fetch/updateSubscriptionContextSchema"; import type { UpdateSubscriptionContext } from "../fetch/updateSubscriptionContextSchema";
import { computeSubscriptionUpdateIntent } from "./computeSubscriptionUpdateIntent"; import { computeSubscriptionUpdateIntent } from "./computeSubscriptionUpdateIntent";
import { getComputeSubscriptionUpdatePlanIntentMap } from "./computeSubscriptionUpdatePlanIntentMap"; import { getComputeSubscriptionUpdatePlanFunction } from "./computeSubscriptionUpdatePlanIntentMap";
/** /**
* Compute the subscription update plan * Compute the subscription update plan
@@ -11,18 +11,17 @@ import { getComputeSubscriptionUpdatePlanIntentMap } from "./computeSubscription
* @param params - The parameters for the subscription update * @param params - The parameters for the subscription update
* @returns The subscription update plan * @returns The subscription update plan
*/ */
export const computeSubscriptionUpdatePlan = ( export const computeSubscriptionUpdatePlan = ({
ctx: AutumnContext, ctx,
{ updateSubscriptionContext,
updateSubscriptionContext, params,
params, }: {
}: { ctx: AutumnContext;
updateSubscriptionContext: UpdateSubscriptionContext; updateSubscriptionContext: UpdateSubscriptionContext;
params: SubscriptionUpdateV0Params; params: SubscriptionUpdateV0Params;
}, }): SubscriptionUpdatePlan => {
): SubscriptionUpdatePlan => {
const intent = computeSubscriptionUpdateIntent(params); const intent = computeSubscriptionUpdateIntent(params);
const computePlan = getComputeSubscriptionUpdatePlanIntentMap(intent); const computePlan = getComputeSubscriptionUpdatePlanFunction(intent);
return computePlan(ctx, { updateSubscriptionContext, params }); return computePlan({ ctx, updateSubscriptionContext, params });
}; };

View File

@@ -9,16 +9,15 @@ import type { UpdateSubscriptionContext } from "../fetch/updateSubscriptionConte
import { computeSubscriptionUpdateQuantityPlan } from "./computeSubscriptionUpdateQuantityPlan"; import { computeSubscriptionUpdateQuantityPlan } from "./computeSubscriptionUpdateQuantityPlan";
import { SubscriptionUpdateIntentEnum } from "./computeSubscriptionUpdateSchema"; import { SubscriptionUpdateIntentEnum } from "./computeSubscriptionUpdateSchema";
export type ComputeSubscriptionUpdatePlan = ( export type ComputeSubscriptionUpdatePlan = ({
ctx: AutumnContext, ctx,
{ updateSubscriptionContext,
updateSubscriptionContext, params,
params, }: {
}: { ctx: AutumnContext;
updateSubscriptionContext: UpdateSubscriptionContext; updateSubscriptionContext: UpdateSubscriptionContext;
params: SubscriptionUpdateV0Params; params: SubscriptionUpdateV0Params;
}, }) => SubscriptionUpdatePlan;
) => SubscriptionUpdatePlan;
export type ComputeSubscriptionUpdatePlanIntentMap = Partial< export type ComputeSubscriptionUpdatePlanIntentMap = Partial<
Record<SubscriptionUpdateIntentEnum, ComputeSubscriptionUpdatePlan> Record<SubscriptionUpdateIntentEnum, ComputeSubscriptionUpdatePlan>
@@ -33,7 +32,7 @@ const computeSubscriptionUpdatePlanIntentMap: ComputeSubscriptionUpdatePlanInten
computeSubscriptionUpdateQuantityPlan, computeSubscriptionUpdateQuantityPlan,
}; };
export const getComputeSubscriptionUpdatePlanIntentMap = ( export const getComputeSubscriptionUpdatePlanFunction = (
intent: SubscriptionUpdateIntentEnum, intent: SubscriptionUpdateIntentEnum,
): ComputeSubscriptionUpdatePlan => { ): ComputeSubscriptionUpdatePlan => {
const plan = computeSubscriptionUpdatePlanIntentMap[intent]; const plan = computeSubscriptionUpdatePlanIntentMap[intent];

View File

@@ -4,35 +4,29 @@ import {
secondsToMs, secondsToMs,
} from "@shared/index"; } from "@shared/index";
import type { AutumnContext } from "@/honoUtils/HonoEnv"; import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { cusProductToExistingUsages } from "@/internal/billing/billingUtils/handleExistingUsages/cusProductToExistingUsages";
import { initFullCusProduct } from "@/internal/billing/billingUtils/initFullCusProduct/initFullCusProduct";
import { buildAutumnLineItems } from "../../compute/computeAutumnUtils/buildAutumnLineItems"; import { buildAutumnLineItems } from "../../compute/computeAutumnUtils/buildAutumnLineItems";
import { buildStripeSubAction } from "../../compute/computeStripeUtils/buildStripeSubAction"; import type { SubscriptionUpdateQuantityPlan } from "../../types";
import {
SubscriptionUpdateQuantityAction,
type SubscriptionUpdateQuantityPlan,
} from "../../types";
import type { UpdateSubscriptionContext } from "../fetch/updateSubscriptionContextSchema"; import type { UpdateSubscriptionContext } from "../fetch/updateSubscriptionContextSchema";
import { computeInvoiceAction } from "./computeInvoiceAction";
import { computeQuantityUpdateDetails } from "./computeQuantityUpdateDetails";
import { SubscriptionUpdateIntentEnum } from "./computeSubscriptionUpdateSchema"; import { SubscriptionUpdateIntentEnum } from "./computeSubscriptionUpdateSchema";
export const computeSubscriptionUpdateQuantityPlan = ( export const computeSubscriptionUpdateQuantityPlan = ({
ctx: AutumnContext, ctx,
{ updateSubscriptionContext,
updateSubscriptionContext, params,
params, }: {
}: { ctx: AutumnContext;
updateSubscriptionContext: UpdateSubscriptionContext; updateSubscriptionContext: UpdateSubscriptionContext;
params: SubscriptionUpdateV0Params; params: SubscriptionUpdateV0Params;
}, }): SubscriptionUpdateQuantityPlan => {
): SubscriptionUpdateQuantityPlan => {
const { options } = params; const { options } = params;
const { const {
customerProduct, customerProduct,
fullCustomer,
stripeSubscription, stripeSubscription,
testClockFrozenTime, testClockFrozenTime,
product,
paymentMethod, paymentMethod,
stripeCustomer,
} = updateSubscriptionContext; } = updateSubscriptionContext;
const featureQuantities = { const featureQuantities = {
@@ -40,19 +34,39 @@ export const computeSubscriptionUpdateQuantityPlan = (
new: options || [], new: options || [],
}; };
const isUpgrade = const currentEpochMs = testClockFrozenTime || Date.now();
featureQuantities.new[0].quantity > featureQuantities.old[0].quantity;
const action = isUpgrade const quantityUpdateDetails = featureQuantities.new.map(
? SubscriptionUpdateQuantityAction.Upgrade (updatedOption, index) =>
: SubscriptionUpdateQuantityAction.Downgrade; computeQuantityUpdateDetails({
ctx,
previousOptions: featureQuantities.old[index],
updatedOptions: updatedOption,
customerProduct,
stripeSubscription,
currentEpochMs,
}),
);
const isSubscriptionTrialing = stripeSubscription.status === "trialing";
const invoiceAction = !isSubscriptionTrialing
? computeInvoiceAction({
ctx,
quantityUpdateDetails,
stripeSubscription,
stripeCustomerId: stripeCustomer.id,
paymentMethod,
shouldGenerateInvoiceOnly: !(params.finalize_invoice ?? true),
})
: undefined;
const billingCycleAnchor = secondsToMs( const billingCycleAnchor = secondsToMs(
stripeSubscription?.billing_cycle_anchor, stripeSubscription.billing_cycle_anchor,
); );
const ongoingCusProductAction = { const ongoingCusProductAction = {
action: OngoingCusProductActionEnum.Expire, action: OngoingCusProductActionEnum.Update,
cusProduct: customerProduct, cusProduct: customerProduct,
}; };
@@ -64,35 +78,32 @@ export const computeSubscriptionUpdateQuantityPlan = (
testClockFrozenTime, testClockFrozenTime,
}); });
const newCustomerProduct = initFullCusProduct({ const stripeSubscriptionAction = {
ctx, type: "update" as const,
fullCus: fullCustomer, subId: stripeSubscription.id,
initContext: { items: quantityUpdateDetails.map((detail) => {
fullCus: fullCustomer, if (detail.existingStripeSubscriptionItem) {
product, return {
featureQuantities: [], id: detail.existingStripeSubscriptionItem.id,
replaceables: [], quantity: detail.updatedFeatureQuantity,
existingUsages: cusProductToExistingUsages({ };
cusProduct: customerProduct, }
}),
},
});
const stripeSubscriptionAction = buildStripeSubAction({ return {
ctx, price: detail.stripePriceId,
stripeSub: stripeSubscription!, quantity: detail.updatedFeatureQuantity,
fullCus: fullCustomer, };
paymentMethod, }),
ongoingCusProductAction, };
newCusProducts: [newCustomerProduct],
});
return { return {
intent: SubscriptionUpdateIntentEnum.UpdateQuantity, intent: SubscriptionUpdateIntentEnum.UpdateQuantity,
customEntitlements: [], customEntitlements: [],
customPrices: [], customPrices: [],
featureQuantities, featureQuantities,
action, quantityUpdateDetails,
isSubscriptionTrialing,
invoiceAction,
autumnLineItems, autumnLineItems,
stripeSubscriptionAction, stripeSubscriptionAction,
ongoingCusProductAction, ongoingCusProductAction,

View File

@@ -1,5 +1,8 @@
import { z } from "zod/v4"; import { z } from "zod/v4";
/**
* The intent for a subscription update
*/
export enum SubscriptionUpdateIntentEnum { export enum SubscriptionUpdateIntentEnum {
UpdateQuantity = "update_quantity", UpdateQuantity = "update_quantity",
UpdatePlan = "update_plan", UpdatePlan = "update_plan",

View File

@@ -1,53 +1,95 @@
import type { SubscriptionUpdateV0Params } from "@shared/index"; import type { SubscriptionUpdateV0Params } from "@shared/index";
import { createStripeCli } from "@/external/connect/createStripeCli";
import type { AutumnContext } from "@/honoUtils/HonoEnv"; import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService";
import { EntitlementService } from "@/internal/products/entitlements/EntitlementService"; import { EntitlementService } from "@/internal/products/entitlements/EntitlementService";
import { PriceService } from "@/internal/products/prices/PriceService"; import { PriceService } from "@/internal/products/prices/PriceService";
import { executeCusProductActions } from "../../execute/executeAutumnActions/executeCusProductActions"; import { executeCusProductActions } from "../../execute/executeAutumnActions/executeCusProductActions";
import { executeInvoiceAction } from "../../execute/executeInvoiceAction";
import { executeStripeSubAction } from "../../execute/executeStripeSubAction"; import { executeStripeSubAction } from "../../execute/executeStripeSubAction";
import type { SubscriptionUpdatePlan } from "../../types"; import type { SubscriptionUpdatePlan } from "../../types";
import type { UpdateSubscriptionContext } from "../fetch/updateSubscriptionContextSchema"; import type { UpdateSubscriptionContext } from "../fetch/updateSubscriptionContextSchema";
export const executeSubscriptionUpdate = async ( export const executeSubscriptionUpdate = async ({
ctx: AutumnContext, ctx,
{ params,
params, updateSubscriptionContext,
updateSubscriptionContext, subscriptionUpdatePlan,
subscriptionUpdatePlan, }: {
}: { ctx: AutumnContext;
params: SubscriptionUpdateV0Params; params: SubscriptionUpdateV0Params;
updateSubscriptionContext: UpdateSubscriptionContext; updateSubscriptionContext: UpdateSubscriptionContext;
subscriptionUpdatePlan: SubscriptionUpdatePlan; subscriptionUpdatePlan: SubscriptionUpdatePlan;
}, }) => {
) => { const { db, logger, org, env } = ctx;
const { db, logger } = ctx; const { customerProduct, stripeCustomer, stripeSubscription } =
const { customerProduct } = updateSubscriptionContext; updateSubscriptionContext;
const { const {
customEntitlements, customEntitlements,
customPrices, customPrices,
ongoingCusProductAction, ongoingCusProductAction,
stripeSubscriptionAction, stripeSubscriptionAction,
quantityUpdateDetails,
invoiceAction,
} = subscriptionUpdatePlan; } = subscriptionUpdatePlan;
await EntitlementService.insert({ if (customEntitlements.length > 0) {
db, logger.info("Inserting custom entitlements");
data: customEntitlements, await EntitlementService.insert({ db, data: customEntitlements });
}); }
await PriceService.insert({ if (customPrices.length > 0) {
db, logger.info("Inserting custom prices");
data: customPrices, await PriceService.insert({ db, data: customPrices });
}); }
logger.info("Executing stripe sub action"); const isProductCanceled = customerProduct.canceled === true;
if (isProductCanceled) {
logger.info("Uncanceling subscription in Stripe");
const stripeClient = createStripeCli({ org, env });
await stripeClient.subscriptions.update(stripeSubscription.id, {
cancel_at_period_end: false,
});
logger.info("Uncanceling customer product in Autumn");
await CusProductService.update({
db,
cusProductId: customerProduct.id,
updates: {
canceled: false,
canceled_at: null,
ended_at: null,
},
});
}
logger.info("Executing Stripe subscription action");
await executeStripeSubAction({ await executeStripeSubAction({
ctx, ctx,
stripeSubAction: stripeSubscriptionAction, stripeSubAction: stripeSubscriptionAction,
}); });
logger.info("Executing cus product actions"); if (invoiceAction) {
logger.info("Executing invoice action");
await executeInvoiceAction({
ctx,
invoiceAction,
stripeCustomerId: stripeCustomer.id,
stripeSubscriptionId: stripeSubscription.id,
customerProduct,
});
} else {
logger.info("No invoice action required");
}
logger.info("Executing customer product actions");
await executeCusProductActions({ await executeCusProductActions({
ctx, ctx,
ongoingCusProductAction, ongoingCusProductAction,
newCusProducts: [customerProduct], newCusProducts: [],
quantityUpdateDetails,
updatedFeatureOptions: params.options || [],
}); });
logger.info("Successfully completed subscription update");
}; };

View File

@@ -4,6 +4,7 @@ import {
type SubscriptionUpdateV0Params, type SubscriptionUpdateV0Params,
} from "@shared/index"; } from "@shared/index";
import type { AutumnContext } from "@/honoUtils/HonoEnv"; import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { mapOptionsList } from "@/internal/customers/attach/attachUtils/mapOptionsList";
import { CusService } from "../../../../customers/CusService"; import { CusService } from "../../../../customers/CusService";
import { fetchStripeCustomerForBilling } from "../../fetch/fetchStripeUtils/fetchStripeCustomerForBilling"; import { fetchStripeCustomerForBilling } from "../../fetch/fetchStripeUtils/fetchStripeCustomerForBilling";
import { fetchStripeSubscriptionForBilling } from "../../fetch/fetchStripeUtils/fetchStripeSubscriptionForBilling"; import { fetchStripeSubscriptionForBilling } from "../../fetch/fetchStripeUtils/fetchStripeSubscriptionForBilling";
@@ -14,23 +15,15 @@ import type { UpdateSubscriptionContext } from "./updateSubscriptionContextSchem
* @param ctx - The context * @param ctx - The context
* @param body - The body of the request * @param body - The body of the request
* @returns The update subscription context * @returns The update subscription context
* @example
* const context = await fetchApiSubscriptionUpdateContext(ctx, params);
*
* Returns:
* 1. Full customer
* 2. Target customer product
* 3. Stripe subscription (if applicable)
* 4. Stripe schedule (if applicable)
* 5. Stripe customer
* 6. Payment method (if applicable)
* 7. Test clock frozen time (if applicable)
*/ */
export const fetchApiSubscriptionUpdateContext = async ( export const fetchApiSubscriptionUpdateContext = async ({
ctx: AutumnContext, ctx,
params: SubscriptionUpdateV0Params, params,
): Promise<UpdateSubscriptionContext> => { }: {
const { db, org, env } = ctx; ctx: AutumnContext;
params: SubscriptionUpdateV0Params;
}): Promise<UpdateSubscriptionContext> => {
const { db, org, env, features } = ctx;
const { customer_id: customerId, product_id: productId } = params; const { customer_id: customerId, product_id: productId } = params;
const fullCustomer = await CusService.getFull({ const fullCustomer = await CusService.getFull({
@@ -63,6 +56,12 @@ export const fetchApiSubscriptionUpdateContext = async (
targetCusProductId: targetCustomerProduct.id, targetCusProductId: targetCustomerProduct.id,
}); });
if (!stripeSubscription) {
throw new InternalError({
message: `[API Subscription Update] No active subscription found for customer product: ${productId}`,
});
}
const { const {
stripeCus: stripeCustomer, stripeCus: stripeCustomer,
paymentMethod, paymentMethod,
@@ -72,6 +71,15 @@ export const fetchApiSubscriptionUpdateContext = async (
fullCus: fullCustomer, fullCus: fullCustomer,
}); });
if (params.options) {
params.options = mapOptionsList({
optionsInput: params.options,
features,
prices: targetCustomerProduct.customer_prices.map((cp) => cp.price),
curCusProduct: targetCustomerProduct,
});
}
return { return {
fullCustomer, fullCustomer,
product: targetProduct, product: targetProduct,

View File

@@ -5,7 +5,7 @@ export type UpdateSubscriptionContext = {
fullCustomer: FullCustomer; fullCustomer: FullCustomer;
product: FullProduct; product: FullProduct;
customerProduct: FullCusProduct; customerProduct: FullCusProduct;
stripeSubscription?: Stripe.Subscription; stripeSubscription: Stripe.Subscription;
stripeSubscriptionSchedule?: Stripe.SubscriptionSchedule; stripeSubscriptionSchedule?: Stripe.SubscriptionSchedule;
stripeCustomer: Stripe.Customer; stripeCustomer: Stripe.Customer;
paymentMethod?: Stripe.PaymentMethod; paymentMethod?: Stripe.PaymentMethod;

View File

@@ -5,6 +5,7 @@ import type {
FreeTrial, FreeTrial,
FullCusProduct, FullCusProduct,
FullCustomer, FullCustomer,
FullCustomerPrice,
FullProduct, FullProduct,
LineItem, LineItem,
OngoingCusProductAction, OngoingCusProductAction,
@@ -84,17 +85,55 @@ export type BaseSubscriptionUpdatePlan = BillingPlan & {
ongoingCusProductAction: OngoingCusProductAction; ongoingCusProductAction: OngoingCusProductAction;
}; };
export enum SubscriptionUpdateQuantityAction { export type QuantityUpdateDetails = {
Upgrade = "upgrade", featureId: string;
Downgrade = "downgrade", internalFeatureId: string;
}
previousFeatureQuantity: number;
updatedFeatureQuantity: number;
quantityDifferenceForEntitlements: number;
stripeSubscriptionItemQuantityDifference: number;
shouldApplyProration: boolean;
shouldFinalizeInvoiceImmediately: boolean;
billingUnitsPerQuantity: number;
calculatedProrationAmountDollars?: number;
subscriptionPeriodStartEpochMs: number;
subscriptionPeriodEndEpochMs: number;
stripeInvoiceItemDescription: string;
customerPrice: FullCustomerPrice;
stripePriceId: string;
existingStripeSubscriptionItem?: Stripe.SubscriptionItem;
customerEntitlementId?: string;
customerEntitlementBalanceChange: number;
};
export type SubscriptionUpdateInvoiceAction = {
shouldCreateInvoice: boolean;
invoiceItems: {
description: string;
amountDollars: number;
stripePriceId: string;
periodStartEpochMs: number;
periodEndEpochMs: number;
}[];
shouldChargeImmediately: boolean;
paymentMethod?: Stripe.PaymentMethod;
customerPrices: FullCustomerPrice[];
};
export type SubscriptionUpdateQuantityPlan = BaseSubscriptionUpdatePlan & { export type SubscriptionUpdateQuantityPlan = BaseSubscriptionUpdatePlan & {
featureQuantities: { featureQuantities: {
old: FeatureOptions[]; old: FeatureOptions[];
new: FeatureOptions[]; new: FeatureOptions[];
}; };
action: SubscriptionUpdateQuantityAction; quantityUpdateDetails: QuantityUpdateDetails[];
isSubscriptionTrialing: boolean;
invoiceAction?: SubscriptionUpdateInvoiceAction;
}; };
export type SubscriptionUpdatePlan = SubscriptionUpdateQuantityPlan; export type SubscriptionUpdatePlan = SubscriptionUpdateQuantityPlan;

View File

@@ -3,6 +3,7 @@ import {
ApiVersion, ApiVersion,
CouponDurationType, CouponDurationType,
type CreateReward, type CreateReward,
ProductItemFeatureType,
RewardType, RewardType,
} from "@autumn/shared"; } from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features.js"; import { TestFeature } from "@tests/setup/v2Features.js";
@@ -40,7 +41,7 @@ const free = constructProduct({
items: [ items: [
constructFeatureItem({ constructFeatureItem({
featureId: TestFeature.Messages, featureId: TestFeature.Messages,
includedUsage: 100, includedUsage: 12,
}), }),
], ],
}); });
@@ -51,7 +52,7 @@ const pro = constructProduct({
items: [ items: [
constructFeatureItem({ constructFeatureItem({
featureId: TestFeature.Messages, featureId: TestFeature.Messages,
includedUsage: 100, includedUsage: 12,
}), }),
// constructArrearProratedItem({ // constructArrearProratedItem({
// featureId: TestFeature.Users, // featureId: TestFeature.Users,
@@ -185,6 +186,17 @@ const reward: CreateReward = {
}, },
}; };
const superProd = constructRawProduct({
id: "super",
items: [
constructPrepaidItem({
featureId: TestFeature.Messages,
billingUnits: 12,
price: 8,
}),
],
});
describe(`${chalk.yellowBright("temp: temporary script for testing")}`, () => { describe(`${chalk.yellowBright("temp: temporary script for testing")}`, () => {
const customerId = "temp"; const customerId = "temp";
const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 }); const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
@@ -206,6 +218,7 @@ describe(`${chalk.yellowBright("temp: temporary script for testing")}`, () => {
freeAddOn, freeAddOn,
monthlyAddOn, monthlyAddOn,
oneOffCredits, oneOffCredits,
superProd,
], ],
prefix: customerId, prefix: customerId,
}); });
@@ -221,7 +234,13 @@ describe(`${chalk.yellowBright("temp: temporary script for testing")}`, () => {
await autumnV1.attach({ await autumnV1.attach({
customer_id: customerId, customer_id: customerId,
product_id: pro.id, product_id: superProd.id,
options: [
{
feature_id: TestFeature.Messages,
quantity: 10,
},
],
}); });
// await autumnV1.entities.create(customerId, entities); // await autumnV1.entities.create(customerId, entities);

View File

@@ -35,6 +35,6 @@
"@utils/*": ["../shared/utils/*"], "@utils/*": ["../shared/utils/*"],
} }
}, },
"include": ["src", "tests", "scripts", "emails", "experiments", "src/internal/billing/v2/subscriptionUpdate", "../shared/api/billing/subscriptionUpdate/compute", "../shared/api/billing/subscriptionUpdate/fetch"], "include": ["src", "tests", "scripts", "emails", "experiments"],
"exclude": ["node_modules", "dist", "tests/archives"] "exclude": ["node_modules", "dist", "tests/archives"]
} }

View File

@@ -5,6 +5,7 @@ export enum OngoingCusProductActionEnum {
Expire = "expire", Expire = "expire",
Cancel = "cancel", Cancel = "cancel",
Uncancel = "uncancel", Uncancel = "uncancel",
Update = "update",
} }
// What happens to the CURRENT active cus product // What happens to the CURRENT active cus product

View File

@@ -17,10 +17,7 @@
"@utils/*": ["./utils/*"] "@utils/*": ["./utils/*"]
} }
}, },
"include": [ "include": ["./**/*"],
"./**/*",
"../server/src/internal/billing/v2/update-subscription"
],
"types": ["node"], "types": ["node"],
"exclude": ["node_modules", "dist"] "exclude": ["node_modules", "dist"]
} }