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;
}) => {
const { action, cusProduct } = ongoingCusProductAction;
if (action === "update") {
return;
}
if (action === "expire") {
return await CusProductService.update({
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 { QuantityUpdateDetails } from "../../types";
import { applyOngoingCusProductAction } from "./applyOngoingCusProductAction";
import { insertNewCusProducts } from "./insertNewCusProducts";
import { updateCustomerEntitlements } from "./updateCustomerEntitlements";
import { updateCustomerProductOptions } from "./updateCustomerProductOptions";
export const executeCusProductActions = async ({
ctx,
// cusProductActions,
ongoingCusProductAction,
newCusProducts,
quantityUpdateDetails,
updatedFeatureOptions,
}: {
ctx: AutumnContext;
// cusProductActions: CusProductActions;
ongoingCusProductAction?: OngoingCusProductAction;
newCusProducts: FullCusProduct[];
quantityUpdateDetails?: QuantityUpdateDetails[];
updatedFeatureOptions?: FeatureOptions[];
}) => {
// 1. Insert new cus products
const { logger } = ctx;
logger.info("Inserting new customer products");
await insertNewCusProducts({
ctx,
newCusProducts,
});
// 2. Apply ongoing cus product action
if (ongoingCusProductAction) {
logger.info(
`Applying ongoing customer product action: ${ongoingCusProductAction.action}`,
);
await applyOngoingCusProductAction({
ctx,
ongoingCusProductAction,
});
}
if (updatedFeatureOptions && ongoingCusProductAction?.cusProduct) {
logger.info("Updating customer product options");
await updateCustomerProductOptions({
ctx,
customerProductId: ongoingCusProductAction.cusProduct.id,
updatedFeatureOptions,
});
}
if (quantityUpdateDetails && quantityUpdateDetails.length > 0) {
logger.info("Updating customer entitlements");
await updateCustomerEntitlements({
ctx,
quantityUpdateDetails,
});
}
logger.info("Successfully executed all customer product actions");
};

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

View File

@@ -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 body = c.req.valid("json");
const updateSubscriptionContext = await fetchApiSubscriptionUpdateContext(
const updateSubscriptionContext = await fetchApiSubscriptionUpdateContext({
ctx,
body,
);
params: body,
});
const subscriptionUpdatePlan = computeSubscriptionUpdatePlan(ctx, {
const subscriptionUpdatePlan = computeSubscriptionUpdatePlan({
ctx,
updateSubscriptionContext,
params: body,
});
await executeSubscriptionUpdate(ctx, {
await executeSubscriptionUpdate({
ctx,
params: body,
updateSubscriptionContext,
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 { UpdateSubscriptionContext } from "../fetch/updateSubscriptionContextSchema";
import { computeSubscriptionUpdateIntent } from "./computeSubscriptionUpdateIntent";
import { getComputeSubscriptionUpdatePlanIntentMap } from "./computeSubscriptionUpdatePlanIntentMap";
import { getComputeSubscriptionUpdatePlanFunction } from "./computeSubscriptionUpdatePlanIntentMap";
/**
* Compute the subscription update plan
@@ -11,18 +11,17 @@ import { getComputeSubscriptionUpdatePlanIntentMap } from "./computeSubscription
* @param params - The parameters for the subscription update
* @returns The subscription update plan
*/
export const computeSubscriptionUpdatePlan = (
ctx: AutumnContext,
{
updateSubscriptionContext,
params,
}: {
updateSubscriptionContext: UpdateSubscriptionContext;
params: SubscriptionUpdateV0Params;
},
): SubscriptionUpdatePlan => {
export const computeSubscriptionUpdatePlan = ({
ctx,
updateSubscriptionContext,
params,
}: {
ctx: AutumnContext;
updateSubscriptionContext: UpdateSubscriptionContext;
params: SubscriptionUpdateV0Params;
}): SubscriptionUpdatePlan => {
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 { SubscriptionUpdateIntentEnum } from "./computeSubscriptionUpdateSchema";
export type ComputeSubscriptionUpdatePlan = (
ctx: AutumnContext,
{
updateSubscriptionContext,
params,
}: {
updateSubscriptionContext: UpdateSubscriptionContext;
params: SubscriptionUpdateV0Params;
},
) => SubscriptionUpdatePlan;
export type ComputeSubscriptionUpdatePlan = ({
ctx,
updateSubscriptionContext,
params,
}: {
ctx: AutumnContext;
updateSubscriptionContext: UpdateSubscriptionContext;
params: SubscriptionUpdateV0Params;
}) => SubscriptionUpdatePlan;
export type ComputeSubscriptionUpdatePlanIntentMap = Partial<
Record<SubscriptionUpdateIntentEnum, ComputeSubscriptionUpdatePlan>
@@ -33,7 +32,7 @@ const computeSubscriptionUpdatePlanIntentMap: ComputeSubscriptionUpdatePlanInten
computeSubscriptionUpdateQuantityPlan,
};
export const getComputeSubscriptionUpdatePlanIntentMap = (
export const getComputeSubscriptionUpdatePlanFunction = (
intent: SubscriptionUpdateIntentEnum,
): ComputeSubscriptionUpdatePlan => {
const plan = computeSubscriptionUpdatePlanIntentMap[intent];

View File

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

View File

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

View File

@@ -1,53 +1,95 @@
import type { SubscriptionUpdateV0Params } from "@shared/index";
import { createStripeCli } from "@/external/connect/createStripeCli";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService";
import { EntitlementService } from "@/internal/products/entitlements/EntitlementService";
import { PriceService } from "@/internal/products/prices/PriceService";
import { executeCusProductActions } from "../../execute/executeAutumnActions/executeCusProductActions";
import { executeInvoiceAction } from "../../execute/executeInvoiceAction";
import { executeStripeSubAction } from "../../execute/executeStripeSubAction";
import type { SubscriptionUpdatePlan } from "../../types";
import type { UpdateSubscriptionContext } from "../fetch/updateSubscriptionContextSchema";
export const executeSubscriptionUpdate = async (
ctx: AutumnContext,
{
params,
updateSubscriptionContext,
subscriptionUpdatePlan,
}: {
params: SubscriptionUpdateV0Params;
updateSubscriptionContext: UpdateSubscriptionContext;
subscriptionUpdatePlan: SubscriptionUpdatePlan;
},
) => {
const { db, logger } = ctx;
const { customerProduct } = updateSubscriptionContext;
export const executeSubscriptionUpdate = async ({
ctx,
params,
updateSubscriptionContext,
subscriptionUpdatePlan,
}: {
ctx: AutumnContext;
params: SubscriptionUpdateV0Params;
updateSubscriptionContext: UpdateSubscriptionContext;
subscriptionUpdatePlan: SubscriptionUpdatePlan;
}) => {
const { db, logger, org, env } = ctx;
const { customerProduct, stripeCustomer, stripeSubscription } =
updateSubscriptionContext;
const {
customEntitlements,
customPrices,
ongoingCusProductAction,
stripeSubscriptionAction,
quantityUpdateDetails,
invoiceAction,
} = subscriptionUpdatePlan;
await EntitlementService.insert({
db,
data: customEntitlements,
});
if (customEntitlements.length > 0) {
logger.info("Inserting custom entitlements");
await EntitlementService.insert({ db, data: customEntitlements });
}
await PriceService.insert({
db,
data: customPrices,
});
if (customPrices.length > 0) {
logger.info("Inserting custom prices");
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({
ctx,
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({
ctx,
ongoingCusProductAction,
newCusProducts: [customerProduct],
newCusProducts: [],
quantityUpdateDetails,
updatedFeatureOptions: params.options || [],
});
logger.info("Successfully completed subscription update");
};

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ import type {
FreeTrial,
FullCusProduct,
FullCustomer,
FullCustomerPrice,
FullProduct,
LineItem,
OngoingCusProductAction,
@@ -84,17 +85,55 @@ export type BaseSubscriptionUpdatePlan = BillingPlan & {
ongoingCusProductAction: OngoingCusProductAction;
};
export enum SubscriptionUpdateQuantityAction {
Upgrade = "upgrade",
Downgrade = "downgrade",
}
export type QuantityUpdateDetails = {
featureId: string;
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 & {
featureQuantities: {
old: FeatureOptions[];
new: FeatureOptions[];
};
action: SubscriptionUpdateQuantityAction;
quantityUpdateDetails: QuantityUpdateDetails[];
isSubscriptionTrialing: boolean;
invoiceAction?: SubscriptionUpdateInvoiceAction;
};
export type SubscriptionUpdatePlan = SubscriptionUpdateQuantityPlan;

View File

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

View File

@@ -35,6 +35,6 @@
"@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"]
}

View File

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

View File

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