chore(wip): initial logic for subscription/update
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
};
|
||||
@@ -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");
|
||||
};
|
||||
103
server/src/internal/billing/v2/execute/executeInvoiceAction.ts
Normal file
103
server/src/internal/billing/v2/execute/executeInvoiceAction.ts
Normal 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;
|
||||
};
|
||||
@@ -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}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
/**
|
||||
* The intent for a subscription update
|
||||
*/
|
||||
export enum SubscriptionUpdateIntentEnum {
|
||||
UpdateQuantity = "update_quantity",
|
||||
UpdatePlan = "update_plan",
|
||||
|
||||
@@ -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");
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ export enum OngoingCusProductActionEnum {
|
||||
Expire = "expire",
|
||||
Cancel = "cancel",
|
||||
Uncancel = "uncancel",
|
||||
Update = "update",
|
||||
}
|
||||
|
||||
// What happens to the CURRENT active cus product
|
||||
|
||||
@@ -17,10 +17,7 @@
|
||||
"@utils/*": ["./utils/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"./**/*",
|
||||
"../server/src/internal/billing/v2/update-subscription"
|
||||
],
|
||||
"include": ["./**/*"],
|
||||
"types": ["node"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user