created test subscription update sheet, defined possible quantity update line item function

This commit is contained in:
John Yeo
2025-12-23 10:11:27 +00:00
parent 4c54993edc
commit eab0e23032
41 changed files with 1330 additions and 154 deletions

View File

@@ -7,7 +7,7 @@ export const isStripeSubscriptionTrialing = (
return stripeSubscription.status === "trialing";
};
export const isStripeSubscriptionCancelling = (
export const isStripeSubscriptionCanceling = (
stripeSubscription?: Stripe.Subscription,
) => {
if (!stripeSubscription) {

View File

@@ -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,
);

View File

@@ -30,12 +30,19 @@ export const StripeSubscriptionActionSchema = z.discriminatedUnion("type", [
}),
]);
export const StripeInvoiceActionSchema = z.object({
addLineParams: z.custom<import("stripe").Stripe.InvoiceAddLinesParams>(),
});
export type StripeInvoiceAction = z.infer<typeof StripeInvoiceActionSchema>;
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({

View File

@@ -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,

View File

@@ -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;
};

View File

@@ -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;
}

View File

@@ -14,7 +14,7 @@ export const handleApiSubscriptionUpdate = createRoute({
params: body,
});
const subscriptionUpdatePlan = computeSubscriptionUpdatePlan({
const subscriptionUpdatePlan = await computeSubscriptionUpdatePlan({
ctx,
updateSubscriptionContext,
params: body,

View File

@@ -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];
};

View File

@@ -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({

View File

@@ -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,
},
};
}
};

View File

@@ -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;
};

View File

@@ -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

View File

@@ -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,
});
}
};

View File

@@ -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 || [],

View File

@@ -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);
},
});

View File

@@ -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;

View File

@@ -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,
});
};

View File

@@ -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;

View File

@@ -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;
};

View File

@@ -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,
},
});
};

View File

@@ -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<Invoice>;
updates: Partial<InsertInvoice>;
}) {
const results = await db
.update(invoices)
.set(updates as any)
.set(updates)
.where(eq(invoices.stripe_id, stripeId))
.returning();

View File

@@ -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;
}

View File

@@ -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

View File

@@ -0,0 +1 @@
export * from "./invoicingModels/lineItemContext";

View File

@@ -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<typeof invoices>;
export type InsertInvoiceRow = InferInsertModel<typeof invoices>;
export type InsertInvoice = InferInsertModel<typeof invoices>;

View File

@@ -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 }),
);
};

View File

@@ -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 = ({

View File

@@ -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,
}));
};

View File

@@ -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);
};

View File

@@ -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

View File

@@ -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;

View File

