diff --git a/server/src/external/stripe/subscriptions/utils/classifyStripeSubscriptionUtils.ts b/server/src/external/stripe/subscriptions/utils/classifyStripeSubscriptionUtils.ts index ad75519ca..2652760b9 100644 --- a/server/src/external/stripe/subscriptions/utils/classifyStripeSubscriptionUtils.ts +++ b/server/src/external/stripe/subscriptions/utils/classifyStripeSubscriptionUtils.ts @@ -7,7 +7,7 @@ export const isStripeSubscriptionTrialing = ( return stripeSubscription.status === "trialing"; }; -export const isStripeSubscriptionCancelling = ( +export const isStripeSubscriptionCanceling = ( stripeSubscription?: Stripe.Subscription, ) => { if (!stripeSubscription) { diff --git a/server/src/internal/billing/billingRouter.ts b/server/src/internal/billing/billingRouter.ts index a89cbcfb5..afbe6a544 100644 --- a/server/src/internal/billing/billingRouter.ts +++ b/server/src/internal/billing/billingRouter.ts @@ -1,4 +1,5 @@ import { Hono } from "hono"; +import { handleSubscriptionUpdatePreview } from "@/internal/billing/v2/subscriptionUpdate/handleSubscriptionUpdatePreview.js"; import type { HonoEnv } from "../../honoUtils/HonoEnv.js"; import { handleAttach } from "./attach/handleAttach.js"; import { handleCheckoutV2 } from "./checkout/handleCheckoutV2.js"; @@ -14,3 +15,7 @@ billingRouter.post("/attach", ...handleAttach); billingRouter.post("/attach_v2", ...handleAttachV2); billingRouter.post("/subscriptions/update", ...handleApiSubscriptionUpdate); +billingRouter.post( + "/subscriptions/preview/update", + ...handleSubscriptionUpdatePreview, +); diff --git a/server/src/internal/billing/v2/billingPlan.ts b/server/src/internal/billing/v2/billingPlan.ts index 3d42b473d..f9d7e587f 100644 --- a/server/src/internal/billing/v2/billingPlan.ts +++ b/server/src/internal/billing/v2/billingPlan.ts @@ -30,12 +30,19 @@ export const StripeSubscriptionActionSchema = z.discriminatedUnion("type", [ }), ]); +export const StripeInvoiceActionSchema = z.object({ + addLineParams: z.custom(), +}); + +export type StripeInvoiceAction = z.infer; + export type StripeSubscriptionAction = z.infer< typeof StripeSubscriptionActionSchema >; export const StripeBillingPlanSchema = z.object({ - subscription: StripeSubscriptionActionSchema.optional(), + subscriptionAction: StripeSubscriptionActionSchema.optional(), + invoiceAction: StripeInvoiceActionSchema.optional(), }); export const AutumnBillingPlanSchema = z.object({ @@ -51,16 +58,6 @@ export const AutumnBillingPlanSchema = z.object({ customPrices: z.array(PriceSchema), // Custom prices to insert customEntitlements: z.array(EntitlementSchema), // Custom entitlements to insert customFreeTrial: FreeTrialSchema.optional(), // Custom free trial to insert - - // expireCusProducts: z.array(z.string()), - - // updateCusProduct: z.object({ - // cusProductId: z.string(), - // options: z.array(FeatureOptionsSchema), - // }), - // entitlementChanges: z.array( - // z.object({ cusEntId: z.string(), delta: z.number() }), - // ), }); export const BillingPlanSchema = z.object({ diff --git a/server/src/internal/billing/v2/compute/computeStripeUtils/buildStripeInvoiceAction.ts b/server/src/internal/billing/v2/compute/computeStripeUtils/buildStripeInvoiceAction.ts index 376165329..64624928d 100644 --- a/server/src/internal/billing/v2/compute/computeStripeUtils/buildStripeInvoiceAction.ts +++ b/server/src/internal/billing/v2/compute/computeStripeUtils/buildStripeInvoiceAction.ts @@ -7,7 +7,7 @@ import { import type { AttachContext, StripeSubAction } from "../../typesOld"; import { applyStripeDiscountsToLineItems } from "../../utils/stripeAdapter/applyStripeDiscounts/applyStripeDiscountsToLineItems"; import { subToDiscounts } from "../../utils/stripeAdapter/applyStripeDiscounts/subToDiscounts"; -import { lineItemsToStripeLines } from "../../utils/stripeAdapter/stripeInvoiceOps/lineItemsToStripeLines"; +import { lineItemsToStripeLines } from "../../utils/stripeAdapter/invoiceLines/lineItemsToStripeLines"; export const buildStripeInvoiceAction = ({ attachContext, diff --git a/server/src/internal/billing/v2/execute/executeStripeInvoiceAction.ts b/server/src/internal/billing/v2/execute/executeStripeInvoiceAction.ts index 6dfff0535..e9bc0d9d4 100644 --- a/server/src/internal/billing/v2/execute/executeStripeInvoiceAction.ts +++ b/server/src/internal/billing/v2/execute/executeStripeInvoiceAction.ts @@ -1,54 +1,31 @@ +import type { BillingContext } from "@/internal/billing/v2/billingContext"; import { createStripeCli } from "../../../../external/connect/createStripeCli"; import type { AutumnContext } from "../../../../honoUtils/HonoEnv"; +import type { StripeInvoiceAction } from "../billingPlan"; import { createAndPayInvoice } from "../utils/stripeAdapter/stripeInvoiceOps/createAndPayInvoice"; -import type { - AttachContext, - StripeCheckoutAction, - StripeInvoiceAction, -} from "../typesOld"; -import { executeStripeCheckoutAction } from "./executeStripeCheckoutAction"; export const executeStripeInvoiceAction = async ({ ctx, - attachContext, - stripeCheckoutAction, + billingContext, stripeInvoiceAction, }: { ctx: AutumnContext; - attachContext: AttachContext; - stripeCheckoutAction: StripeCheckoutAction; + billingContext: BillingContext; stripeInvoiceAction: StripeInvoiceAction; }) => { - const { org, env, logger } = ctx; - const { items, onPaymentFailure } = stripeInvoiceAction; + const { org, env } = ctx; + const { addLineParams } = stripeInvoiceAction; const stripeCli = createStripeCli({ org, env }); // 1. Create and pay invoice - const { invoice, paid, error, createCheckoutSession, hostedUrl } = - await createAndPayInvoice({ - stripeCli, - stripeCusId: attachContext.stripeCus.id, - stripeLineItems: items, - paymentMethod: attachContext.paymentMethod, - onPaymentFailure: onPaymentFailure, - }); + const result = await createAndPayInvoice({ + stripeCli, + stripeCusId: billingContext.stripeCustomer?.id, + stripeLineItems: addLineParams.lines, + paymentMethod: billingContext.paymentMethod, + onPaymentFailure: "return_url", + }); - if (!paid) { - // 1. Either return checkout session, hosted url, or throw error - if (createCheckoutSession) { - return await executeStripeCheckoutAction({ - ctx, - stripeCheckoutAction: stripeCheckoutAction, - }); - } - - if (hostedUrl) { - return hostedUrl; - } - - throw error; - } - - return invoice; + return result; }; diff --git a/server/src/internal/billing/v2/fetch/fetchAutumnUtils/resolveAttachActions/getUncancelAttachActions.ts b/server/src/internal/billing/v2/fetch/fetchAutumnUtils/resolveAttachActions/getUncancelAttachActions.ts index b4a4fc461..3cb8d9f55 100644 --- a/server/src/internal/billing/v2/fetch/fetchAutumnUtils/resolveAttachActions/getUncancelAttachActions.ts +++ b/server/src/internal/billing/v2/fetch/fetchAutumnUtils/resolveAttachActions/getUncancelAttachActions.ts @@ -3,7 +3,7 @@ import { type CusProductActions, getOngoingCusProductById, getScheduledMainCusProductByGroup, - isCusProductCanceled, + isCustomerProductCanceled, } from "@autumn/shared"; /** @@ -27,7 +27,7 @@ export const getUncancelAttachActions = ({ if ( !ongoingSameCusProduct || - !isCusProductCanceled({ cusProduct: ongoingSameCusProduct }) + !isCustomerProductCanceled(ongoingSameCusProduct) ) { return undefined; } diff --git a/server/src/internal/billing/v2/handlers/handleApiSubscriptionUpdate.ts b/server/src/internal/billing/v2/handlers/handleApiSubscriptionUpdate.ts index 27e3b7f01..d60287b46 100644 --- a/server/src/internal/billing/v2/handlers/handleApiSubscriptionUpdate.ts +++ b/server/src/internal/billing/v2/handlers/handleApiSubscriptionUpdate.ts @@ -14,7 +14,7 @@ export const handleApiSubscriptionUpdate = createRoute({ params: body, }); - const subscriptionUpdatePlan = computeSubscriptionUpdatePlan({ + const subscriptionUpdatePlan = await computeSubscriptionUpdatePlan({ ctx, updateSubscriptionContext, params: body, diff --git a/server/src/internal/billing/v2/subscriptionUpdate/compute/buildQuantityUpdateLineItems.ts b/server/src/internal/billing/v2/subscriptionUpdate/compute/buildQuantityUpdateLineItems.ts new file mode 100644 index 000000000..83a024269 --- /dev/null +++ b/server/src/internal/billing/v2/subscriptionUpdate/compute/buildQuantityUpdateLineItems.ts @@ -0,0 +1,88 @@ +import { + type BillingPeriod, + cusEntToCusPrice, + cusProductToCusEnts, + type Feature, + type FullCusProduct, + findPrepaidCustomerEntitlement, + InternalError, + type LineItemContext, + orgToCurrency, + usagePriceToLineItem, +} from "@autumn/shared"; +import { Decimal } from "decimal.js"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; + +export const buildQuantityUpdateLineItems = ({ + ctx, + customerProduct, + feature, + billingPeriod, + quantityDifferenceForEntitlements, + currentEpochMs, +}: { + ctx: AutumnContext; + customerProduct: FullCusProduct; + feature: Feature; + billingPeriod?: BillingPeriod; + quantityDifferenceForEntitlements: number; + currentEpochMs: number; +}) => { + const { org } = ctx; + const customerEntitlements = cusProductToCusEnts({ customerProduct }); + + const prepaidCustomerEntitlement = findPrepaidCustomerEntitlement({ + customerEntitlements, + feature, + }); + + if (!prepaidCustomerEntitlement) { + throw new InternalError({ + message: `[Quantity Update] Prepaid customer entitlement not found for feature: ${feature.internal_id}`, + }); + } + + const customerPrice = cusEntToCusPrice({ + cusEnt: prepaidCustomerEntitlement, + }); + + if (!customerPrice) { + throw new InternalError({ + message: `[Quantity Update] Prepaid customer price not found for feature: ${feature.internal_id}`, + }); + } + + // New customer entitlement + const newCustomerEntitlement = structuredClone(prepaidCustomerEntitlement); + newCustomerEntitlement.balance = new Decimal( + newCustomerEntitlement.balance ?? 0, + ) + .add(quantityDifferenceForEntitlements) // does this include billing units? + .toNumber(); + + const lineItemContext: LineItemContext = { + price: customerPrice?.price, + product: customerProduct.product, + feature, + currency: orgToCurrency({ org }), + direction: "charge", + now: currentEpochMs, + billingTiming: "in_arrear", + billingPeriod, + }; + + const refundLineItem = usagePriceToLineItem({ + cusEnt: newCustomerEntitlement, + context: { + ...lineItemContext, + direction: "refund", + }, + }); + + const chargeLineItem = usagePriceToLineItem({ + cusEnt: prepaidCustomerEntitlement, + context: lineItemContext, + }); + + return [refundLineItem, chargeLineItem]; +}; diff --git a/server/src/internal/billing/v2/subscriptionUpdate/compute/computeQuantityUpdateDetails.ts b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeQuantityUpdateDetails.ts index f10fd141f..85099e421 100644 --- a/server/src/internal/billing/v2/subscriptionUpdate/compute/computeQuantityUpdateDetails.ts +++ b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeQuantityUpdateDetails.ts @@ -1,9 +1,9 @@ import { cusProductToProduct, extractBillingPeriod, - type Feature, type FeatureOptions, type FullCusProduct, + findFeatureByInternalId, InternalError, } from "@autumn/shared"; import { usagePriceToLineDescription } from "@autumn/shared/utils/billingUtils/invoicingUtils/descriptionUtils/usagePriceToLineDescription"; @@ -46,7 +46,7 @@ export const computeQuantityUpdateDetails = ({ stripeSubscription: Stripe.Subscription; currentEpochMs: number; }): QuantityUpdateDetails => { - const { features } = ctx; + const { features, org } = ctx; const internalFeatureId = updatedOptions.internal_feature_id; const featureId = updatedOptions.feature_id; @@ -57,6 +57,17 @@ export const computeQuantityUpdateDetails = ({ }); } + const feature = findFeatureByInternalId({ + features, + internalId: internalFeatureId, + }); + + if (!feature) { + throw new InternalError({ + message: `[Quantity Update] Feature not found for internal_id: ${internalFeatureId}`, + }); + } + const quantityDifferences = calculateQuantityDifferences({ previousOptions, updatedOptions, @@ -85,16 +96,6 @@ export const computeQuantityUpdateDetails = ({ }, }); - const feature = features.find( - (featureItem: Feature) => featureItem.internal_id === internalFeatureId, - ); - - if (!feature) { - throw new InternalError({ - message: `[Quantity Update] Feature not found for internal_id: ${internalFeatureId}`, - }); - } - const product = cusProductToProduct({ cusProduct: customerProduct }); const stripeInvoiceItemDescription = usagePriceToLineDescription({ diff --git a/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeInvoiceAction.ts b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeInvoiceAction.ts new file mode 100644 index 000000000..397a1e702 --- /dev/null +++ b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeInvoiceAction.ts @@ -0,0 +1,74 @@ +import { + type FullCusProduct, + isCusProductTrialing, + isCustomerProductFree, + isCustomerProductOneOff, +} from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import type { + StripeInvoiceAction, + StripeSubscriptionAction, +} from "@/internal/billing/v2/billingPlan"; +import { buildAutumnLineItems } from "@/internal/billing/v2/compute/computeAutumnUtils/buildAutumnLineItems"; +import type { UpdateSubscriptionContext } from "@/internal/billing/v2/subscriptionUpdate/fetch/updateSubscriptionContextSchema"; +import { lineItemsToStripeLines } from "@/internal/billing/v2/utils/stripeAdapter/invoiceLines/lineItemsToStripeLines"; + +export const computeInvoiceAction = ({ + ctx, + billingContext, + newCustomerProduct, + stripeSubscriptionAction, + billingCycleAnchor, +}: { + ctx: AutumnContext; + billingContext: UpdateSubscriptionContext; + newCustomerProduct: FullCusProduct; + stripeSubscriptionAction?: StripeSubscriptionAction; + billingCycleAnchor?: number; +}): StripeInvoiceAction | undefined => { + if (isCusProductTrialing({ cusProduct: newCustomerProduct })) { + return undefined; + } + + /** + * Cases: + * One off -> Recurring (subscription created) + * One off -> Free...? (no subscription action) + * Free -> Recurring (subscription created) + * Free -> One off (invoice needed...?) + * Recurring -> Free (subscription canceled) + * Recurring -> One off (subscription canceled... need... invoice?) + */ + + const fromCustomerProduct = billingContext.customerProduct; + const toCustomerProduct = newCustomerProduct; + + if ( + isCustomerProductFree(fromCustomerProduct) && + isCustomerProductOneOff(toCustomerProduct) + ) { + return undefined; + } + + // If subscription action is update, we need to create an invoice + const stripeSubscriptionActionType = stripeSubscriptionAction?.type; + if (stripeSubscriptionActionType === "update") { + const autumnLineItems = buildAutumnLineItems({ + ctx, + newCusProducts: [toCustomerProduct], + ongoingCustomerProduct: fromCustomerProduct, + billingCycleAnchor, + testClockFrozenTime: billingContext.testClockFrozenTime, + }); + + const addLineParams = lineItemsToStripeLines({ + lineItems: autumnLineItems, + }); + + return { + addLineParams: { + lines: addLineParams, + }, + }; + } +}; diff --git a/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateCustomPlan.ts b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateCustomPlan.ts index 78702e74c..9d148a75d 100644 --- a/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateCustomPlan.ts +++ b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateCustomPlan.ts @@ -2,18 +2,16 @@ import { CusProductStatus, cusProductToProduct, type SubscriptionUpdateV0Params, + secondsToMs, } from "@autumn/shared"; import type { AutumnContext } from "@server/honoUtils/HonoEnv"; import type { UpdateSubscriptionContext } from "@server/internal/billing/v2/subscriptionUpdate/fetch/updateSubscriptionContextSchema"; import type { BillingPlan } from "@/internal/billing/v2/billingPlan"; -import { addStripeSubscriptionIdToBillingPlan } from "@/internal/billing/v2/execute/addStripeSubscriptionIdToBillingPlan"; -import { executeAutumnBillingPlan } from "@/internal/billing/v2/execute/executeAutumnBillingPlan"; +import { computeInvoiceAction } from "@/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeInvoiceAction"; import { computeSubscriptionUpdateFreeTrialPlan } from "@/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateFreeTrialPlan"; import { computeSubscriptionUpdateNewCustomerProduct } from "@/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateNewCustomerProduct"; import { computeSubscriptionUpdateStripeSubscriptionAction } from "@/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateStripeSubscriptionAction"; -import { logBillingPlan } from "@/internal/billing/v2/utils/logBillingPlan"; import { createStripeResourcesForProducts } from "@/internal/billing/v2/utils/stripeAdapter/createStripeResourcesForProduct"; -import { executeStripeSubscriptionAction } from "@/internal/billing/v2/utils/stripeAdapter/subscriptions/executeStripeSubscriptionAction"; import { computeCustomFullProduct } from "../../../compute/computeAutumnUtils/computeCustomFullProduct"; export const computeSubscriptionUpdateCustomPlan = async ({ @@ -25,7 +23,7 @@ export const computeSubscriptionUpdateCustomPlan = async ({ updateSubscriptionContext: UpdateSubscriptionContext; params: SubscriptionUpdateV0Params; }) => { - const { customerProduct } = updateSubscriptionContext; + const { customerProduct, stripeSubscription } = updateSubscriptionContext; const currentFullProduct = cusProductToProduct({ cusProduct: customerProduct, @@ -50,6 +48,10 @@ export const computeSubscriptionUpdateCustomPlan = async ({ fullProduct: customFullProduct, }); + const billingCycleAnchor = + freeTrialPlan.trialEndsAt ?? + secondsToMs(stripeSubscription?.billing_cycle_anchor); + // 3. Compute the new customer product const newFullCustomerProduct = computeSubscriptionUpdateNewCustomerProduct({ ctx, @@ -57,6 +59,7 @@ export const computeSubscriptionUpdateCustomPlan = async ({ params, fullProduct: customFullProduct, freeTrialPlan, + billingCycleAnchor, }); // 4. Create stripe prices @@ -76,9 +79,18 @@ export const computeSubscriptionUpdateCustomPlan = async ({ freeTrialPlan, }); + const stripeInvoiceAction = computeInvoiceAction({ + ctx, + billingContext: updateSubscriptionContext, + newCustomerProduct: newFullCustomerProduct, + stripeSubscriptionAction, + billingCycleAnchor, + }); + const billingPlan: BillingPlan = { stripe: { - subscription: stripeSubscriptionAction, + subscriptionAction: stripeSubscriptionAction, + invoiceAction: stripeInvoiceAction, }, autumn: { insertCustomerProducts: [newFullCustomerProduct], @@ -96,26 +108,51 @@ export const computeSubscriptionUpdateCustomPlan = async ({ }, }; - logBillingPlan({ ctx, billingPlan }); - - if (stripeSubscriptionAction) { - const updatedStripeSubscription = await executeStripeSubscriptionAction({ - ctx, - subscriptionAction: stripeSubscriptionAction, - }); - - if (updatedStripeSubscription) { - addStripeSubscriptionIdToBillingPlan({ - billingPlan, - stripeSubscriptionId: updatedStripeSubscription.id, - }); - } - } - - await executeAutumnBillingPlan({ - ctx, - autumnBillingPlan: billingPlan.autumn, - }); - return billingPlan; + + // logBillingPlan({ ctx, billingPlan }); + + // if (stripeInvoiceAction) { + // const result = await executeStripeInvoiceAction({ + // ctx, + // billingContext: updateSubscriptionContext, + // stripeInvoiceAction, + // }); + + // if (result.invoice) { + // await upsertInvoiceFromBilling({ + // ctx, + // stripeInvoice: result.invoice, + // fullProducts: [customFullProduct], + // fullCustomer: fullCustomer, + // }); + // } + // } + + // if (stripeSubscriptionAction) { + // const stripeSubscription = await executeStripeSubscriptionAction({ + // ctx, + // subscriptionAction: stripeSubscriptionAction, + // }); + + // if (stripeSubscription) { + // addStripeSubscriptionIdToBillingPlan({ + // billingPlan, + // stripeSubscriptionId: stripeSubscription.id, + // }); + + // // Add subscription to DB + // await upsertSubscriptionFromBilling({ + // ctx, + // stripeSubscription, + // }); + // } + // } + + // await executeAutumnBillingPlan({ + // ctx, + // autumnBillingPlan: billingPlan.autumn, + // }); + + // return billingPlan; }; diff --git a/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateNewCustomerProduct.ts b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateNewCustomerProduct.ts index 2e02815be..bc2063029 100644 --- a/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateNewCustomerProduct.ts +++ b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateNewCustomerProduct.ts @@ -1,8 +1,4 @@ -import { - type FullProduct, - type SubscriptionUpdateV0Params, - secondsToMs, -} from "@autumn/shared"; +import type { FullProduct, SubscriptionUpdateV0Params } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; import type { FreeTrialPlan } from "@/internal/billing/v2/billingPlan"; import { computeSubscriptionUpdateFeatureQuantities } from "@/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateFeatureQuantities"; @@ -17,12 +13,14 @@ export const computeSubscriptionUpdateNewCustomerProduct = ({ updateSubscriptionContext, fullProduct, freeTrialPlan, + billingCycleAnchor, }: { ctx: AutumnContext; params: SubscriptionUpdateV0Params; updateSubscriptionContext: UpdateSubscriptionContext; fullProduct: FullProduct; freeTrialPlan: FreeTrialPlan; + billingCycleAnchor?: number; }) => { const { customerProduct, @@ -48,11 +46,6 @@ export const computeSubscriptionUpdateNewCustomerProduct = ({ params, }); - // TODO: Move this to a separate function - const billingCycleAnchor = - freeTrialPlan.trialEndsAt ?? - secondsToMs(stripeSubscription?.billing_cycle_anchor); - const now = updateSubscriptionContext.testClockFrozenTime ?? Date.now(); // 1. Compute the new full customer product diff --git a/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdatePlan.ts b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdatePlan.ts index f3b5f8ae7..86587c1ac 100644 --- a/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdatePlan.ts +++ b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdatePlan.ts @@ -1,9 +1,10 @@ import type { SubscriptionUpdateV0Params } from "@shared/index"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; -import type { SubscriptionUpdatePlan } from "../../typesOld"; +import { computeSubscriptionUpdateCustomPlan } from "@/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateCustomPlan"; +import { computeSubscriptionUpdateQuantityPlan } from "@/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateQuantityPlan"; +import { SubscriptionUpdateIntentEnum } from "@/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateSchema"; import type { UpdateSubscriptionContext } from "../fetch/updateSubscriptionContextSchema"; import { computeSubscriptionUpdateIntent } from "./computeSubscriptionUpdateIntent"; -import { getComputeSubscriptionUpdatePlanFunction } from "./computeSubscriptionUpdatePlanIntentMap"; /** * Compute the subscription update plan @@ -11,7 +12,7 @@ import { getComputeSubscriptionUpdatePlanFunction } from "./computeSubscriptionU * @param params - The parameters for the subscription update * @returns The subscription update plan */ -export const computeSubscriptionUpdatePlan = ({ +export const computeSubscriptionUpdatePlan = async ({ ctx, updateSubscriptionContext, params, @@ -19,9 +20,21 @@ export const computeSubscriptionUpdatePlan = ({ ctx: AutumnContext; updateSubscriptionContext: UpdateSubscriptionContext; params: SubscriptionUpdateV0Params; -}): SubscriptionUpdatePlan => { +}) => { const intent = computeSubscriptionUpdateIntent(params); - const computePlan = getComputeSubscriptionUpdatePlanFunction(intent); - return computePlan({ ctx, updateSubscriptionContext, params }); + switch (intent) { + case SubscriptionUpdateIntentEnum.UpdateQuantity: + return computeSubscriptionUpdateQuantityPlan({ + ctx, + updateSubscriptionContext, + params, + }); + case SubscriptionUpdateIntentEnum.UpdatePlan: + return await computeSubscriptionUpdateCustomPlan({ + ctx, + updateSubscriptionContext, + params, + }); + } }; diff --git a/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateQuantityPlan.ts b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateQuantityPlan.ts index 1ac84a04f..bcaf71e49 100644 --- a/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateQuantityPlan.ts +++ b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateQuantityPlan.ts @@ -1,8 +1,9 @@ import { + InternalError, OngoingCusProductActionEnum, type SubscriptionUpdateV0Params, secondsToMs, -} from "@shared/index"; +} from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { buildAutumnLineItems } from "../../compute/computeAutumnUtils/buildAutumnLineItems"; import type { SubscriptionUpdateQuantityPlan } from "../../typesOld"; @@ -28,6 +29,12 @@ export const computeSubscriptionUpdateQuantityPlan = ({ paymentMethod, } = updateSubscriptionContext; + if (!stripeSubscription) { + throw new InternalError({ + message: `[Subscription Update] Stripe subscription not found`, + }); + } + const featureQuantities = { old: customerProduct.options, new: params.options || [], diff --git a/server/src/internal/billing/v2/subscriptionUpdate/handleSubscriptionUpdatePreview.ts b/server/src/internal/billing/v2/subscriptionUpdate/handleSubscriptionUpdatePreview.ts new file mode 100644 index 000000000..0e626fea3 --- /dev/null +++ b/server/src/internal/billing/v2/subscriptionUpdate/handleSubscriptionUpdatePreview.ts @@ -0,0 +1,25 @@ +import { SubscriptionUpdateV0ParamsSchema } from "@autumn/shared"; +import { createRoute } from "../../../../honoMiddlewares/routeHandler"; +import { computeSubscriptionUpdatePlan } from "../subscriptionUpdate/compute/computeSubscriptionUpdatePlan"; +import { fetchApiSubscriptionUpdateContext } from "../subscriptionUpdate/fetch/fetchApiSubscriptionUpdateContext"; + +export const handleSubscriptionUpdatePreview = createRoute({ + body: SubscriptionUpdateV0ParamsSchema, + handler: async (c) => { + const ctx = c.get("ctx"); + const body = c.req.valid("json"); + + const updateSubscriptionContext = await fetchApiSubscriptionUpdateContext({ + ctx, + params: body, + }); + + const subscriptionUpdatePlan = await computeSubscriptionUpdatePlan({ + ctx, + updateSubscriptionContext, + params: body, + }); + + return c.json(subscriptionUpdatePlan, 200); + }, +}); diff --git a/server/src/internal/billing/v2/typesOld.ts b/server/src/internal/billing/v2/typesOld.ts index f125bde2c..7e43b91ce 100644 --- a/server/src/internal/billing/v2/typesOld.ts +++ b/server/src/internal/billing/v2/typesOld.ts @@ -40,11 +40,6 @@ export type StripeSubAction = { items?: Stripe.SubscriptionUpdateParams.Item[]; }; -export type StripeInvoiceAction = { - items: Stripe.InvoiceAddLinesParams.Line[]; - onPaymentFailure: "return_url" | "checkout_session"; -}; - export type StripeCheckoutAction = { shouldCreate: boolean; reason?: string; diff --git a/server/src/internal/billing/v2/utils/logBillingPlan.ts b/server/src/internal/billing/v2/utils/logBillingPlan.ts index 99d252b7b..99dff2034 100644 --- a/server/src/internal/billing/v2/utils/logBillingPlan.ts +++ b/server/src/internal/billing/v2/utils/logBillingPlan.ts @@ -34,20 +34,6 @@ export const logBillingPlan = ({ } : undefined, }, - stripe: { - subscription: billingPlan.stripe.subscription - ? { - type: billingPlan.stripe.subscription.type, - stripeSubscriptionId: - billingPlan.stripe.subscription.type !== "create" - ? billingPlan.stripe.subscription.stripeSubscriptionId - : undefined, - params: - billingPlan.stripe.subscription.type !== "cancel" - ? billingPlan.stripe.subscription.params - : undefined, - } - : undefined, - }, + stripe: billingPlan.stripe, }); }; diff --git a/server/src/internal/billing/v2/utils/stripeAdapter/applyStripeDiscounts/applyAmountOffDiscountToLineItems.ts b/server/src/internal/billing/v2/utils/stripeAdapter/discounts/applyAmountOffDiscountToLineItems.ts similarity index 100% rename from server/src/internal/billing/v2/utils/stripeAdapter/applyStripeDiscounts/applyAmountOffDiscountToLineItems.ts rename to server/src/internal/billing/v2/utils/stripeAdapter/discounts/applyAmountOffDiscountToLineItems.ts diff --git a/server/src/internal/billing/v2/utils/stripeAdapter/applyStripeDiscounts/applyPercentOffDiscountToLineItems.ts b/server/src/internal/billing/v2/utils/stripeAdapter/discounts/applyPercentOffDiscountToLineItems.ts similarity index 100% rename from server/src/internal/billing/v2/utils/stripeAdapter/applyStripeDiscounts/applyPercentOffDiscountToLineItems.ts rename to server/src/internal/billing/v2/utils/stripeAdapter/discounts/applyPercentOffDiscountToLineItems.ts diff --git a/server/src/internal/billing/v2/utils/stripeAdapter/applyStripeDiscounts/applyStripeDiscountsToLineItems.ts b/server/src/internal/billing/v2/utils/stripeAdapter/discounts/applyStripeDiscountsToLineItems.ts similarity index 100% rename from server/src/internal/billing/v2/utils/stripeAdapter/applyStripeDiscounts/applyStripeDiscountsToLineItems.ts rename to server/src/internal/billing/v2/utils/stripeAdapter/discounts/applyStripeDiscountsToLineItems.ts diff --git a/server/src/internal/billing/v2/utils/stripeAdapter/applyStripeDiscounts/discountAppliesToLineItem.ts b/server/src/internal/billing/v2/utils/stripeAdapter/discounts/discountAppliesToLineItem.ts similarity index 100% rename from server/src/internal/billing/v2/utils/stripeAdapter/applyStripeDiscounts/discountAppliesToLineItem.ts rename to server/src/internal/billing/v2/utils/stripeAdapter/discounts/discountAppliesToLineItem.ts diff --git a/server/src/internal/billing/v2/utils/stripeAdapter/applyStripeDiscounts/subToDiscounts.ts b/server/src/internal/billing/v2/utils/stripeAdapter/discounts/subToDiscounts.ts similarity index 100% rename from server/src/internal/billing/v2/utils/stripeAdapter/applyStripeDiscounts/subToDiscounts.ts rename to server/src/internal/billing/v2/utils/stripeAdapter/discounts/subToDiscounts.ts diff --git a/server/src/internal/billing/v2/utils/stripeAdapter/stripeInvoiceOps/lineItemsToStripeLines.ts b/server/src/internal/billing/v2/utils/stripeAdapter/invoiceLines/lineItemsToStripeLines.ts similarity index 100% rename from server/src/internal/billing/v2/utils/stripeAdapter/stripeInvoiceOps/lineItemsToStripeLines.ts rename to server/src/internal/billing/v2/utils/stripeAdapter/invoiceLines/lineItemsToStripeLines.ts diff --git a/server/src/internal/billing/v2/utils/stripeAdapter/subscriptions/buildStripeSubscriptionUpdateAction.ts b/server/src/internal/billing/v2/utils/stripeAdapter/subscriptions/buildStripeSubscriptionUpdateAction.ts index 60343ca3b..0d918ce0c 100644 --- a/server/src/internal/billing/v2/utils/stripeAdapter/subscriptions/buildStripeSubscriptionUpdateAction.ts +++ b/server/src/internal/billing/v2/utils/stripeAdapter/subscriptions/buildStripeSubscriptionUpdateAction.ts @@ -1,6 +1,6 @@ import { msToSeconds } from "@shared/utils/common/unixUtils"; import type Stripe from "stripe"; -import { isStripeSubscriptionCancelling } from "@/external/stripe/subscriptions/utils/classifyStripeSubscriptionUtils"; +import { isStripeSubscriptionCanceling } from "@/external/stripe/subscriptions/utils/classifyStripeSubscriptionUtils"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; import type { BillingContext } from "@/internal/billing/v2/billingContext"; import type { FreeTrialPlan } from "@/internal/billing/v2/billingPlan"; @@ -25,7 +25,7 @@ export const buildStripeSubscriptionUpdateAction = ({ } const trialEndsAt = freeTrialPlan?.trialEndsAt; - const cancelAtPeriodEnd = isStripeSubscriptionCancelling(stripeSubscription) + const cancelAtPeriodEnd = isStripeSubscriptionCanceling(stripeSubscription) ? false : undefined; diff --git a/server/src/internal/billing/v2/utils/upsertInvoiceFromBilling.ts b/server/src/internal/billing/v2/utils/upsertInvoiceFromBilling.ts new file mode 100644 index 000000000..5b8cd4ff1 --- /dev/null +++ b/server/src/internal/billing/v2/utils/upsertInvoiceFromBilling.ts @@ -0,0 +1,55 @@ +import type { FullCustomer, FullProduct } from "@autumn/shared"; +import type Stripe from "stripe"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { InvoiceService } from "@/internal/invoices/InvoiceService"; +import { getInvoiceItems } from "@/internal/invoices/invoiceUtils"; + +export const upsertInvoiceFromBilling = async ({ + ctx, + stripeInvoice, + fullProducts, + fullCustomer, +}: { + ctx: AutumnContext; + stripeInvoice: Stripe.Invoice; + fullProducts: FullProduct[]; + fullCustomer: FullCustomer; +}) => { + const productIds = fullProducts.map((p) => p.id); + const internalProductIds = fullProducts.map((p) => p.internal_id); + + const internalCustomerId = fullCustomer.internal_id; + const internalEntityId = fullCustomer.entity?.internal_id; + + const autumnInvoiceItems = await getInvoiceItems({ + stripeInvoice, + prices: fullProducts.flatMap((p) => p.prices), + logger: ctx.logger, + }); + + // 1. Check if invoice exists in Autumn + const updatedInvoice = await InvoiceService.updateByStripeId({ + db: ctx.db, + stripeId: stripeInvoice.id, + updates: { + product_ids: productIds, + internal_product_ids: internalProductIds, + }, + }); + + if (updatedInvoice) return; + + // 2. Create invoice + const newInvoice = await InvoiceService.createInvoiceFromStripe({ + db: ctx.db, + stripeInvoice, + internalCustomerId, + internalEntityId, + org: ctx.org, + productIds, + internalProductIds, + items: autumnInvoiceItems, + }); + + return newInvoice; +}; diff --git a/server/src/internal/billing/v2/utils/upsertSubscriptionFromBilling.ts b/server/src/internal/billing/v2/utils/upsertSubscriptionFromBilling.ts new file mode 100644 index 000000000..3d74775a1 --- /dev/null +++ b/server/src/internal/billing/v2/utils/upsertSubscriptionFromBilling.ts @@ -0,0 +1,42 @@ +import type Stripe from "stripe"; +import { + getEarliestPeriodEnd, + getLatestPeriodStart, +} from "@/external/stripe/stripeSubUtils/convertSubUtils"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { SubService } from "@/internal/subscriptions/SubService"; +import { generateId } from "@/utils/genUtils"; + +export const upsertSubscriptionFromBilling = async ({ + ctx, + stripeSubscription, +}: { + ctx: AutumnContext; + stripeSubscription: Stripe.Subscription; +}) => { + // Store + const earliestPeriodEnd = getEarliestPeriodEnd({ sub: stripeSubscription }); + const currentPeriodStart = getLatestPeriodStart({ sub: stripeSubscription }); + + const updatedSubscription = await SubService.updateFromStripe({ + db: ctx.db, + stripeSub: stripeSubscription, + }); + + if (updatedSubscription) return; + + await SubService.createSub({ + db: ctx.db, + sub: { + id: generateId("sub"), + stripe_id: stripeSubscription.id, + stripe_schedule_id: stripeSubscription.schedule as string, + created_at: stripeSubscription.created * 1000, + usage_features: [], + org_id: ctx.org.id, + env: ctx.env, + current_period_start: currentPeriodStart, + current_period_end: earliestPeriodEnd, + }, + }); +}; diff --git a/server/src/internal/invoices/InvoiceService.ts b/server/src/internal/invoices/InvoiceService.ts index 2246c9631..f87cd2e32 100644 --- a/server/src/internal/invoices/InvoiceService.ts +++ b/server/src/internal/invoices/InvoiceService.ts @@ -2,6 +2,7 @@ import { type ApiInvoiceV1, type Customer, type Feature, + type InsertInvoice, type Invoice, type InvoiceItem, type InvoiceStatus, @@ -210,11 +211,11 @@ export class InvoiceService { }: { db: DrizzleCli; stripeId: string; - updates: Partial; + updates: Partial; }) { const results = await db .update(invoices) - .set(updates as any) + .set(updates) .where(eq(invoices.stripe_id, stripeId)) .returning(); diff --git a/server/src/internal/products/productUtils/productResponseUtils/getAttachScenario.ts b/server/src/internal/products/productUtils/productResponseUtils/getAttachScenario.ts index 6e7ef5f24..223a60c04 100644 --- a/server/src/internal/products/productUtils/productResponseUtils/getAttachScenario.ts +++ b/server/src/internal/products/productUtils/productResponseUtils/getAttachScenario.ts @@ -3,7 +3,7 @@ import { cusProductToProduct, type FullCustomer, type FullProduct, - isCusProductCanceled, + isCustomerProductCanceled, } from "@autumn/shared"; import { getExistingCusProducts } from "@/internal/customers/cusProducts/cusProductUtils/getExistingCusProducts.js"; @@ -43,7 +43,7 @@ export const getAttachScenario = ({ curSameProduct && curSameProduct.product.id !== curScheduledProduct?.product.id ) { - if (isCusProductCanceled({ cusProduct: curSameProduct })) { + if (isCustomerProductCanceled(curSameProduct)) { return AttachScenario.Renew; } else { return AttachScenario.Active; @@ -59,7 +59,7 @@ export const getAttachScenario = ({ // 1. If current product is the same as the product, return active if (curMainProduct?.product.id === fullProduct.id) { - if (isCusProductCanceled({ cusProduct: curMainProduct })) { + if (isCustomerProductCanceled(curMainProduct)) { return AttachScenario.Renew; } else return AttachScenario.Active; } diff --git a/shared/index.ts b/shared/index.ts index 97feece8c..fab3ed602 100644 --- a/shared/index.ts +++ b/shared/index.ts @@ -100,7 +100,7 @@ export * from "./models/attachModels/attachFunctionResponse.js"; export * from "./models/billingModels/cusProductActions.js"; export * from "./models/billingModels/existingRollovers.js"; export * from "./models/billingModels/existingUsages.js"; - +export * from "./models/billingModels/index.js"; export * from "./models/billingModels/initFullCustomerProductContext.js"; export * from "./models/billingModels/invoicingModels/lineItem.js"; // Billing Models diff --git a/shared/models/billingModels/index.ts b/shared/models/billingModels/index.ts index e69de29bb..16696ba2b 100644 --- a/shared/models/billingModels/index.ts +++ b/shared/models/billingModels/index.ts @@ -0,0 +1 @@ +export * from "./invoicingModels/lineItemContext"; diff --git a/shared/models/cusModels/invoiceModels/invoiceTable.ts b/shared/models/cusModels/invoiceModels/invoiceTable.ts index 5b2eea154..963ea9628 100644 --- a/shared/models/cusModels/invoiceModels/invoiceTable.ts +++ b/shared/models/cusModels/invoiceModels/invoiceTable.ts @@ -1,9 +1,9 @@ +import type { InferInsertModel, InferSelectModel } from "drizzle-orm"; import { foreignKey, jsonb, numeric, pgTable, text } from "drizzle-orm/pg-core"; import { collatePgColumn, sqlNow } from "../../../db/utils.js"; -import { InvoiceDiscount, InvoiceItem } from "./invoiceModels.js"; import { customers } from "../cusTable.js"; import { entities } from "../entityModels/entityTable.js"; -import { InferSelectModel, InferInsertModel } from "drizzle-orm"; +import type { InvoiceDiscount, InvoiceItem } from "./invoiceModels.js"; export const invoices = pgTable( "invoices", @@ -41,4 +41,4 @@ export const invoices = pgTable( collatePgColumn(invoices.id, "C"); export type InvoiceRow = InferSelectModel; -export type InsertInvoiceRow = InferInsertModel; +export type InsertInvoice = InferInsertModel; diff --git a/shared/utils/cusEntUtils/findCustomerEntitlement/findPrepaidCustomerEntitlement.ts b/shared/utils/cusEntUtils/findCustomerEntitlement/findPrepaidCustomerEntitlement.ts new file mode 100644 index 000000000..1c030490d --- /dev/null +++ b/shared/utils/cusEntUtils/findCustomerEntitlement/findPrepaidCustomerEntitlement.ts @@ -0,0 +1,19 @@ +import type { FullCusEntWithFullCusProduct } from "@models/cusProductModels/cusEntModels/cusEntWithProduct"; +import type { Feature } from "@models/featureModels/featureModels"; +import { isPrepaidCusEnt } from "@utils/cusEntUtils/cusEntUtils"; +import { cusEntMatchesFeature } from "@utils/cusEntUtils/filterCusEntUtils"; + +export const findPrepaidCustomerEntitlement = ({ + customerEntitlements, + feature, +}: { + customerEntitlements: FullCusEntWithFullCusProduct[]; + feature: Feature; +}) => { + // 1. Get prepaid customer entitlement + return customerEntitlements.find( + (entitlement) => + isPrepaidCusEnt({ cusEnt: entitlement }) && + cusEntMatchesFeature({ cusEnt: entitlement, feature }), + ); +}; diff --git a/shared/utils/cusProductUtils/classifyCusProduct.ts b/shared/utils/cusProductUtils/classifyCusProduct.ts index 5dbfaa46d..e8c446bcd 100644 --- a/shared/utils/cusProductUtils/classifyCusProduct.ts +++ b/shared/utils/cusProductUtils/classifyCusProduct.ts @@ -7,11 +7,7 @@ import { notNullish, nullish } from "../utils"; import { cusProductToPrices } from "./convertCusProduct"; import { ACTIVE_STATUSES } from "./cusProductConstants"; -export const isCusProductOneOff = ({ - cusProduct, -}: { - cusProduct?: FullCusProduct; -}) => { +export const isCustomerProductOneOff = (cusProduct?: FullCusProduct) => { if (!cusProduct) return false; const prices = cusProductToPrices({ cusProduct }); @@ -19,14 +15,18 @@ export const isCusProductOneOff = ({ return isOneOffProduct({ prices }); }; -export const isCusProductCanceled = ({ - cusProduct, -}: { - cusProduct?: FullCusProduct; -}) => { +export const isCustomerProductCanceled = (cusProduct?: FullCusProduct) => { if (!cusProduct) return false; - return cusProduct.canceled; + return notNullish(cusProduct.canceled_at); +}; + +export const isCustomerProductFree = (cusProduct?: FullCusProduct) => { + if (!cusProduct) return false; + + const prices = cusProductToPrices({ cusProduct }); + + return isFreeProduct({ prices }); }; export const isCusProductTrialing = ({ diff --git a/shared/utils/cusProductUtils/convertCusProduct.ts b/shared/utils/cusProductUtils/convertCusProduct.ts index bf19ddc44..39d837b20 100644 --- a/shared/utils/cusProductUtils/convertCusProduct.ts +++ b/shared/utils/cusProductUtils/convertCusProduct.ts @@ -163,3 +163,14 @@ export const cusProductToProduct = ({ free_trial: cusProduct.free_trial, } as FullProduct; }; + +export const cusProductToCusEnts = ({ + customerProduct, +}: { + customerProduct: FullCusProduct; +}): FullCusEntWithFullCusProduct[] => { + return customerProduct.customer_entitlements.map((cusEnt) => ({ + ...cusEnt, + customer_product: customerProduct, + })); +}; diff --git a/shared/utils/featureUtils/findFeatureUtils.ts b/shared/utils/featureUtils/findFeatureUtils.ts new file mode 100644 index 000000000..55a8c5105 --- /dev/null +++ b/shared/utils/featureUtils/findFeatureUtils.ts @@ -0,0 +1,11 @@ +import type { Feature } from "@models/featureModels/featureModels"; + +export const findFeatureByInternalId = ({ + features, + internalId, +}: { + features: Feature[]; + internalId: string; +}): Feature | undefined => { + return features.find((feature) => feature.internal_id === internalId); +}; diff --git a/shared/utils/index.ts b/shared/utils/index.ts index 3fcfcd0a8..e1d993998 100644 --- a/shared/utils/index.ts +++ b/shared/utils/index.ts @@ -24,6 +24,7 @@ export * from "./cusEntUtils/convertCusEntUtils/cusEntToCusPrice.js"; export * from "./cusEntUtils/convertCusEntUtils.js"; export * from "./cusEntUtils/cusEntUtils.js"; export * from "./cusEntUtils/filterCusEntUtils.js"; +export * from "./cusEntUtils/findCustomerEntitlement/findPrepaidCustomerEntitlement.js"; // Cus ent utils export * from "./cusEntUtils/getRolloverFields.js"; export * from "./cusEntUtils/getStartingBalance.js"; @@ -47,6 +48,7 @@ export * from "./cusUtils/fullCusUtils/getCusStripeSubCount.js"; export * from "./expandUtils.js"; export * from "./featureUtils/apiFeatureToDbFeature.js"; export * from "./featureUtils/convertFeatureUtils.js"; +export * from "./featureUtils/findFeatureUtils.js"; // Feature utils export * from "./featureUtils.js"; // INTERVAL UTILS diff --git a/vite/src/hooks/stores/useSheetStore.ts b/vite/src/hooks/stores/useSheetStore.ts index 3f8c0feda..84d97ec97 100644 --- a/vite/src/hooks/stores/useSheetStore.ts +++ b/vite/src/hooks/stores/useSheetStore.ts @@ -11,6 +11,7 @@ export type SheetType = | "attach-product" | "subscription-detail" | "subscription-update" + | "subscription-update-test" // TEST: Remove this line to revert | "balance-selection" | "balance-edit" | null; diff --git a/vite/src/views/customers2/components/sheets/SubscriptionUpdateTestSheet.tsx b/vite/src/views/customers2/components/sheets/SubscriptionUpdateTestSheet.tsx new file mode 100644 index 000000000..a7ddf5b35 --- /dev/null +++ b/vite/src/views/customers2/components/sheets/SubscriptionUpdateTestSheet.tsx @@ -0,0 +1,831 @@ +import { + type Entity, + type Feature, + type FrontendProduct, + type FullCusProduct, + type FullCustomer, + getProductItemDisplay, + type ProductItem, + type ProductV2, + type SubscriptionUpdateV0Params, + stripeToAtmnAmount, +} from "@autumn/shared"; +import { PencilSimple } from "@phosphor-icons/react"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { useEffect, useMemo, useState } from "react"; +import { useNavigate } from "react-router"; +import { Button } from "@/components/v2/buttons/Button"; +import { IconButton } from "@/components/v2/buttons/IconButton"; +import { SheetHeader } from "@/components/v2/sheets/InlineSheet"; +import { usePrepaidItems } from "@/hooks/stores/useProductStore"; +import { useSheetStore } from "@/hooks/stores/useSheetStore"; +import { useSubscriptionById } from "@/hooks/stores/useSubscriptionStore"; +import { useAxiosInstance } from "@/services/useAxiosInstance"; +import { pushPage } from "@/utils/genUtils"; +import { useCusQuery } from "@/views/customers/customer/hooks/useCusQuery"; + +/** + * TEST SHEET: SubscriptionUpdateTestSheet + * + * This is an isolated test sheet for testing the subscription update flow. + * It calls: + * - POST /v1/subscriptions/preview/update - to get a billing plan preview + * - POST /v1/subscriptions/update - to execute the update + * + * Usage: Open this sheet with an itemId (cusProduct id) and optional customizedProduct in data + */ + +interface PrepaidEditorProps { + prepaidItems: Array<{ + feature_id?: string | null; + feature?: { internal_id: string } | undefined; + }>; + prepaidOptions: Record; + onPrepaidChange: (featureId: string, quantity: number) => void; +} + +function PrepaidEditor({ + prepaidItems, + prepaidOptions, + onPrepaidChange, +}: PrepaidEditorProps) { + if (prepaidItems.length === 0) return null; + + return ( +
+
+

Prepaid Quantities

+
+
+ {prepaidItems.map((item) => { + const featureId = item.feature_id ?? item.feature?.internal_id ?? ""; + const inputId = `prepaid-${featureId}`; + return ( +
+ + + onPrepaidChange(featureId, parseInt(e.target.value, 10) || 0) + } + className="w-20 px-2 py-1 border border-border rounded text-sm bg-transparent" + /> +
+ ); + })} +
+
+ ); +} + +interface BillingPlanData { + autumn?: { + insertCustomerProducts?: Array<{ + product?: { name?: string }; + customer_entitlements?: Array<{ + feature_id?: string; + balance?: number; + entitlement?: { feature?: { name?: string } }; + }>; + }>; + updateCustomerProduct?: { + customerProduct?: { product?: { name?: string } }; + updates?: Record; + }; + customPrices?: unknown[]; + customEntitlements?: unknown[]; + }; + stripe?: { + subscriptionAction?: { + type?: string; + stripeSubscriptionId?: string; + params?: { + items?: Array<{ + id?: string; + price?: string; + quantity?: number; + deleted?: boolean; + }>; + trial_end?: number; + proration_behavior?: string; + cancel_at_period_end?: boolean; + }; + }; + invoiceAction?: { + addLineParams?: { + lines?: Array<{ + description?: string; + amount?: number; + }>; + }; + }; + }; +} + +interface PreviewResultProps { + data: unknown; + isLoading: boolean; + error: Error | null; +} + +function PreviewResult({ data, isLoading, error }: PreviewResultProps) { + const billingPlan = data as BillingPlanData | null; + + return ( +
+
+

Billing Plan Preview

+
+ + {isLoading ? ( +
+ Loading preview... +
+ ) : null} + + {error ? ( +
+ Error: {error.message} +
+ ) : null} + + {!data && !isLoading && !error ? ( +
+ No preview data yet +
+ ) : null} + + {billingPlan && !isLoading ? ( +
+ {/* Insert Customer Products */} + {billingPlan.autumn?.insertCustomerProducts && + billingPlan.autumn.insertCustomerProducts.length > 0 ? ( +
+

+ ๐Ÿ“ฅ Inserting Customer Product +

+ {billingPlan.autumn.insertCustomerProducts.map((cp, index) => ( +
+
+ {cp.product?.name || "Unknown Product"} +
+ {cp.customer_entitlements && + cp.customer_entitlements.length > 0 ? ( +
+ Balances: + {cp.customer_entitlements.map((ent, entIndex) => ( + + {ent.entitlement?.feature?.name || ent.feature_id}:{" "} + {ent.balance} + {entIndex < + (cp.customer_entitlements?.length ?? 0) - 1 + ? ", " + : ""} + + ))} +
+ ) : null} +
+ ))} +
+ ) : null} + + {/* Update Customer Product */} + {(() => { + const updateCusProduct = billingPlan.autumn?.updateCustomerProduct; + if (!updateCusProduct) return null; + return ( +
+

+ โœ๏ธ Updating Customer Product +

+
+
+ {updateCusProduct.customerProduct?.product?.name || + "Unknown Product"} +
+ {updateCusProduct.updates ? ( +
+ Updates: + + {JSON.stringify(updateCusProduct.updates)} + +
+ ) : null} +
+
+ ); + })()} + + {/* Stripe Subscription Action */} + {billingPlan.stripe?.subscriptionAction ? ( +
+

+ ๐Ÿ’ณ Stripe Subscription Action +

+
+ + Type: + + {billingPlan.stripe.subscriptionAction.type || "none"} + + + {billingPlan.stripe.subscriptionAction.stripeSubscriptionId ? ( + + Sub: + + { + billingPlan.stripe.subscriptionAction + .stripeSubscriptionId + } + + + ) : null} +
+ {/* Subscription Items */} + {billingPlan.stripe.subscriptionAction.params?.items && + billingPlan.stripe.subscriptionAction.params.items.length > 0 ? ( +
+
+ Items: +
+ {billingPlan.stripe.subscriptionAction.params.items.map( + (item, index) => ( +
+ {item.deleted ? ( + <> + + {item.id || "Unknown item"} + + ๐Ÿ—‘๏ธ delete + + ) : ( + <> + + {item.price || item.id || "Unknown"} + + + qty: {item.quantity ?? 1} + + + )} +
+ ), + )} +
+ ) : null} + {/* Other params */} + {billingPlan.stripe.subscriptionAction.params?.trial_end ? ( +
+ Trial ends: + + {new Date( + billingPlan.stripe.subscriptionAction.params.trial_end * + 1000, + ).toLocaleDateString()} + +
+ ) : null} + {billingPlan.stripe.subscriptionAction.params ? ( +
+ + View raw params + +
+										{JSON.stringify(
+											billingPlan.stripe.subscriptionAction.params,
+											null,
+											2,
+										)}
+									
+
+ ) : null} +
+ ) : null} + + {/* Stripe Invoice Action */} + {billingPlan.stripe?.invoiceAction ? ( +
+

+ ๐Ÿงพ Stripe Invoice Action +

+ {billingPlan.stripe.invoiceAction.addLineParams?.lines && + billingPlan.stripe.invoiceAction.addLineParams.lines.length > + 0 ? ( +
+ {billingPlan.stripe.invoiceAction.addLineParams.lines.map( + ( + line: { + description?: string; + amount?: number; + }, + index: number, + ) => { + const amount = line.amount + ? stripeToAtmnAmount({ + amount: line.amount, + currency: "usd", + }) + : 0; + return ( +
+ + {line.description || "Line item"} + + = 0 ? "text-green-400" : "text-red-400" + } + > + ${amount.toFixed(2)} + +
+ ); + }, + )} +
+ ) : ( +
No line items
+ )} +
+ + View raw params + +
+									{JSON.stringify(billingPlan.stripe.invoiceAction, null, 2)}
+								
+
+
+ ) : null} + + {/* Empty Stripe section indicator */} + {billingPlan.stripe && + !billingPlan.stripe.subscriptionAction && + !billingPlan.stripe.invoiceAction ? ( +
+ No Stripe actions required +
+ ) : null} + + {/* Raw JSON toggle */} +
+ + View raw JSON + +
+							{JSON.stringify(data, null, 2)}
+						
+
+
+ ) : null} +
+ ); +} + +interface UpdateResultProps { + data: unknown; + isLoading: boolean; + error: Error | null; +} + +function UpdateResult({ data, isLoading, error }: UpdateResultProps) { + if (!data && !isLoading && !error) return null; + + return ( +
+
+

Update Response

+
+ {isLoading ? ( +
Updating...
+ ) : null} + {error ? ( +
+ Error: {error.message} +
+ ) : null} + {data !== null && data !== undefined && !isLoading ? ( +
+
โœ“ Success
+
+						{JSON.stringify(data, null, 2)}
+					
+
+ ) : null} +
+ ); +} + +function useSubscriptionUpdatePreview({ + body, + enabled, +}: { + body: SubscriptionUpdateV0Params | null; + enabled: boolean; +}) { + const axiosInstance = useAxiosInstance(); + + // Debounce the body to avoid too many API calls + const [debouncedBody, setDebouncedBody] = useState(body); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedBody(body); + }, 300); + return () => clearTimeout(timer); + }, [body]); + + const isDebouncing = JSON.stringify(body) !== JSON.stringify(debouncedBody); + + const query = useQuery({ + queryKey: [ + "subscription-update-preview-test", + JSON.stringify(debouncedBody), + ], + queryFn: async () => { + if (!debouncedBody) return null; + const response = await axiosInstance.post( + "/v1/subscriptions/preview/update", + debouncedBody, + ); + return response.data; + }, + enabled: enabled && !!debouncedBody, + retry: false, + }); + + return { + ...query, + isLoading: query.isLoading || isDebouncing, + }; +} + +function useSubscriptionUpdate() { + const axiosInstance = useAxiosInstance(); + + return useMutation({ + mutationFn: async (body: SubscriptionUpdateV0Params) => { + const response = await axiosInstance.post( + "/v1/subscriptions/update", + body, + ); + return response.data; + }, + }); +} + +function SheetContent({ + cusProduct, + productV2, + customizedProduct, +}: { + cusProduct: FullCusProduct; + productV2: ProductV2; + customizedProduct: FrontendProduct | undefined; +}) { + const navigate = useNavigate(); + const { customer, features } = useCusQuery(); + const customerId = customer?.id ?? customer?.internal_id; + const entityId = cusProduct?.entity_id ?? undefined; + + const product = customizedProduct?.id ? customizedProduct : productV2; + const { prepaidItems } = usePrepaidItems({ product }); + + // Get display info for custom items + const getItemDisplay = (item: ProductItem) => { + return getProductItemDisplay({ + item, + features: (features as Feature[]) ?? [], + currency: "usd", + }); + }; + + // Handle Edit Plan - navigates to plan editor + const handleEditPlan = () => { + if (!cusProduct || !customer) return; + + const entity = (customer as FullCustomer).entities?.find( + (e: Entity) => + e.internal_id === cusProduct.internal_entity_id || + e.id === cusProduct.entity_id, + ); + + pushPage({ + path: `/customers/${customer.id || customer.internal_id}/${cusProduct.product_id}`, + queryParams: { + id: cusProduct.id, + entity_id: entity ? entity.id || entity.internal_id : undefined, + version: String(cusProduct.product.version), + }, + navigate, + }); + }; + + // Get initial prepaid values from the current subscription + const initialPrepaidOptions = useMemo(() => { + return cusProduct.options.reduce( + (acc, option) => { + acc[option.feature_id] = option.quantity; + return acc; + }, + {} as Record, + ); + }, [cusProduct.options]); + + const [prepaidOptions, setPrepaidOptions] = useState>( + initialPrepaidOptions, + ); + + const handlePrepaidChange = (featureId: string, quantity: number) => { + setPrepaidOptions((prev) => ({ + ...prev, + [featureId]: quantity, + })); + }; + + // Build the request body + const requestBody = useMemo(() => { + if (!customerId) return null; + + const body: SubscriptionUpdateV0Params = { + customer_id: customerId, + product_id: product?.id, + entity_id: entityId, + customer_product_id: cusProduct.id ?? cusProduct.internal_product_id, + }; + + // Add options if there are prepaid items with quantities set + if (prepaidItems.length > 0) { + const options = prepaidItems + .map((item) => { + const featureId = item.feature_id ?? item.feature?.internal_id ?? ""; + const quantity = prepaidOptions[featureId]; + if (quantity !== undefined && quantity !== null && featureId) { + return { feature_id: featureId, quantity }; + } + return null; + }) + .filter(Boolean); + + if (options.length > 0) { + body.options = options as Array<{ + feature_id: string; + quantity: number; + }>; + } + } + + // Add custom items if we have a customized product + if (customizedProduct?.items) { + body.items = customizedProduct.items; + } + + // Add free trial if we have a customized product with free trial + if (customizedProduct?.free_trial) { + body.free_trial = customizedProduct.free_trial; + } + + return body; + }, [ + customerId, + product?.id, + entityId, + cusProduct.id, + cusProduct.internal_product_id, + prepaidItems, + prepaidOptions, + customizedProduct?.items, + customizedProduct?.free_trial, + ]); + + // Preview query - fires when body changes + const previewQuery = useSubscriptionUpdatePreview({ + body: requestBody, + enabled: !!requestBody, + }); + + // Update mutation + const updateMutation = useSubscriptionUpdate(); + + const handleConfirm = () => { + if (!requestBody) return; + updateMutation.mutate(requestBody); + }; + + return ( +
+ + } + > + Edit Plan + + + +
+ {/* Request Body Display */} +
+
+

Request Body

+
+
+
+ customer_id: + {requestBody?.customer_id} +
+
+ product_id: + {requestBody?.product_id} +
+ {requestBody?.entity_id ? ( +
+ entity_id: + {requestBody.entity_id} +
+ ) : null} + {requestBody?.options && requestBody.options.length > 0 ? ( +
+ options: + + {requestBody.options + .map((o) => `${o.feature_id}: ${o.quantity}`) + .join(", ")} + +
+ ) : null} + {requestBody?.items && requestBody.items.length > 0 ? ( +
+ items: +
+ {requestBody.items.map((item, index) => { + const display = getItemDisplay(item as ProductItem); + return ( +
+ + {display.primary_text} + + {display.secondary_text ? ( + + {display.secondary_text} + + ) : null} +
+ ); + })} +
+
+ ) : null} + {requestBody?.free_trial ? ( +
+ free_trial: + + + {requestBody.free_trial.length}{" "} + {requestBody.free_trial.duration} + {Number(requestBody.free_trial.length) > 1 ? "s" : ""} + + + (card_required:{" "} + {requestBody.free_trial.card_required ? "true" : "false"}) + + +
+ ) : null} +
+ + View raw JSON + +
+								{JSON.stringify(requestBody, null, 2)}
+							
+
+
+
+ + {/* Prepaid Editor */} + + + {/* Preview Result */} + + + {/* Update Result */} + +
+ + {/* Footer Actions */} +
+ + +
+
+ ); +} + +/** + * Main sheet component. + * + * To use this sheet, you need to: + * 1. Add "subscription-update-test" to the SheetType union in useSheetStore.ts + * 2. Add a case for it in CustomerSheets.tsx + * 3. Or, for quick testing, temporarily replace SubscriptionUpdateSheet import + * + * Example trigger: + * setSheet({ + * type: "subscription-update-test", + * itemId: cusProduct.id, + * data: { customizedProduct: product } // optional + * }) + */ +export function SubscriptionUpdateTestSheet() { + const itemId = useSheetStore((s) => s.itemId); + const sheetData = useSheetStore((s) => s.data); + + const { cusProduct, productV2 } = useSubscriptionById({ itemId }); + + const customizedProduct = sheetData?.customizedProduct as + | FrontendProduct + | undefined; + + if (!cusProduct) { + return ( +
+ +
+ No customer product found for itemId: {itemId} +
+
+ ); + } + + if (!productV2) { + return ( +
+ +
+ ); + } + + return ( + + ); +} diff --git a/vite/src/views/customers2/components/table/customer-products/CustomerProductsTable.tsx b/vite/src/views/customers2/components/table/customer-products/CustomerProductsTable.tsx index 02de14ca1..901d0c54c 100644 --- a/vite/src/views/customers2/components/table/customer-products/CustomerProductsTable.tsx +++ b/vite/src/views/customers2/components/table/customer-products/CustomerProductsTable.tsx @@ -173,7 +173,7 @@ export function CustomerProductsTable() { const handleRowClick = (cusProduct: FullCusProduct) => { setSheet({ - type: "subscription-detail", + type: "subscription-update-test", // SWAP: Change back to "subscription-detail" to revert itemId: cusProduct.id, }); }; diff --git a/vite/src/views/customers2/customer-plan/CustomerPlanEditorBar.tsx b/vite/src/views/customers2/customer-plan/CustomerPlanEditorBar.tsx index 87bf3aea8..cb351f4c6 100644 --- a/vite/src/views/customers2/customer-plan/CustomerPlanEditorBar.tsx +++ b/vite/src/views/customers2/customer-plan/CustomerPlanEditorBar.tsx @@ -1,5 +1,4 @@ import { parseAsInteger, parseAsString, useQueryStates } from "nuqs"; -import { useEffect } from "react"; import { useNavigate } from "react-router"; import { Button } from "@/components/v2/buttons/Button"; import { ShortcutButton } from "@/components/v2/buttons/ShortcutButton"; @@ -60,7 +59,9 @@ export const CustomerPlanEditorBar = () => { } else { // We have a subscription ID, so we're editing an existing subscription setSheet({ - type: changesMade ? "subscription-update" : "subscription-detail", + type: changesMade + ? "subscription-update-test" + : "subscription-update-test", // SWAP: Change back to "subscription-update" : "subscription-detail" to revert itemId: queryStates.id, data: changesMade ? { customizedProduct: product } : null, }); diff --git a/vite/src/views/customers2/customer/CustomerSheets.tsx b/vite/src/views/customers2/customer/CustomerSheets.tsx index eba5a9c59..2352c887b 100644 --- a/vite/src/views/customers2/customer/CustomerSheets.tsx +++ b/vite/src/views/customers2/customer/CustomerSheets.tsx @@ -11,6 +11,7 @@ import { BalanceEditSheet } from "../components/sheets/BalanceEditSheet"; import { BalanceSelectionSheet } from "../components/sheets/BalanceSelectionSheet"; import { SubscriptionDetailSheet } from "../components/sheets/SubscriptionDetailSheet"; import { SubscriptionUpdateSheet } from "../components/sheets/SubscriptionUpdateSheet"; +import { SubscriptionUpdateTestSheet } from "../components/sheets/SubscriptionUpdateTestSheet"; // TEST: Remove this line to revert import { SHEET_ANIMATION } from "./customerAnimations"; export function CustomerSheets() { @@ -32,6 +33,8 @@ export function CustomerSheets() { return ; case "subscription-update": return ; + case "subscription-update-test": // TEST: Remove this case to revert + return ; case "balance-selection": return ; case "balance-edit":