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";
|
return stripeSubscription.status === "trialing";
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isStripeSubscriptionCancelling = (
|
export const isStripeSubscriptionCanceling = (
|
||||||
stripeSubscription?: Stripe.Subscription,
|
stripeSubscription?: Stripe.Subscription,
|
||||||
) => {
|
) => {
|
||||||
if (!stripeSubscription) {
|
if (!stripeSubscription) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
|
import { handleSubscriptionUpdatePreview } from "@/internal/billing/v2/subscriptionUpdate/handleSubscriptionUpdatePreview.js";
|
||||||
import type { HonoEnv } from "../../honoUtils/HonoEnv.js";
|
import type { HonoEnv } from "../../honoUtils/HonoEnv.js";
|
||||||
import { handleAttach } from "./attach/handleAttach.js";
|
import { handleAttach } from "./attach/handleAttach.js";
|
||||||
import { handleCheckoutV2 } from "./checkout/handleCheckoutV2.js";
|
import { handleCheckoutV2 } from "./checkout/handleCheckoutV2.js";
|
||||||
@@ -14,3 +15,7 @@ billingRouter.post("/attach", ...handleAttach);
|
|||||||
billingRouter.post("/attach_v2", ...handleAttachV2);
|
billingRouter.post("/attach_v2", ...handleAttachV2);
|
||||||
|
|
||||||
billingRouter.post("/subscriptions/update", ...handleApiSubscriptionUpdate);
|
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<
|
export type StripeSubscriptionAction = z.infer<
|
||||||
typeof StripeSubscriptionActionSchema
|
typeof StripeSubscriptionActionSchema
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export const StripeBillingPlanSchema = z.object({
|
export const StripeBillingPlanSchema = z.object({
|
||||||
subscription: StripeSubscriptionActionSchema.optional(),
|
subscriptionAction: StripeSubscriptionActionSchema.optional(),
|
||||||
|
invoiceAction: StripeInvoiceActionSchema.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const AutumnBillingPlanSchema = z.object({
|
export const AutumnBillingPlanSchema = z.object({
|
||||||
@@ -51,16 +58,6 @@ export const AutumnBillingPlanSchema = z.object({
|
|||||||
customPrices: z.array(PriceSchema), // Custom prices to insert
|
customPrices: z.array(PriceSchema), // Custom prices to insert
|
||||||
customEntitlements: z.array(EntitlementSchema), // Custom entitlements to insert
|
customEntitlements: z.array(EntitlementSchema), // Custom entitlements to insert
|
||||||
customFreeTrial: FreeTrialSchema.optional(), // Custom free trial 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({
|
export const BillingPlanSchema = z.object({
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
import type { AttachContext, StripeSubAction } from "../../typesOld";
|
import type { AttachContext, StripeSubAction } from "../../typesOld";
|
||||||
import { applyStripeDiscountsToLineItems } from "../../utils/stripeAdapter/applyStripeDiscounts/applyStripeDiscountsToLineItems";
|
import { applyStripeDiscountsToLineItems } from "../../utils/stripeAdapter/applyStripeDiscounts/applyStripeDiscountsToLineItems";
|
||||||
import { subToDiscounts } from "../../utils/stripeAdapter/applyStripeDiscounts/subToDiscounts";
|
import { subToDiscounts } from "../../utils/stripeAdapter/applyStripeDiscounts/subToDiscounts";
|
||||||
import { lineItemsToStripeLines } from "../../utils/stripeAdapter/stripeInvoiceOps/lineItemsToStripeLines";
|
import { lineItemsToStripeLines } from "../../utils/stripeAdapter/invoiceLines/lineItemsToStripeLines";
|
||||||
|
|
||||||
export const buildStripeInvoiceAction = ({
|
export const buildStripeInvoiceAction = ({
|
||||||
attachContext,
|
attachContext,
|
||||||
|
|||||||
@@ -1,54 +1,31 @@
|
|||||||
|
import type { BillingContext } from "@/internal/billing/v2/billingContext";
|
||||||
import { createStripeCli } from "../../../../external/connect/createStripeCli";
|
import { createStripeCli } from "../../../../external/connect/createStripeCli";
|
||||||
import type { AutumnContext } from "../../../../honoUtils/HonoEnv";
|
import type { AutumnContext } from "../../../../honoUtils/HonoEnv";
|
||||||
|
import type { StripeInvoiceAction } from "../billingPlan";
|
||||||
import { createAndPayInvoice } from "../utils/stripeAdapter/stripeInvoiceOps/createAndPayInvoice";
|
import { createAndPayInvoice } from "../utils/stripeAdapter/stripeInvoiceOps/createAndPayInvoice";
|
||||||
import type {
|
|
||||||
AttachContext,
|
|
||||||
StripeCheckoutAction,
|
|
||||||
StripeInvoiceAction,
|
|
||||||
} from "../typesOld";
|
|
||||||
import { executeStripeCheckoutAction } from "./executeStripeCheckoutAction";
|
|
||||||
|
|
||||||
export const executeStripeInvoiceAction = async ({
|
export const executeStripeInvoiceAction = async ({
|
||||||
ctx,
|
ctx,
|
||||||
attachContext,
|
billingContext,
|
||||||
stripeCheckoutAction,
|
|
||||||
stripeInvoiceAction,
|
stripeInvoiceAction,
|
||||||
}: {
|
}: {
|
||||||
ctx: AutumnContext;
|
ctx: AutumnContext;
|
||||||
attachContext: AttachContext;
|
billingContext: BillingContext;
|
||||||
stripeCheckoutAction: StripeCheckoutAction;
|
|
||||||
stripeInvoiceAction: StripeInvoiceAction;
|
stripeInvoiceAction: StripeInvoiceAction;
|
||||||
}) => {
|
}) => {
|
||||||
const { org, env, logger } = ctx;
|
const { org, env } = ctx;
|
||||||
const { items, onPaymentFailure } = stripeInvoiceAction;
|
const { addLineParams } = stripeInvoiceAction;
|
||||||
|
|
||||||
const stripeCli = createStripeCli({ org, env });
|
const stripeCli = createStripeCli({ org, env });
|
||||||
|
|
||||||
// 1. Create and pay invoice
|
// 1. Create and pay invoice
|
||||||
const { invoice, paid, error, createCheckoutSession, hostedUrl } =
|
const result = await createAndPayInvoice({
|
||||||
await createAndPayInvoice({
|
|
||||||
stripeCli,
|
stripeCli,
|
||||||
stripeCusId: attachContext.stripeCus.id,
|
stripeCusId: billingContext.stripeCustomer?.id,
|
||||||
stripeLineItems: items,
|
stripeLineItems: addLineParams.lines,
|
||||||
paymentMethod: attachContext.paymentMethod,
|
paymentMethod: billingContext.paymentMethod,
|
||||||
onPaymentFailure: onPaymentFailure,
|
onPaymentFailure: "return_url",
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!paid) {
|
return result;
|
||||||
// 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;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
type CusProductActions,
|
type CusProductActions,
|
||||||
getOngoingCusProductById,
|
getOngoingCusProductById,
|
||||||
getScheduledMainCusProductByGroup,
|
getScheduledMainCusProductByGroup,
|
||||||
isCusProductCanceled,
|
isCustomerProductCanceled,
|
||||||
} from "@autumn/shared";
|
} from "@autumn/shared";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -27,7 +27,7 @@ export const getUncancelAttachActions = ({
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
!ongoingSameCusProduct ||
|
!ongoingSameCusProduct ||
|
||||||
!isCusProductCanceled({ cusProduct: ongoingSameCusProduct })
|
!isCustomerProductCanceled(ongoingSameCusProduct)
|
||||||
) {
|
) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export const handleApiSubscriptionUpdate = createRoute({
|
|||||||
params: body,
|
params: body,
|
||||||
});
|
});
|
||||||
|
|
||||||
const subscriptionUpdatePlan = computeSubscriptionUpdatePlan({
|
const subscriptionUpdatePlan = await computeSubscriptionUpdatePlan({
|
||||||
ctx,
|
ctx,
|
||||||
updateSubscriptionContext,
|
updateSubscriptionContext,
|
||||||
params: body,
|
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 {
|
import {
|
||||||
cusProductToProduct,
|
cusProductToProduct,
|
||||||
extractBillingPeriod,
|
extractBillingPeriod,
|
||||||
type Feature,
|
|
||||||
type FeatureOptions,
|
type FeatureOptions,
|
||||||
type FullCusProduct,
|
type FullCusProduct,
|
||||||
|
findFeatureByInternalId,
|
||||||
InternalError,
|
InternalError,
|
||||||
} from "@autumn/shared";
|
} from "@autumn/shared";
|
||||||
import { usagePriceToLineDescription } from "@autumn/shared/utils/billingUtils/invoicingUtils/descriptionUtils/usagePriceToLineDescription";
|
import { usagePriceToLineDescription } from "@autumn/shared/utils/billingUtils/invoicingUtils/descriptionUtils/usagePriceToLineDescription";
|
||||||
@@ -46,7 +46,7 @@ export const computeQuantityUpdateDetails = ({
|
|||||||
stripeSubscription: Stripe.Subscription;
|
stripeSubscription: Stripe.Subscription;
|
||||||
currentEpochMs: number;
|
currentEpochMs: number;
|
||||||
}): QuantityUpdateDetails => {
|
}): QuantityUpdateDetails => {
|
||||||
const { features } = ctx;
|
const { features, org } = ctx;
|
||||||
|
|
||||||
const internalFeatureId = updatedOptions.internal_feature_id;
|
const internalFeatureId = updatedOptions.internal_feature_id;
|
||||||
const featureId = updatedOptions.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({
|
const quantityDifferences = calculateQuantityDifferences({
|
||||||
previousOptions,
|
previousOptions,
|
||||||
updatedOptions,
|
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 product = cusProductToProduct({ cusProduct: customerProduct });
|
||||||
|
|
||||||
const stripeInvoiceItemDescription = usagePriceToLineDescription({
|
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,
|
CusProductStatus,
|
||||||
cusProductToProduct,
|
cusProductToProduct,
|
||||||
type SubscriptionUpdateV0Params,
|
type SubscriptionUpdateV0Params,
|
||||||
|
secondsToMs,
|
||||||
} from "@autumn/shared";
|
} from "@autumn/shared";
|
||||||
import type { AutumnContext } from "@server/honoUtils/HonoEnv";
|
import type { AutumnContext } from "@server/honoUtils/HonoEnv";
|
||||||
import type { UpdateSubscriptionContext } from "@server/internal/billing/v2/subscriptionUpdate/fetch/updateSubscriptionContextSchema";
|
import type { UpdateSubscriptionContext } from "@server/internal/billing/v2/subscriptionUpdate/fetch/updateSubscriptionContextSchema";
|
||||||
import type { BillingPlan } from "@/internal/billing/v2/billingPlan";
|
import type { BillingPlan } from "@/internal/billing/v2/billingPlan";
|
||||||
import { addStripeSubscriptionIdToBillingPlan } from "@/internal/billing/v2/execute/addStripeSubscriptionIdToBillingPlan";
|
import { computeInvoiceAction } from "@/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeInvoiceAction";
|
||||||
import { executeAutumnBillingPlan } from "@/internal/billing/v2/execute/executeAutumnBillingPlan";
|
|
||||||
import { computeSubscriptionUpdateFreeTrialPlan } from "@/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateFreeTrialPlan";
|
import { computeSubscriptionUpdateFreeTrialPlan } from "@/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateFreeTrialPlan";
|
||||||
import { computeSubscriptionUpdateNewCustomerProduct } from "@/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateNewCustomerProduct";
|
import { computeSubscriptionUpdateNewCustomerProduct } from "@/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateNewCustomerProduct";
|
||||||
import { computeSubscriptionUpdateStripeSubscriptionAction } from "@/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateStripeSubscriptionAction";
|
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 { createStripeResourcesForProducts } from "@/internal/billing/v2/utils/stripeAdapter/createStripeResourcesForProduct";
|
||||||
import { executeStripeSubscriptionAction } from "@/internal/billing/v2/utils/stripeAdapter/subscriptions/executeStripeSubscriptionAction";
|
|
||||||
import { computeCustomFullProduct } from "../../../compute/computeAutumnUtils/computeCustomFullProduct";
|
import { computeCustomFullProduct } from "../../../compute/computeAutumnUtils/computeCustomFullProduct";
|
||||||
|
|
||||||
export const computeSubscriptionUpdateCustomPlan = async ({
|
export const computeSubscriptionUpdateCustomPlan = async ({
|
||||||
@@ -25,7 +23,7 @@ export const computeSubscriptionUpdateCustomPlan = async ({
|
|||||||
updateSubscriptionContext: UpdateSubscriptionContext;
|
updateSubscriptionContext: UpdateSubscriptionContext;
|
||||||
params: SubscriptionUpdateV0Params;
|
params: SubscriptionUpdateV0Params;
|
||||||
}) => {
|
}) => {
|
||||||
const { customerProduct } = updateSubscriptionContext;
|
const { customerProduct, stripeSubscription } = updateSubscriptionContext;
|
||||||
|
|
||||||
const currentFullProduct = cusProductToProduct({
|
const currentFullProduct = cusProductToProduct({
|
||||||
cusProduct: customerProduct,
|
cusProduct: customerProduct,
|
||||||
@@ -50,6 +48,10 @@ export const computeSubscriptionUpdateCustomPlan = async ({
|
|||||||
fullProduct: customFullProduct,
|
fullProduct: customFullProduct,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const billingCycleAnchor =
|
||||||
|
freeTrialPlan.trialEndsAt ??
|
||||||
|
secondsToMs(stripeSubscription?.billing_cycle_anchor);
|
||||||
|
|
||||||
// 3. Compute the new customer product
|
// 3. Compute the new customer product
|
||||||
const newFullCustomerProduct = computeSubscriptionUpdateNewCustomerProduct({
|
const newFullCustomerProduct = computeSubscriptionUpdateNewCustomerProduct({
|
||||||
ctx,
|
ctx,
|
||||||
@@ -57,6 +59,7 @@ export const computeSubscriptionUpdateCustomPlan = async ({
|
|||||||
params,
|
params,
|
||||||
fullProduct: customFullProduct,
|
fullProduct: customFullProduct,
|
||||||
freeTrialPlan,
|
freeTrialPlan,
|
||||||
|
billingCycleAnchor,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 4. Create stripe prices
|
// 4. Create stripe prices
|
||||||
@@ -76,9 +79,18 @@ export const computeSubscriptionUpdateCustomPlan = async ({
|
|||||||
freeTrialPlan,
|
freeTrialPlan,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const stripeInvoiceAction = computeInvoiceAction({
|
||||||
|
ctx,
|
||||||
|
billingContext: updateSubscriptionContext,
|
||||||
|
newCustomerProduct: newFullCustomerProduct,
|
||||||
|
stripeSubscriptionAction,
|
||||||
|
billingCycleAnchor,
|
||||||
|
});
|
||||||
|
|
||||||
const billingPlan: BillingPlan = {
|
const billingPlan: BillingPlan = {
|
||||||
stripe: {
|
stripe: {
|
||||||
subscription: stripeSubscriptionAction,
|
subscriptionAction: stripeSubscriptionAction,
|
||||||
|
invoiceAction: stripeInvoiceAction,
|
||||||
},
|
},
|
||||||
autumn: {
|
autumn: {
|
||||||
insertCustomerProducts: [newFullCustomerProduct],
|
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;
|
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 {
|
import type { FullProduct, SubscriptionUpdateV0Params } from "@autumn/shared";
|
||||||
type FullProduct,
|
|
||||||
type SubscriptionUpdateV0Params,
|
|
||||||
secondsToMs,
|
|
||||||
} from "@autumn/shared";
|
|
||||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||||
import type { FreeTrialPlan } from "@/internal/billing/v2/billingPlan";
|
import type { FreeTrialPlan } from "@/internal/billing/v2/billingPlan";
|
||||||
import { computeSubscriptionUpdateFeatureQuantities } from "@/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateFeatureQuantities";
|
import { computeSubscriptionUpdateFeatureQuantities } from "@/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateFeatureQuantities";
|
||||||
@@ -17,12 +13,14 @@ export const computeSubscriptionUpdateNewCustomerProduct = ({
|
|||||||
updateSubscriptionContext,
|
updateSubscriptionContext,
|
||||||
fullProduct,
|
fullProduct,
|
||||||
freeTrialPlan,
|
freeTrialPlan,
|
||||||
|
billingCycleAnchor,
|
||||||
}: {
|
}: {
|
||||||
ctx: AutumnContext;
|
ctx: AutumnContext;
|
||||||
params: SubscriptionUpdateV0Params;
|
params: SubscriptionUpdateV0Params;
|
||||||
updateSubscriptionContext: UpdateSubscriptionContext;
|
updateSubscriptionContext: UpdateSubscriptionContext;
|
||||||
fullProduct: FullProduct;
|
fullProduct: FullProduct;
|
||||||
freeTrialPlan: FreeTrialPlan;
|
freeTrialPlan: FreeTrialPlan;
|
||||||
|
billingCycleAnchor?: number;
|
||||||
}) => {
|
}) => {
|
||||||
const {
|
const {
|
||||||
customerProduct,
|
customerProduct,
|
||||||
@@ -48,11 +46,6 @@ export const computeSubscriptionUpdateNewCustomerProduct = ({
|
|||||||
params,
|
params,
|
||||||
});
|
});
|
||||||
|
|
||||||
// TODO: Move this to a separate function
|
|
||||||
const billingCycleAnchor =
|
|
||||||
freeTrialPlan.trialEndsAt ??
|
|
||||||
secondsToMs(stripeSubscription?.billing_cycle_anchor);
|
|
||||||
|
|
||||||
const now = updateSubscriptionContext.testClockFrozenTime ?? Date.now();
|
const now = updateSubscriptionContext.testClockFrozenTime ?? Date.now();
|
||||||
|
|
||||||
// 1. Compute the new full customer product
|
// 1. Compute the new full customer product
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import type { SubscriptionUpdateV0Params } from "@shared/index";
|
import type { SubscriptionUpdateV0Params } from "@shared/index";
|
||||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
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 type { UpdateSubscriptionContext } from "../fetch/updateSubscriptionContextSchema";
|
||||||
import { computeSubscriptionUpdateIntent } from "./computeSubscriptionUpdateIntent";
|
import { computeSubscriptionUpdateIntent } from "./computeSubscriptionUpdateIntent";
|
||||||
import { getComputeSubscriptionUpdatePlanFunction } from "./computeSubscriptionUpdatePlanIntentMap";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compute the subscription update plan
|
* Compute the subscription update plan
|
||||||
@@ -11,7 +12,7 @@ import { getComputeSubscriptionUpdatePlanFunction } from "./computeSubscriptionU
|
|||||||
* @param params - The parameters for the subscription update
|
* @param params - The parameters for the subscription update
|
||||||
* @returns The subscription update plan
|
* @returns The subscription update plan
|
||||||
*/
|
*/
|
||||||
export const computeSubscriptionUpdatePlan = ({
|
export const computeSubscriptionUpdatePlan = async ({
|
||||||
ctx,
|
ctx,
|
||||||
updateSubscriptionContext,
|
updateSubscriptionContext,
|
||||||
params,
|
params,
|
||||||
@@ -19,9 +20,21 @@ export const computeSubscriptionUpdatePlan = ({
|
|||||||
ctx: AutumnContext;
|
ctx: AutumnContext;
|
||||||
updateSubscriptionContext: UpdateSubscriptionContext;
|
updateSubscriptionContext: UpdateSubscriptionContext;
|
||||||
params: SubscriptionUpdateV0Params;
|
params: SubscriptionUpdateV0Params;
|
||||||
}): SubscriptionUpdatePlan => {
|
}) => {
|
||||||
const intent = computeSubscriptionUpdateIntent(params);
|
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 {
|
import {
|
||||||
|
InternalError,
|
||||||
OngoingCusProductActionEnum,
|
OngoingCusProductActionEnum,
|
||||||
type SubscriptionUpdateV0Params,
|
type SubscriptionUpdateV0Params,
|
||||||
secondsToMs,
|
secondsToMs,
|
||||||
} from "@shared/index";
|
} from "@autumn/shared";
|
||||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||||
import { buildAutumnLineItems } from "../../compute/computeAutumnUtils/buildAutumnLineItems";
|
import { buildAutumnLineItems } from "../../compute/computeAutumnUtils/buildAutumnLineItems";
|
||||||
import type { SubscriptionUpdateQuantityPlan } from "../../typesOld";
|
import type { SubscriptionUpdateQuantityPlan } from "../../typesOld";
|
||||||
@@ -28,6 +29,12 @@ export const computeSubscriptionUpdateQuantityPlan = ({
|
|||||||
paymentMethod,
|
paymentMethod,
|
||||||
} = updateSubscriptionContext;
|
} = updateSubscriptionContext;
|
||||||
|
|
||||||
|
if (!stripeSubscription) {
|
||||||
|
throw new InternalError({
|
||||||
|
message: `[Subscription Update] Stripe subscription not found`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const featureQuantities = {
|
const featureQuantities = {
|
||||||
old: customerProduct.options,
|
old: customerProduct.options,
|
||||||
new: params.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[];
|
items?: Stripe.SubscriptionUpdateParams.Item[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type StripeInvoiceAction = {
|
|
||||||
items: Stripe.InvoiceAddLinesParams.Line[];
|
|
||||||
onPaymentFailure: "return_url" | "checkout_session";
|
|
||||||
};
|
|
||||||
|
|
||||||
export type StripeCheckoutAction = {
|
export type StripeCheckoutAction = {
|
||||||
shouldCreate: boolean;
|
shouldCreate: boolean;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
|
|||||||
@@ -34,20 +34,6 @@ export const logBillingPlan = ({
|
|||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
},
|
},
|
||||||
stripe: {
|
stripe: billingPlan.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,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { msToSeconds } from "@shared/utils/common/unixUtils";
|
import { msToSeconds } from "@shared/utils/common/unixUtils";
|
||||||
import type Stripe from "stripe";
|
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 { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||||
import type { BillingContext } from "@/internal/billing/v2/billingContext";
|
import type { BillingContext } from "@/internal/billing/v2/billingContext";
|
||||||
import type { FreeTrialPlan } from "@/internal/billing/v2/billingPlan";
|
import type { FreeTrialPlan } from "@/internal/billing/v2/billingPlan";
|
||||||
@@ -25,7 +25,7 @@ export const buildStripeSubscriptionUpdateAction = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const trialEndsAt = freeTrialPlan?.trialEndsAt;
|
const trialEndsAt = freeTrialPlan?.trialEndsAt;
|
||||||
const cancelAtPeriodEnd = isStripeSubscriptionCancelling(stripeSubscription)
|
const cancelAtPeriodEnd = isStripeSubscriptionCanceling(stripeSubscription)
|
||||||
? false
|
? false
|
||||||
: undefined;
|
: 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 ApiInvoiceV1,
|
||||||
type Customer,
|
type Customer,
|
||||||
type Feature,
|
type Feature,
|
||||||
|
type InsertInvoice,
|
||||||
type Invoice,
|
type Invoice,
|
||||||
type InvoiceItem,
|
type InvoiceItem,
|
||||||
type InvoiceStatus,
|
type InvoiceStatus,
|
||||||
@@ -210,11 +211,11 @@ export class InvoiceService {
|
|||||||
}: {
|
}: {
|
||||||
db: DrizzleCli;
|
db: DrizzleCli;
|
||||||
stripeId: string;
|
stripeId: string;
|
||||||
updates: Partial<Invoice>;
|
updates: Partial<InsertInvoice>;
|
||||||
}) {
|
}) {
|
||||||
const results = await db
|
const results = await db
|
||||||
.update(invoices)
|
.update(invoices)
|
||||||
.set(updates as any)
|
.set(updates)
|
||||||
.where(eq(invoices.stripe_id, stripeId))
|
.where(eq(invoices.stripe_id, stripeId))
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
cusProductToProduct,
|
cusProductToProduct,
|
||||||
type FullCustomer,
|
type FullCustomer,
|
||||||
type FullProduct,
|
type FullProduct,
|
||||||
isCusProductCanceled,
|
isCustomerProductCanceled,
|
||||||
} from "@autumn/shared";
|
} from "@autumn/shared";
|
||||||
|
|
||||||
import { getExistingCusProducts } from "@/internal/customers/cusProducts/cusProductUtils/getExistingCusProducts.js";
|
import { getExistingCusProducts } from "@/internal/customers/cusProducts/cusProductUtils/getExistingCusProducts.js";
|
||||||
@@ -43,7 +43,7 @@ export const getAttachScenario = ({
|
|||||||
curSameProduct &&
|
curSameProduct &&
|
||||||
curSameProduct.product.id !== curScheduledProduct?.product.id
|
curSameProduct.product.id !== curScheduledProduct?.product.id
|
||||||
) {
|
) {
|
||||||
if (isCusProductCanceled({ cusProduct: curSameProduct })) {
|
if (isCustomerProductCanceled(curSameProduct)) {
|
||||||
return AttachScenario.Renew;
|
return AttachScenario.Renew;
|
||||||
} else {
|
} else {
|
||||||
return AttachScenario.Active;
|
return AttachScenario.Active;
|
||||||
@@ -59,7 +59,7 @@ export const getAttachScenario = ({
|
|||||||
|
|
||||||
// 1. If current product is the same as the product, return active
|
// 1. If current product is the same as the product, return active
|
||||||
if (curMainProduct?.product.id === fullProduct.id) {
|
if (curMainProduct?.product.id === fullProduct.id) {
|
||||||
if (isCusProductCanceled({ cusProduct: curMainProduct })) {
|
if (isCustomerProductCanceled(curMainProduct)) {
|
||||||
return AttachScenario.Renew;
|
return AttachScenario.Renew;
|
||||||
} else return AttachScenario.Active;
|
} else return AttachScenario.Active;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ export * from "./models/attachModels/attachFunctionResponse.js";
|
|||||||
export * from "./models/billingModels/cusProductActions.js";
|
export * from "./models/billingModels/cusProductActions.js";
|
||||||
export * from "./models/billingModels/existingRollovers.js";
|
export * from "./models/billingModels/existingRollovers.js";
|
||||||
export * from "./models/billingModels/existingUsages.js";
|
export * from "./models/billingModels/existingUsages.js";
|
||||||
|
export * from "./models/billingModels/index.js";
|
||||||
export * from "./models/billingModels/initFullCustomerProductContext.js";
|
export * from "./models/billingModels/initFullCustomerProductContext.js";
|
||||||
export * from "./models/billingModels/invoicingModels/lineItem.js";
|
export * from "./models/billingModels/invoicingModels/lineItem.js";
|
||||||
// Billing Models
|
// 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 { foreignKey, jsonb, numeric, pgTable, text } from "drizzle-orm/pg-core";
|
||||||
import { collatePgColumn, sqlNow } from "../../../db/utils.js";
|
import { collatePgColumn, sqlNow } from "../../../db/utils.js";
|
||||||
import { InvoiceDiscount, InvoiceItem } from "./invoiceModels.js";
|
|
||||||
import { customers } from "../cusTable.js";
|
import { customers } from "../cusTable.js";
|
||||||
import { entities } from "../entityModels/entityTable.js";
|
import { entities } from "../entityModels/entityTable.js";
|
||||||
import { InferSelectModel, InferInsertModel } from "drizzle-orm";
|
import type { InvoiceDiscount, InvoiceItem } from "./invoiceModels.js";
|
||||||
|
|
||||||
export const invoices = pgTable(
|
export const invoices = pgTable(
|
||||||
"invoices",
|
"invoices",
|
||||||
@@ -41,4 +41,4 @@ export const invoices = pgTable(
|
|||||||
collatePgColumn(invoices.id, "C");
|
collatePgColumn(invoices.id, "C");
|
||||||
|
|
||||||
export type InvoiceRow = InferSelectModel<typeof invoices>;
|
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 { cusProductToPrices } from "./convertCusProduct";
|
||||||
import { ACTIVE_STATUSES } from "./cusProductConstants";
|
import { ACTIVE_STATUSES } from "./cusProductConstants";
|
||||||
|
|
||||||
export const isCusProductOneOff = ({
|
export const isCustomerProductOneOff = (cusProduct?: FullCusProduct) => {
|
||||||
cusProduct,
|
|
||||||
}: {
|
|
||||||
cusProduct?: FullCusProduct;
|
|
||||||
}) => {
|
|
||||||
if (!cusProduct) return false;
|
if (!cusProduct) return false;
|
||||||
|
|
||||||
const prices = cusProductToPrices({ cusProduct });
|
const prices = cusProductToPrices({ cusProduct });
|
||||||
@@ -19,14 +15,18 @@ export const isCusProductOneOff = ({
|
|||||||
return isOneOffProduct({ prices });
|
return isOneOffProduct({ prices });
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isCusProductCanceled = ({
|
export const isCustomerProductCanceled = (cusProduct?: FullCusProduct) => {
|
||||||
cusProduct,
|
|
||||||
}: {
|
|
||||||
cusProduct?: FullCusProduct;
|
|
||||||
}) => {
|
|
||||||
if (!cusProduct) return false;
|
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 = ({
|
export const isCusProductTrialing = ({
|
||||||
|
|||||||
@@ -163,3 +163,14 @@ export const cusProductToProduct = ({
|
|||||||
free_trial: cusProduct.free_trial,
|
free_trial: cusProduct.free_trial,
|
||||||
} as FullProduct;
|
} 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/convertCusEntUtils.js";
|
||||||
export * from "./cusEntUtils/cusEntUtils.js";
|
export * from "./cusEntUtils/cusEntUtils.js";
|
||||||
export * from "./cusEntUtils/filterCusEntUtils.js";
|
export * from "./cusEntUtils/filterCusEntUtils.js";
|
||||||
|
export * from "./cusEntUtils/findCustomerEntitlement/findPrepaidCustomerEntitlement.js";
|
||||||
// Cus ent utils
|
// Cus ent utils
|
||||||
export * from "./cusEntUtils/getRolloverFields.js";
|
export * from "./cusEntUtils/getRolloverFields.js";
|
||||||
export * from "./cusEntUtils/getStartingBalance.js";
|
export * from "./cusEntUtils/getStartingBalance.js";
|
||||||
@@ -47,6 +48,7 @@ export * from "./cusUtils/fullCusUtils/getCusStripeSubCount.js";
|
|||||||
export * from "./expandUtils.js";
|
export * from "./expandUtils.js";
|
||||||
export * from "./featureUtils/apiFeatureToDbFeature.js";
|
export * from "./featureUtils/apiFeatureToDbFeature.js";
|
||||||
export * from "./featureUtils/convertFeatureUtils.js";
|
export * from "./featureUtils/convertFeatureUtils.js";
|
||||||
|
export * from "./featureUtils/findFeatureUtils.js";
|
||||||
// Feature utils
|
// Feature utils
|
||||||
export * from "./featureUtils.js";
|
export * from "./featureUtils.js";
|
||||||
// INTERVAL UTILS
|
// INTERVAL UTILS
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export type SheetType =
|
|||||||
| "attach-product"
|
| "attach-product"
|
||||||
| "subscription-detail"
|
| "subscription-detail"
|
||||||
| "subscription-update"
|
| "subscription-update"
|
||||||
|
| "subscription-update-test" // TEST: Remove this line to revert
|
||||||
| "balance-selection"
|
| "balance-selection"
|
||||||
| "balance-edit"
|
| "balance-edit"
|
||||||
| null;
|
| 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) => {
|
const handleRowClick = (cusProduct: FullCusProduct) => {
|
||||||
setSheet({
|
setSheet({
|
||||||
type: "subscription-detail",
|
type: "subscription-update-test", // SWAP: Change back to "subscription-detail" to revert
|
||||||
itemId: cusProduct.id,
|
itemId: cusProduct.id,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { parseAsInteger, parseAsString, useQueryStates } from "nuqs";
|
import { parseAsInteger, parseAsString, useQueryStates } from "nuqs";
|
||||||
import { useEffect } from "react";
|
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
import { Button } from "@/components/v2/buttons/Button";
|
import { Button } from "@/components/v2/buttons/Button";
|
||||||
import { ShortcutButton } from "@/components/v2/buttons/ShortcutButton";
|
import { ShortcutButton } from "@/components/v2/buttons/ShortcutButton";
|
||||||
@@ -60,7 +59,9 @@ export const CustomerPlanEditorBar = () => {
|
|||||||
} else {
|
} else {
|
||||||
// We have a subscription ID, so we're editing an existing subscription
|
// We have a subscription ID, so we're editing an existing subscription
|
||||||
setSheet({
|
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,
|
itemId: queryStates.id,
|
||||||
data: changesMade ? { customizedProduct: product } : null,
|
data: changesMade ? { customizedProduct: product } : null,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { BalanceEditSheet } from "../components/sheets/BalanceEditSheet";
|
|||||||
import { BalanceSelectionSheet } from "../components/sheets/BalanceSelectionSheet";
|
import { BalanceSelectionSheet } from "../components/sheets/BalanceSelectionSheet";
|
||||||
import { SubscriptionDetailSheet } from "../components/sheets/SubscriptionDetailSheet";
|
import { SubscriptionDetailSheet } from "../components/sheets/SubscriptionDetailSheet";
|
||||||
import { SubscriptionUpdateSheet } from "../components/sheets/SubscriptionUpdateSheet";
|
import { SubscriptionUpdateSheet } from "../components/sheets/SubscriptionUpdateSheet";
|
||||||
|
import { SubscriptionUpdateTestSheet } from "../components/sheets/SubscriptionUpdateTestSheet"; // TEST: Remove this line to revert
|
||||||
import { SHEET_ANIMATION } from "./customerAnimations";
|
import { SHEET_ANIMATION } from "./customerAnimations";
|
||||||
|
|
||||||
export function CustomerSheets() {
|
export function CustomerSheets() {
|
||||||
@@ -32,6 +33,8 @@ export function CustomerSheets() {
|
|||||||
return <SubscriptionDetailSheet />;
|
return <SubscriptionDetailSheet />;
|
||||||
case "subscription-update":
|
case "subscription-update":
|
||||||
return <SubscriptionUpdateSheet />;
|
return <SubscriptionUpdateSheet />;
|
||||||
|
case "subscription-update-test": // TEST: Remove this case to revert
|
||||||
|
return <SubscriptionUpdateTestSheet />;
|
||||||
case "balance-selection":
|
case "balance-selection":
|
||||||
return <BalanceSelectionSheet />;
|
return <BalanceSelectionSheet />;
|
||||||
case "balance-edit":
|
case "balance-edit":
|
||||||
|
|||||||
Reference in New Issue
Block a user