@@ -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<string, number>;
onPrepaidChange: (featureId: string, quantity: number) => void;
}
function PrepaidEditor({
prepaidItems,
prepaidOptions,
onPrepaidChange,
}: PrepaidEditorProps) {
if (prepaidItems.length === 0) return null;
return (
<div className="border-b border-border">
<div className="px-4 py-2 border-b border-border">
<h3 className="text-sm font-medium">Prepaid Quantities</h3>
</div>
<div className="px-4 py-3 space-y-2">
{prepaidItems.map((item) => {
const featureId = item.feature_id ?? item.feature?.internal_id ?? "";
const inputId = `prepaid-${featureId}`;
return (
<div key={featureId} className="flex items-center gap-3">
<label
htmlFor={inputId}
className="text-sm text-t-secondary flex-1"
>
{featureId}
</label>
<input
id={inputId}
type="number"
min={0}
value={prepaidOptions[featureId] ?? 0}
onChange={(e) =>
onPrepaidChange(featureId, parseInt(e.target.value, 10) || 0)
}
className="w-20 px-2 py-1 border border-border rounded text-sm bg-transparent"
/>
</div>
);
})}
</div>
</div>
);
}
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<string, unknown>;
};
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 (
<div className="border-b border-border">
<div className="px-4 py-2 border-b border-border">
<h3 className="text-sm font-medium">Billing Plan Preview</h3>
</div>
{isLoading ? (
<div className="px-4 py-3 text-sm text-t-secondary">
Loading preview...
</div>
) : null}
{error ? (
<div className="px-4 py-3 text-sm text-red-400">
Error: {error.message}
</div>
) : null}
{!data && !isLoading && !error ? (
<div className="px-4 py-3 text-sm text-t-secondary">
No preview data yet
</div>
) : null}
{billingPlan && !isLoading ? (
<div className="divide-y divide-border">
{/* Insert Customer Products */}
{billingPlan.autumn?.insertCustomerProducts &&
billingPlan.autumn.insertCustomerProducts.length > 0 ? (
<div className="px-4 py-3 border-l-2 border-l-green-500">
<h4 className="text-xs font-semibold text-green-400 mb-1">
📥 Inserting Customer Product
</h4>
{billingPlan.autumn.insertCustomerProducts.map((cp, index) => (
<div key={index} className="text-sm">
<div className="font-medium">
{cp.product?.name || "Unknown Product"}
</div>
{cp.customer_entitlements &&
cp.customer_entitlements.length > 0 ? (
<div className="mt-1 text-xs text-t-secondary">
<span className="font-medium">Balances: </span>
{cp.customer_entitlements.map((ent, entIndex) => (
<span key={entIndex}>
{ent.entitlement?.feature?.name || ent.feature_id}:{" "}
{ent.balance}
{entIndex <
(cp.customer_entitlements?.length ?? 0) - 1
? ", "
: ""}
</span>
))}
</div>
) : null}
</div>
))}
</div>
) : null}
{/* Update Customer Product */}
{(() => {
const updateCusProduct = billingPlan.autumn?.updateCustomerProduct;
if (!updateCusProduct) return null;
return (
<div className="px-4 py-3 border-l-2 border-l-amber-500">
<h4 className="text-xs font-semibold text-amber-400 mb-1">
Updating Customer Product
</h4>
<div className="text-sm">
<div className="font-medium">
{updateCusProduct.customerProduct?.product?.name ||
"Unknown Product"}
</div>
{updateCusProduct.updates ? (
<div className="mt-1 text-xs text-t-secondary">
<span className="font-medium">Updates: </span>
<code className="text-amber-400">
{JSON.stringify(updateCusProduct.updates)}
</code>
</div>
) : null}
</div>
</div>
);
})()}
{/* Stripe Subscription Action */}
{billingPlan.stripe?.subscriptionAction ? (
<div className="px-4 py-3 border-l-2 border-l-blue-500">
<h4 className="text-xs font-semibold text-blue-400 mb-1">
💳 Stripe Subscription Action
</h4>
<div className="text-sm flex items-center gap-3">
<span>
<span className="text-t-secondary">Type: </span>
<span className="font-medium">
{billingPlan.stripe.subscriptionAction.type || "none"}
</span>
</span>
{billingPlan.stripe.subscriptionAction.stripeSubscriptionId ? (
<span>
<span className="text-t-secondary">Sub: </span>
<code className="text-xs text-blue-400">
{
billingPlan.stripe.subscriptionAction
.stripeSubscriptionId
}
</code>
</span>
) : null}
</div>
{/* Subscription Items */}
{billingPlan.stripe.subscriptionAction.params?.items &&
billingPlan.stripe.subscriptionAction.params.items.length > 0 ? (
<div className="mt-2 space-y-1">
<div className="text-xs text-t-secondary font-medium">
Items:
</div>
{billingPlan.stripe.subscriptionAction.params.items.map(
(item, index) => (
<div
key={index}
className={`flex justify-between text-xs pl-2 border-l ${
item.deleted
? "border-red-500/30"
: "border-blue-500/30"
}`}
>
{item.deleted ? (
<>
<span className="text-red-400 font-mono">
{item.id || "Unknown item"}
</span>
<span className="text-red-400">🗑 delete</span>
</>
) : (
<>
<span className="text-t-primary font-mono">
{item.price || item.id || "Unknown"}
</span>
<span className="text-blue-400">
qty: {item.quantity ?? 1}
</span>
</>
)}
</div>
),
)}
</div>
) : null}
{/* Other params */}
{billingPlan.stripe.subscriptionAction.params?.trial_end ? (
<div className="mt-1 text-xs text-t-secondary">
<span>Trial ends: </span>
<span className="text-green-400">
{new Date(
billingPlan.stripe.subscriptionAction.params.trial_end *
1000,
).toLocaleDateString()}
</span>
</div>
) : null}
{billingPlan.stripe.subscriptionAction.params ? (
<details className="mt-1">
<summary className="text-xs cursor-pointer text-t-secondary hover:text-t-primary">
View raw params
</summary>
<pre className="text-xs bg-t-50 p-2 rounded mt-1 overflow-auto max-h-32">
{JSON.stringify(
billingPlan.stripe.subscriptionAction.params,
null,
2,
)}
</pre>
</details>
) : null}
</div>
) : null}
{/* Stripe Invoice Action */}
{billingPlan.stripe?.invoiceAction ? (
<div className="px-4 py-3 border-l-2 border-l-purple-500">
<h4 className="text-xs font-semibold text-purple-400 mb-1">
🧾 Stripe Invoice Action
</h4>
{billingPlan.stripe.invoiceAction.addLineParams?.lines &&
billingPlan.stripe.invoiceAction.addLineParams.lines.length >
0 ? (
<div className="space-y-1">
{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 (
<div
key={index}
className="flex justify-between text-xs"
>
<span className="text-t-primary">
{line.description || "Line item"}
</span>
<span
className={
amount >= 0 ? "text-green-400" : "text-red-400"
}
>
${amount.toFixed(2)}
</span>
</div>
);
},
)}
</div>
) : (
<div className="text-xs text-t-secondary">No line items</div>
)}
<details className="mt-1">
<summary className="text-xs cursor-pointer text-t-secondary hover:text-t-primary">
View raw params
</summary>
<pre className="text-xs bg-t-50 p-2 rounded mt-1 overflow-auto max-h-32">
{JSON.stringify(billingPlan.stripe.invoiceAction, null, 2)}
</pre>
</details>
</div>
) : null}
{/* Empty Stripe section indicator */}
{billingPlan.stripe &&
!billingPlan.stripe.subscriptionAction &&
!billingPlan.stripe.invoiceAction ? (
<div className="px-4 py-2 text-xs text-t-secondary">
No Stripe actions required
</div>
) : null}
{/* Raw JSON toggle */}
<details className="px-4 py-2 text-xs">
<summary className="cursor-pointer text-t-secondary hover:text-t-primary">
View raw JSON
</summary>
<pre className="bg-t-50 p-2 rounded mt-1 overflow-auto max-h-40">
{JSON.stringify(data, null, 2)}
</pre>
</details>
</div>
) : null}
</div>
);
}
interface UpdateResultProps {
data: unknown;
isLoading: boolean;
error: Error | null;
}
function UpdateResult({ data, isLoading, error }: UpdateResultProps) {
if (!data && !isLoading && !error) return null;
return (
<div className="border-b border-border">
<div className="px-4 py-2 border-b border-border">
<h3 className="text-sm font-medium">Update Response</h3>
</div>
{isLoading ? (
<div className="px-4 py-3 text-sm text-t-secondary">Updating...</div>
) : null}
{error ? (
<div className="px-4 py-3 text-sm text-red-400">
Error: {error.message}
</div>
) : null}
{data !== null && data !== undefined && !isLoading ? (
<div className="px-4 py-3 border-l-2 border-l-green-500">
<div className="text-xs text-green-400 mb-1"> Success</div>
<pre className="text-xs bg-t-50 p-2 rounded overflow-auto max-h-60">
{JSON.stringify(data, null, 2)}
</pre>
</div>
) : null}
</div>
);
}
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<string, number>,
);
}, [cusProduct.options]);
const [prepaidOptions, setPrepaidOptions] = useState<Record<string, number>>(
initialPrepaidOptions,
);
const handlePrepaidChange = (featureId: string, quantity: number) => {
setPrepaidOptions((prev) => ({
...prev,
[featureId]: quantity,
}));
};
// Build the request body
const requestBody = useMemo<SubscriptionUpdateV0Params | null>(() => {
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 (
<div className="flex flex-col h-full">
<SheetHeader
title="Subscription Update Test"
description={`Testing update for ${cusProduct.product.name}`}
>
<IconButton
variant="primary"
onClick={handleEditPlan}
icon={<PencilSimple size={16} weight="duotone" />}
>
Edit Plan
</IconButton>
</SheetHeader>
<div className="flex-1 overflow-y-auto">
{/* Request Body Display */}
<div className="border-b border-border">
<div className="px-4 py-2 border-b border-border">
<h3 className="text-sm font-medium">Request Body</h3>
</div>
<div className="px-4 py-3 text-sm space-y-1">
<div>
<span className="text-t-secondary">customer_id: </span>
<code className="text-xs">{requestBody?.customer_id}</code>
</div>
<div>
<span className="text-t-secondary">product_id: </span>
<code className="text-xs">{requestBody?.product_id}</code>
</div>
{requestBody?.entity_id ? (
<div>
<span className="text-t-secondary">entity_id: </span>
<code className="text-xs">{requestBody.entity_id}</code>
</div>
) : null}
{requestBody?.options && requestBody.options.length > 0 ? (
<div>
<span className="text-t-secondary">options: </span>
<code className="text-xs">
{requestBody.options
.map((o) => `${o.feature_id}: ${o.quantity}`)
.join(", ")}
</code>
</div>
) : null}
{requestBody?.items && requestBody.items.length > 0 ? (
<div>
<span className="text-t-secondary">items: </span>
<div className="mt-1 pl-3 space-y-1">
{requestBody.items.map((item, index) => {
const display = getItemDisplay(item as ProductItem);
return (
<div
key={index}
className="text-xs border-l-2 border-l-purple-500 pl-2"
>
<span className="text-t-primary">
{display.primary_text}
</span>
{display.secondary_text ? (
<span className="text-t-secondary ml-1">
{display.secondary_text}
</span>
) : null}
</div>
);
})}
</div>
</div>
) : null}
{requestBody?.free_trial ? (
<div>
<span className="text-t-secondary">free_trial: </span>
<span className="text-xs">
<span className="text-green-400">
{requestBody.free_trial.length}{" "}
{requestBody.free_trial.duration}
{Number(requestBody.free_trial.length) > 1 ? "s" : ""}
</span>
<span className="text-t-secondary ml-2">
(card_required:{" "}
{requestBody.free_trial.card_required ? "true" : "false"})
</span>
</span>
</div>
) : null}
<details className="mt-2">
<summary className="text-xs cursor-pointer text-t-secondary hover:text-t-primary">
View raw JSON
</summary>
<pre className="text-xs bg-t-50 p-2 rounded mt-1 overflow-auto max-h-32">
{JSON.stringify(requestBody, null, 2)}
</pre>
</details>
</div>
</div>
{/* Prepaid Editor */}
<PrepaidEditor
prepaidItems={prepaidItems}
prepaidOptions={prepaidOptions}
onPrepaidChange={handlePrepaidChange}
/>
{/* Preview Result */}
<PreviewResult
data={previewQuery.data}
isLoading={previewQuery.isLoading}
error={previewQuery.error as Error | null}
/>
{/* Update Result */}
<UpdateResult
data={updateMutation.data}
isLoading={updateMutation.isPending}
error={updateMutation.error as Error | null}
/>
</div>
{/* Footer Actions */}
<div className="p-4 border-t flex gap-3">
<Button
variant="primary"
onClick={handleConfirm}
disabled={!requestBody || updateMutation.isPending}
>
{updateMutation.isPending ? "Updating..." : "Confirm Update"}
</Button>
<Button
variant="secondary"
onClick={() => previewQuery.refetch()}
disabled={!requestBody || previewQuery.isLoading}
>
Refresh Preview
</Button>
</div>
</div>
);
}
/**
* 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 (
<div className="flex flex-col h-full">
<SheetHeader
title="Subscription Update Test"
description="Loading..."
/>
<div className="p-4 text-sm text-t-secondary">
No customer product found for itemId: {itemId}
</div>
</div>
);
}
if (!productV2) {
return (
<div className="flex flex-col h-full">
<SheetHeader
title="Subscription Update Test"
description="Loading product..."
/>
</div>
);
}
return (
<SheetContent
cusProduct={cusProduct}
productV2={productV2}
customizedProduct={customizedProduct}
/>
);
}

View File

@@ -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,
});
};

View File

@@ -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,
});

View File

@@ -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 <SubscriptionDetailSheet />;
case "subscription-update":
return <SubscriptionUpdateSheet />;
case "subscription-update-test": // TEST: Remove this case to revert
return <SubscriptionUpdateTestSheet />;
case "balance-selection":
return <BalanceSelectionSheet />;
case "balance-edit":