created test subscription update sheet, defined possible quantity update line item function
This commit is contained in:
@@ -7,7 +7,7 @@ export const isStripeSubscriptionTrialing = (
|
||||
return stripeSubscription.status === "trialing";
|
||||
};
|
||||
|
||||
export const isStripeSubscriptionCancelling = (
|
||||
export const isStripeSubscriptionCanceling = (
|
||||
stripeSubscription?: Stripe.Subscription,
|
||||
) => {
|
||||
if (!stripeSubscription) {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
const result = await createAndPayInvoice({
|
||||
stripeCli,
|
||||
stripeCusId: attachContext.stripeCus.id,
|
||||
stripeLineItems: items,
|
||||
paymentMethod: attachContext.paymentMethod,
|
||||
onPaymentFailure: onPaymentFailure,
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ export const handleApiSubscriptionUpdate = createRoute({
|
||||
params: body,
|
||||
});
|
||||
|
||||
const subscriptionUpdatePlan = computeSubscriptionUpdatePlan({
|
||||
const subscriptionUpdatePlan = await computeSubscriptionUpdatePlan({
|
||||
ctx,
|
||||
updateSubscriptionContext,
|
||||
params: body,
|
||||
|
||||
@@ -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];
|
||||
};
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 || [],
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./invoicingModels/lineItemContext";
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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 }),
|
||||
);
|
||||
};
|
||||
@@ -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 = ({
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
};
|
||||
|
||||
11
shared/utils/featureUtils/findFeatureUtils.ts
Normal file
11
shared/utils/featureUtils/findFeatureUtils.ts
Normal 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);
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user