feat: added restore + sync
This commit is contained in:
6
server/src/external/autumn/autumnCli.ts
vendored
6
server/src/external/autumn/autumnCli.ts
vendored
@@ -35,6 +35,8 @@ import {
|
||||
type LegacyVersion,
|
||||
type OrgConfig,
|
||||
type ProductItem,
|
||||
type RestoreParamsV1,
|
||||
type RestoreResponse,
|
||||
type RewardRedemption,
|
||||
type SetUsageParams,
|
||||
type SetupPaymentParamsV1,
|
||||
@@ -1111,5 +1113,9 @@ export class AutumnInt {
|
||||
});
|
||||
return data;
|
||||
},
|
||||
|
||||
restore: async (params: RestoreParamsV1): Promise<RestoreResponse> => {
|
||||
return await this.post(`/billing.restore`, params);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from "./operations/getStripeActiveSubscriptionSchedule";
|
||||
|
||||
export * from "./utils/classifyStripeSubscriptionScheduleUtils";
|
||||
export * from "./utils/convertStripeSubscriptionScheduleUtils";
|
||||
export * from "./utils/logStripeSchedulePhaseUtils";
|
||||
|
||||
@@ -3,12 +3,15 @@ import type Stripe from "stripe";
|
||||
export const getStripeActiveSubscriptionSchedule = async ({
|
||||
stripeClient,
|
||||
subscriptionScheduleId,
|
||||
expand,
|
||||
}: {
|
||||
stripeClient: Stripe;
|
||||
subscriptionScheduleId: string;
|
||||
expand?: string[];
|
||||
}): Promise<Stripe.SubscriptionSchedule | undefined> => {
|
||||
const schedule = await stripeClient.subscriptionSchedules.retrieve(
|
||||
subscriptionScheduleId,
|
||||
expand ? { expand } : undefined,
|
||||
);
|
||||
|
||||
if (schedule.status === "canceled" || schedule.status === "released") {
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
import type Stripe from "stripe";
|
||||
import { stripeSubscriptionScheduleToPhaseIndex } from "./convertStripeSubscriptionScheduleUtils";
|
||||
|
||||
/** Checks if a Stripe subscription schedule phase is current. */
|
||||
export const isStripeSubscriptionSchedulePhaseCurrent = ({
|
||||
phase,
|
||||
nowSeconds,
|
||||
}: {
|
||||
phase: Stripe.SubscriptionSchedule.Phase;
|
||||
nowSeconds: number;
|
||||
}): boolean => {
|
||||
if (nowSeconds < phase.start_date) return false;
|
||||
if (phase.end_date && nowSeconds >= phase.end_date) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
/** Checks if a Stripe subscription schedule is in its last phase. */
|
||||
export const isStripeSubscriptionScheduleInLastPhase = ({
|
||||
stripeSubscriptionSchedule,
|
||||
|
||||
@@ -68,6 +68,21 @@ export const stripeSubscriptionToTrialEndsAtMs = ({
|
||||
: undefined;
|
||||
};
|
||||
|
||||
/** Gets the earliest current period start for a Stripe subscription. */
|
||||
export const stripeSubscriptionToStartDate = ({
|
||||
stripeSubscription,
|
||||
}: {
|
||||
stripeSubscription: Stripe.Subscription;
|
||||
}) => {
|
||||
if (stripeSubscription.items.data.length === 0) {
|
||||
return stripeSubscription.start_date;
|
||||
}
|
||||
|
||||
return stripeSubscription.items.data.reduce((earliestStartDate, item) => {
|
||||
return Math.min(earliestStartDate, item.current_period_start);
|
||||
}, stripeSubscription.items.data[0].current_period_start);
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the latest invoice for a Stripe subscription.
|
||||
* Handles both expanded (object) and unexpanded (string ID) cases.
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import type { SyncMappingV0 } from "@autumn/shared";
|
||||
import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/stripeWebhookContext.js";
|
||||
import { sync } from "@/internal/billing/v2/actions/sync/sync.js";
|
||||
import { findAutumnProductsForSubscription } from "@/internal/billing/v2/providers/stripe/utils/sync/stripeToAutumn/findAutumnProductsForSubscription.js";
|
||||
import { subscriptionToPrepaidFeatureOptions } from "@/internal/billing/v2/providers/stripe/utils/sync/stripeToAutumn/subscriptionToFeatureOptions.js";
|
||||
import { billingActions } from "@/internal/billing/v2/actions";
|
||||
import { canAutoSync } from "@/internal/billing/v2/actions/sync/canAutoSync.js";
|
||||
import { subscriptionToSyncParams } from "@/internal/billing/v2/actions/sync/subscriptionToSyncParams.js";
|
||||
import type { StripeSubscriptionCreatedContext } from "../setupStripeSubscriptionCreatedContext.js";
|
||||
|
||||
/**
|
||||
* On Stripe subscription create, run detection on the new subscription and
|
||||
* (when safe) execute a syncV2 to materialize the matching Autumn customer
|
||||
* products.
|
||||
*
|
||||
* Eligibility is gated by `canAutoSync` — conservative defaults: any
|
||||
* unmatched Stripe item, custom feature price, plan warning, or
|
||||
* unresolvable base price aborts auto-sync. Custom BASE prices are
|
||||
* accepted (handled via `customize.price`).
|
||||
*/
|
||||
export const autoSyncFromSubscription = async ({
|
||||
ctx,
|
||||
subscriptionCreatedContext,
|
||||
@@ -13,53 +22,22 @@ export const autoSyncFromSubscription = async ({
|
||||
subscriptionCreatedContext: StripeSubscriptionCreatedContext;
|
||||
}) => {
|
||||
const { logger } = ctx;
|
||||
const { subscription, fullCustomer, candidateProducts } =
|
||||
subscriptionCreatedContext;
|
||||
const { subscription, fullCustomer } = subscriptionCreatedContext;
|
||||
const customerId = fullCustomer.id ?? fullCustomer.internal_id;
|
||||
|
||||
const matchedProducts = findAutumnProductsForSubscription({
|
||||
stripeSubscription: subscription,
|
||||
products: candidateProducts,
|
||||
const { match, params } = await subscriptionToSyncParams({
|
||||
ctx,
|
||||
customerId,
|
||||
subscription,
|
||||
});
|
||||
|
||||
if (matchedProducts.length === 0) {
|
||||
const eligibility = canAutoSync({ match });
|
||||
if (!eligibility.eligible) {
|
||||
logger.info(
|
||||
`sub.created auto-sync: no Autumn product matched stripe sub ${subscription.id}, skipping`,
|
||||
`sub.created auto-sync skipping ${subscription.id}: ${eligibility.reason} — ${eligibility.details}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (matchedProducts.length > 1) {
|
||||
logger.warn(
|
||||
`sub.created auto-sync: stripe sub ${subscription.id} matched ${matchedProducts.length} Autumn products (${matchedProducts
|
||||
.map((product) => product.id)
|
||||
.join(
|
||||
", ",
|
||||
)}); skipping due to ambiguity. Check for overlapping stripe price IDs across products.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const [matchedProduct] = matchedProducts;
|
||||
const prepaidFeatureOptions = subscriptionToPrepaidFeatureOptions({
|
||||
ctx,
|
||||
stripeSubscription: subscription,
|
||||
matchedProduct,
|
||||
});
|
||||
|
||||
const mappings: SyncMappingV0[] = [
|
||||
{
|
||||
stripe_subscription_id: subscription.id,
|
||||
plan_id: matchedProduct.id,
|
||||
prepaid_feature_options: prepaidFeatureOptions,
|
||||
expire_previous: true,
|
||||
},
|
||||
];
|
||||
|
||||
await sync({
|
||||
ctx,
|
||||
params: {
|
||||
customer_id: fullCustomer.id ?? fullCustomer.internal_id,
|
||||
mappings,
|
||||
},
|
||||
});
|
||||
await billingActions.syncV2({ ctx, params });
|
||||
};
|
||||
|
||||
@@ -13,9 +13,12 @@ import { handlePreviewCreateSchedule } from "./v2/handlers/handlePreviewCreateSc
|
||||
import { handleMultiAttach } from "./v2/handlers/handleMultiAttach.js";
|
||||
import { handlePreviewMultiAttach } from "./v2/handlers/handlePreviewMultiAttach.js";
|
||||
import { handlePreviewUpdateSubscription } from "./v2/handlers/handlePreviewUpdateSubscription.js";
|
||||
import { handleRestore } from "./v2/handlers/handleRestore.js";
|
||||
import { handleSetupPaymentV2 } from "./v2/handlers/handleSetupPaymentV2.js";
|
||||
import { handleSync } from "./v2/handlers/handleSync.js";
|
||||
import { handleSyncProposals } from "./v2/handlers/handleSyncProposals.js";
|
||||
import { handleSyncProposalsV2 } from "./v2/handlers/handleSyncProposalsV2.js";
|
||||
import { handleSyncV2 } from "./v2/handlers/handleSyncV2.js";
|
||||
import { handleUpdateSubscription } from "./v2/handlers/handleUpdateSubscription.js";
|
||||
|
||||
export const billingRouter = new Hono<HonoEnv>();
|
||||
@@ -52,4 +55,7 @@ billingRpcRouter.post(
|
||||
...handleOpenCustomerPortalV2,
|
||||
);
|
||||
billingRpcRouter.post("/billing.sync_proposals", ...handleSyncProposals);
|
||||
billingRpcRouter.post("/billing.sync_proposals_v2", ...handleSyncProposalsV2);
|
||||
billingRpcRouter.post("/billing.sync", ...handleSync);
|
||||
billingRpcRouter.post("/billing.sync_v2", ...handleSyncV2);
|
||||
billingRpcRouter.post("/billing.restore", ...handleRestore);
|
||||
|
||||
@@ -1,41 +1,9 @@
|
||||
import type {
|
||||
CreateScheduleBillingContext,
|
||||
FullCusProduct,
|
||||
ScheduledPhaseContext,
|
||||
} from "@autumn/shared";
|
||||
import { BillingVersion, CusProductStatus } from "@autumn/shared";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import { initFullCustomerProduct } from "@/internal/billing/v2/utils/initFullCustomerProduct/initFullCustomerProduct";
|
||||
|
||||
const initScheduledCustomerProduct = ({
|
||||
ctx,
|
||||
billingContext,
|
||||
phaseContext,
|
||||
productContext,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
billingContext: CreateScheduleBillingContext;
|
||||
phaseContext: ScheduledPhaseContext;
|
||||
productContext: ScheduledPhaseContext["productContexts"][number];
|
||||
}): FullCusProduct => {
|
||||
return initFullCustomerProduct({
|
||||
ctx,
|
||||
initContext: {
|
||||
fullCustomer: billingContext.fullCustomer,
|
||||
fullProduct: productContext.fullProduct,
|
||||
featureQuantities: productContext.featureQuantities,
|
||||
resetCycleAnchor: phaseContext.startsAt,
|
||||
freeTrial: null,
|
||||
now: billingContext.currentEpochMs,
|
||||
billingVersion: BillingVersion.V2,
|
||||
},
|
||||
initOptions: {
|
||||
startsAt: phaseContext.startsAt,
|
||||
endedAt: phaseContext.endsAt,
|
||||
status: CusProductStatus.Scheduled,
|
||||
},
|
||||
});
|
||||
};
|
||||
import { initScheduledCustomerProduct } from "@/internal/billing/v2/utils/initFullCustomerProduct/initScheduledCustomerProduct";
|
||||
|
||||
/** Build scheduled customer products to insert and existing ones to delete. */
|
||||
export const computeScheduledCustomerProducts = ({
|
||||
@@ -59,9 +27,12 @@ export const computeScheduledCustomerProducts = ({
|
||||
for (const productContext of phaseContext.productContexts) {
|
||||
const customerProduct = initScheduledCustomerProduct({
|
||||
ctx,
|
||||
billingContext,
|
||||
phaseContext,
|
||||
productContext,
|
||||
fullCustomer: billingContext.fullCustomer,
|
||||
fullProduct: productContext.fullProduct,
|
||||
featureQuantities: productContext.featureQuantities,
|
||||
startsAt: phaseContext.startsAt,
|
||||
endsAt: phaseContext.endsAt,
|
||||
currentEpochMs: billingContext.currentEpochMs,
|
||||
});
|
||||
insertCustomerProducts.push(customerProduct);
|
||||
phaseCustomerProductIds.push(customerProduct.id);
|
||||
|
||||
@@ -6,9 +6,12 @@ import { renew } from "@/internal/billing/v2/actions/legacy/renew";
|
||||
import { updateQuantity } from "@/internal/billing/v2/actions/legacy/updateQuantity";
|
||||
import { migrate } from "@/internal/billing/v2/actions/migrate/migrate";
|
||||
import { multiAttach } from "@/internal/billing/v2/actions/multiAttach/multiAttach";
|
||||
import { restore } from "@/internal/billing/v2/actions/restore/restore";
|
||||
import { setupPayment } from "@/internal/billing/v2/actions/setupPayment/setupPayment";
|
||||
import { sync } from "@/internal/billing/v2/actions/sync/sync";
|
||||
import { syncProposals } from "@/internal/billing/v2/actions/sync/syncProposals";
|
||||
import { syncProposalsV2 } from "@/internal/billing/v2/actions/sync/syncProposalsV2";
|
||||
import { syncV2 } from "@/internal/billing/v2/actions/sync/syncV2";
|
||||
import { updateSubscription } from "@/internal/billing/v2/actions/updateSubscription/updateSubscription";
|
||||
|
||||
export const billingActions = {
|
||||
@@ -19,8 +22,11 @@ export const billingActions = {
|
||||
setupPayment: setupPayment,
|
||||
updateSubscription: updateSubscription,
|
||||
migrate: migrate,
|
||||
restore: restore,
|
||||
sync: sync,
|
||||
syncV2: syncV2,
|
||||
syncProposals: syncProposals,
|
||||
syncProposalsV2: syncProposalsV2,
|
||||
|
||||
legacy: {
|
||||
attach: legacyAttach,
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import {
|
||||
InternalError,
|
||||
type StripeBillingPlan,
|
||||
type StripeSubscriptionAction,
|
||||
type StripeSubscriptionScheduleAction,
|
||||
} from "@autumn/shared";
|
||||
|
||||
const ALLOWED_SUB_ACTION_TYPES = new Set<StripeSubscriptionAction["type"]>([
|
||||
"update",
|
||||
]);
|
||||
|
||||
const ALLOWED_SCHEDULE_ACTION_TYPES = new Set<
|
||||
StripeSubscriptionScheduleAction["type"]
|
||||
>(["update", "create"]);
|
||||
|
||||
/**
|
||||
* Restore must never create new subs, cancel subs, charge invoices, or release
|
||||
* schedules. A "release" specifically signals the schedule was never imported
|
||||
* into Autumn — silently releasing it would compound the drift restore is meant
|
||||
* to fix. Anything other than no-op / update for the subscription, or no-op /
|
||||
* update / create for the schedule, throws.
|
||||
*/
|
||||
export const handleRestoreErrors = ({
|
||||
stripeBillingPlan,
|
||||
stripeSubscriptionId,
|
||||
}: {
|
||||
stripeBillingPlan: StripeBillingPlan;
|
||||
stripeSubscriptionId: string;
|
||||
}) => {
|
||||
const {
|
||||
subscriptionAction,
|
||||
subscriptionScheduleAction,
|
||||
invoiceAction,
|
||||
invoiceItemsAction,
|
||||
checkoutSessionAction,
|
||||
refundAction,
|
||||
} = stripeBillingPlan;
|
||||
|
||||
if (
|
||||
subscriptionAction &&
|
||||
!ALLOWED_SUB_ACTION_TYPES.has(subscriptionAction.type)
|
||||
) {
|
||||
throw new InternalError({
|
||||
message: `[Restore] Unexpected subscription action '${subscriptionAction.type}' for subscription ${stripeSubscriptionId}. Restore only allows 'update'.`,
|
||||
code: "restore_unexpected_subscription_action",
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
subscriptionScheduleAction &&
|
||||
!ALLOWED_SCHEDULE_ACTION_TYPES.has(subscriptionScheduleAction.type)
|
||||
) {
|
||||
throw new InternalError({
|
||||
message: `[Restore] Unexpected schedule action '${subscriptionScheduleAction.type}' for subscription ${stripeSubscriptionId}. Restore only allows 'update' or 'create'; 'release' usually means the schedule was never imported into Autumn.`,
|
||||
code: "restore_unexpected_schedule_action",
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
invoiceAction ||
|
||||
invoiceItemsAction ||
|
||||
checkoutSessionAction ||
|
||||
refundAction
|
||||
) {
|
||||
throw new InternalError({
|
||||
message: `[Restore] Unexpected non-subscription action produced for ${stripeSubscriptionId}. Restore should only mutate Stripe subscription/schedule state.`,
|
||||
code: "restore_unexpected_action",
|
||||
});
|
||||
}
|
||||
};
|
||||
71
server/src/internal/billing/v2/actions/restore/restore.ts
Normal file
71
server/src/internal/billing/v2/actions/restore/restore.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import type {
|
||||
AutumnBillingPlan,
|
||||
RestoreParamsV1,
|
||||
RestoreResponse,
|
||||
RestoreSubscriptionResult,
|
||||
} from "@autumn/shared";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import { evaluateStripeBillingPlan } from "@/internal/billing/v2/providers/stripe/actionBuilders/evaluateStripeBillingPlan";
|
||||
import { executeStripeBillingPlan } from "@/internal/billing/v2/providers/stripe/execute/executeStripeBillingPlan";
|
||||
import { handleRestoreErrors } from "./errors/handleRestoreErrors";
|
||||
import { buildRestoreBillingContext } from "./setup/buildRestoreBillingContext";
|
||||
import { setupRestoreContext } from "./setup/setupRestoreContext";
|
||||
|
||||
export const restore = async ({
|
||||
ctx,
|
||||
params,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
params: RestoreParamsV1;
|
||||
}): Promise<RestoreResponse> => {
|
||||
const { customer_id: customerId } = params;
|
||||
|
||||
const { fullCustomer, stripeCustomer, subscriptionIds } =
|
||||
await setupRestoreContext({ ctx, customerId });
|
||||
|
||||
const restored: RestoreSubscriptionResult[] = [];
|
||||
|
||||
for (const stripeSubscriptionId of subscriptionIds) {
|
||||
const billingContext = await buildRestoreBillingContext({
|
||||
ctx,
|
||||
fullCustomer,
|
||||
stripeCustomer,
|
||||
stripeSubscriptionId,
|
||||
});
|
||||
|
||||
const autumnBillingPlan: AutumnBillingPlan = {
|
||||
customerId,
|
||||
insertCustomerProducts: [],
|
||||
};
|
||||
|
||||
const stripeBillingPlan = await evaluateStripeBillingPlan({
|
||||
ctx,
|
||||
billingContext,
|
||||
autumnBillingPlan,
|
||||
});
|
||||
|
||||
handleRestoreErrors({ stripeBillingPlan, stripeSubscriptionId });
|
||||
|
||||
await executeStripeBillingPlan({
|
||||
ctx,
|
||||
billingPlan: { autumn: autumnBillingPlan, stripe: stripeBillingPlan },
|
||||
billingContext,
|
||||
});
|
||||
|
||||
restored.push({
|
||||
stripe_subscription_id: stripeSubscriptionId,
|
||||
stripe_schedule_id: billingContext.stripeSubscriptionSchedule?.id ?? null,
|
||||
sub_action: stripeBillingPlan.subscriptionAction ? "update" : "noop",
|
||||
schedule_action: stripeBillingPlan.subscriptionScheduleAction
|
||||
? (stripeBillingPlan.subscriptionScheduleAction.type as
|
||||
| "update"
|
||||
| "create")
|
||||
: "noop",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
customer_id: customerId,
|
||||
restored,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
type BillingContext,
|
||||
BillingVersion,
|
||||
type FullCustomer,
|
||||
InternalError,
|
||||
secondsToMs,
|
||||
} from "@autumn/shared";
|
||||
import type Stripe from "stripe";
|
||||
import { createStripeCli } from "@/external/connect/createStripeCli";
|
||||
import { getStripeActiveSubscriptionSchedule } from "@/external/stripe/subscriptionSchedules/index";
|
||||
import { isStripeSubscriptionCanceled } from "@/external/stripe/subscriptions/utils/classifyStripeSubscriptionUtils";
|
||||
import { stripeSubscriptionToScheduleId } from "@/external/stripe/subscriptions/utils/convertStripeSubscription";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
|
||||
export const buildRestoreBillingContext = async ({
|
||||
ctx,
|
||||
fullCustomer,
|
||||
stripeCustomer,
|
||||
stripeSubscriptionId,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
fullCustomer: FullCustomer;
|
||||
stripeCustomer?: Stripe.Customer;
|
||||
stripeSubscriptionId: string;
|
||||
}): Promise<BillingContext> => {
|
||||
const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env });
|
||||
|
||||
const stripeSubscription = await stripeCli.subscriptions.retrieve(
|
||||
stripeSubscriptionId,
|
||||
{ expand: ["discounts.source.coupon.applies_to"] },
|
||||
);
|
||||
|
||||
if (isStripeSubscriptionCanceled(stripeSubscription)) {
|
||||
throw new InternalError({
|
||||
message: `[Restore] Stripe subscription is canceled: ${stripeSubscriptionId}`,
|
||||
});
|
||||
}
|
||||
|
||||
const scheduleId = stripeSubscriptionToScheduleId({ stripeSubscription });
|
||||
|
||||
const stripeSubscriptionSchedule = scheduleId
|
||||
? await getStripeActiveSubscriptionSchedule({
|
||||
stripeClient: stripeCli,
|
||||
subscriptionScheduleId: scheduleId,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
fullCustomer,
|
||||
fullProducts: [],
|
||||
featureQuantities: [],
|
||||
currentEpochMs: Date.now(),
|
||||
billingCycleAnchorMs: secondsToMs(stripeSubscription.billing_cycle_anchor),
|
||||
resetCycleAnchorMs: "now",
|
||||
|
||||
stripeCustomer,
|
||||
stripeSubscription,
|
||||
stripeSubscriptionSchedule,
|
||||
|
||||
billingVersion: BillingVersion.V2,
|
||||
actionSource: "restore",
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
ACTIVE_STATUSES,
|
||||
CusProductStatus,
|
||||
cusProductToPrices,
|
||||
type FullCusProduct,
|
||||
type FullCustomer,
|
||||
isFreeProduct,
|
||||
isOneOffProduct,
|
||||
} from "@autumn/shared";
|
||||
import type Stripe from "stripe";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import { fetchStripeCustomerForBilling } from "@/internal/billing/v2/providers/stripe/setup/fetchStripeCustomerForBilling";
|
||||
import { setupFullCustomerContext } from "@/internal/billing/v2/setup/setupFullCustomerContext";
|
||||
|
||||
export type RestoreContext = {
|
||||
fullCustomer: FullCustomer;
|
||||
stripeCustomer?: Stripe.Customer;
|
||||
subscriptionIds: string[];
|
||||
};
|
||||
|
||||
const isPaidRecurring = (customerProduct: FullCusProduct) => {
|
||||
const hasActiveStatus =
|
||||
ACTIVE_STATUSES.includes(customerProduct.status) ||
|
||||
customerProduct.status === CusProductStatus.Trialing;
|
||||
if (!hasActiveStatus) return false;
|
||||
|
||||
const prices = cusProductToPrices({ cusProduct: customerProduct });
|
||||
return !isOneOffProduct({ prices }) && !isFreeProduct({ prices });
|
||||
};
|
||||
|
||||
export const setupRestoreContext = async ({
|
||||
ctx,
|
||||
customerId,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
customerId: string;
|
||||
}): Promise<RestoreContext> => {
|
||||
const fullCustomer = await setupFullCustomerContext({
|
||||
ctx,
|
||||
params: { customer_id: customerId },
|
||||
});
|
||||
|
||||
const { stripeCus } = await fetchStripeCustomerForBilling({
|
||||
ctx,
|
||||
fullCus: fullCustomer,
|
||||
});
|
||||
|
||||
const uniqueSubscriptionIds = new Set<string>();
|
||||
for (const customerProduct of fullCustomer.customer_products) {
|
||||
if (!isPaidRecurring(customerProduct)) continue;
|
||||
const subscriptionId = customerProduct.subscription_ids?.[0];
|
||||
if (subscriptionId) uniqueSubscriptionIds.add(subscriptionId);
|
||||
}
|
||||
|
||||
return {
|
||||
fullCustomer,
|
||||
stripeCustomer: stripeCus,
|
||||
subscriptionIds: [...uniqueSubscriptionIds],
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
import {
|
||||
type FeatureQuantityParamsV0,
|
||||
isPrepaidPrice,
|
||||
priceToEnt,
|
||||
} from "@autumn/shared";
|
||||
import { Decimal } from "decimal.js";
|
||||
import type {
|
||||
ItemDiff,
|
||||
MatchedPlan,
|
||||
} from "@/internal/billing/v2/actions/sync/detect/types";
|
||||
|
||||
/**
|
||||
* Build feature_quantities for a MatchedPlan by walking the product's
|
||||
* prepaid prices.
|
||||
*
|
||||
* Stripe stores the same Autumn prepaid price under TWO Stripe price ids
|
||||
* with different quantity semantics:
|
||||
*
|
||||
* - `price.config.stripe_price_id` (V1): Stripe quantity counts EXTRAS
|
||||
* only — allowance is implicit, not billed. We add `allowance` back so
|
||||
* `paramsToFeatureOptions`'s allowance subtraction resolves to extras.
|
||||
*
|
||||
* - `price.config.stripe_prepaid_price_v2_id` (V2): Stripe quantity is
|
||||
* TOTAL packs (allowance included). Pass through unchanged.
|
||||
*
|
||||
* Either way the emitted `quantity` is in feature units, which is what
|
||||
* `paramsToFeatureOptions` expects.
|
||||
*
|
||||
* If a prepaid price is defined in Autumn but absent from Stripe, the
|
||||
* helper emits `{ quantity: 0 }` so the synced cusProduct surfaces the
|
||||
* feature with zero allowance.
|
||||
*/
|
||||
export const buildFeatureQuantities = ({
|
||||
matchedPlan,
|
||||
itemDiffs,
|
||||
}: {
|
||||
matchedPlan: MatchedPlan;
|
||||
itemDiffs: ItemDiff[];
|
||||
}): FeatureQuantityParamsV0[] => {
|
||||
const result: FeatureQuantityParamsV0[] = [];
|
||||
|
||||
for (const price of matchedPlan.product.prices) {
|
||||
if (!isPrepaidPrice(price)) continue;
|
||||
|
||||
const entitlement = priceToEnt({
|
||||
price,
|
||||
entitlements: matchedPlan.product.entitlements,
|
||||
});
|
||||
if (!entitlement) continue;
|
||||
|
||||
const matchedFeature = matchedPlan.features.find(
|
||||
(f) => f.autumn_price_id === price.id,
|
||||
);
|
||||
|
||||
if (!matchedFeature) {
|
||||
result.push({ feature_id: entitlement.feature.id, quantity: 0 });
|
||||
continue;
|
||||
}
|
||||
|
||||
const itemDiff = itemDiffs.find(
|
||||
(d) => d.stripe.id === matchedFeature.stripe_item_id,
|
||||
);
|
||||
if (!itemDiff) {
|
||||
result.push({ feature_id: entitlement.feature.id, quantity: 0 });
|
||||
continue;
|
||||
}
|
||||
|
||||
const billingUnits = price.config.billing_units ?? 1;
|
||||
const allowance = entitlement.allowance ?? 0;
|
||||
const stripePriceIdOnSub = itemDiff.stripe.stripe_price_id;
|
||||
const isV2Prepaid =
|
||||
"stripe_prepaid_price_v2_id" in price.config &&
|
||||
stripePriceIdOnSub === price.config.stripe_prepaid_price_v2_id;
|
||||
|
||||
const stripeQuantityInUnits = new Decimal(itemDiff.stripe.quantity)
|
||||
.mul(billingUnits)
|
||||
.toNumber();
|
||||
const featureUnits = isV2Prepaid
|
||||
? stripeQuantityInUnits
|
||||
: stripeQuantityInUnits + allowance;
|
||||
|
||||
result.push({
|
||||
feature_id: entitlement.feature.id,
|
||||
quantity: featureUnits,
|
||||
stripe_price_id: stripePriceIdOnSub,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
159
server/src/internal/billing/v2/actions/sync/canAutoSync.ts
Normal file
159
server/src/internal/billing/v2/actions/sync/canAutoSync.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import type {
|
||||
PhaseMatch,
|
||||
PlanWarning,
|
||||
SubscriptionMatch,
|
||||
} from "./detect/types";
|
||||
|
||||
export type AutoSyncRejectionReason =
|
||||
| "no_matched_plans"
|
||||
| "multiple_main_plans"
|
||||
| "plan_warnings"
|
||||
| "base_price_unresolvable"
|
||||
| "custom_feature_price"
|
||||
| "base_quantity_gt_one";
|
||||
|
||||
export type AutoSyncEligibility =
|
||||
| { eligible: true }
|
||||
| {
|
||||
eligible: false;
|
||||
reason: AutoSyncRejectionReason;
|
||||
details: string;
|
||||
};
|
||||
|
||||
const findCurrentPhase = ({
|
||||
match,
|
||||
}: {
|
||||
match: SubscriptionMatch;
|
||||
}): PhaseMatch | null =>
|
||||
match.phaseMatches.find((phase) => phase.is_current) ?? null;
|
||||
|
||||
/**
|
||||
* Default warnings that auto-sync tolerates without human review:
|
||||
* - `base_price_dropped` — Autumn product has a base price but the Stripe
|
||||
* sub omits it; the sync materializes the cusProduct with
|
||||
* `customize: { price: null }`.
|
||||
* - `base_price_adopted` — strategy A: an unclaimed extra item filled in
|
||||
* for the missing base. Captured via `customize.price`.
|
||||
*
|
||||
* Blocking warnings (NOT in this default set):
|
||||
* - `extra_items_under_plan` — extras Autumn can't yet express; ambiguous.
|
||||
* - `base_plan_quantity_gt_one` — handled separately below.
|
||||
*/
|
||||
const DEFAULT_ALLOWED_WARNINGS: PlanWarning["type"][] = [
|
||||
"base_price_dropped",
|
||||
"base_price_adopted",
|
||||
];
|
||||
|
||||
/**
|
||||
* Decide whether a `SubscriptionMatch` is safe to sync without human review.
|
||||
*
|
||||
* Conservative defaults — eligible iff the current phase:
|
||||
* - has at least one MatchedPlan
|
||||
* - has no MatchedPlan with `base.kind === "absent"` (Autumn product
|
||||
* expects a base price but none could be derived)
|
||||
* - has no Stripe item matching a non-base Autumn price via product id
|
||||
* (custom feature price)
|
||||
* - has no non-add-on plan with Stripe item quantity > 1
|
||||
* - emits only PlanWarnings in `allowedWarnings`
|
||||
*
|
||||
* Stripe items that don't match anything (`ItemDiff.match.kind === "none"`)
|
||||
* are tolerated — they're external/unrelated prices that don't influence
|
||||
* the Autumn cusProducts being attached.
|
||||
*/
|
||||
export const canAutoSync = ({
|
||||
match,
|
||||
allowedWarnings = DEFAULT_ALLOWED_WARNINGS,
|
||||
}: {
|
||||
match: SubscriptionMatch;
|
||||
allowedWarnings?: PlanWarning["type"][];
|
||||
}): AutoSyncEligibility => {
|
||||
const currentPhase = findCurrentPhase({ match });
|
||||
|
||||
if (!currentPhase || currentPhase.plans.length === 0) {
|
||||
return {
|
||||
eligible: false,
|
||||
reason: "no_matched_plans",
|
||||
details: "No Autumn plans matched the current phase.",
|
||||
};
|
||||
}
|
||||
|
||||
// More than one non-add-on plan in the current phase is ambiguous —
|
||||
// "main" plans are mutually exclusive within a customer (typically a
|
||||
// single tier per product group). Auto-sync requires a clear primary
|
||||
// plan to attach; add-ons may stack on top.
|
||||
const mainPlans = currentPhase.plans.filter(
|
||||
(plan) => plan.product.is_add_on !== true,
|
||||
);
|
||||
if (mainPlans.length > 1) {
|
||||
return {
|
||||
eligible: false,
|
||||
reason: "multiple_main_plans",
|
||||
details: `Stripe sub matched multiple non-add-on Autumn plans: ${mainPlans.map((p) => p.product.id).join(", ")}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Block when any Stripe item matches a non-base Autumn price via
|
||||
// stripe_product_id (priority-2). That signals a custom Stripe price
|
||||
// for a feature/prepaid item — we don't auto-rewrite feature prices on
|
||||
// sync. Custom BASE prices (priority-3, kind: "autumn_product") are
|
||||
// allowed since `customize.price` captures them safely.
|
||||
const customFeaturePriceItems = currentPhase.item_diffs.filter(
|
||||
(diff) =>
|
||||
diff.match.kind === "autumn_price" &&
|
||||
diff.match.matched_on.type === "stripe_product_id",
|
||||
);
|
||||
if (customFeaturePriceItems.length > 0) {
|
||||
return {
|
||||
eligible: false,
|
||||
reason: "custom_feature_price",
|
||||
details: `Stripe items use custom prices for feature items: ${customFeaturePriceItems
|
||||
.map((d) => d.stripe.id)
|
||||
.join(", ")}`,
|
||||
};
|
||||
}
|
||||
|
||||
const absentBase = currentPhase.plans.find(
|
||||
(plan) => plan.base.kind === "absent",
|
||||
);
|
||||
if (absentBase) {
|
||||
return {
|
||||
eligible: false,
|
||||
reason: "base_price_unresolvable",
|
||||
details: `Plan ${absentBase.product.id} has no resolvable base price.`,
|
||||
};
|
||||
}
|
||||
|
||||
// Stripe item quantity > 1 on a non-add-on plan is ambiguous (the
|
||||
// detection rollup tags it via the `base_plan_quantity_gt_one` warning).
|
||||
// We surface a dedicated rejection reason so logs are explicit.
|
||||
const bigQuantityPlan = currentPhase.plans.find((plan) =>
|
||||
plan.warnings.some((w) => w.type === "base_plan_quantity_gt_one"),
|
||||
);
|
||||
if (bigQuantityPlan) {
|
||||
return {
|
||||
eligible: false,
|
||||
reason: "base_quantity_gt_one",
|
||||
details: `Plan ${bigQuantityPlan.product.id} is not an add-on but its Stripe item has quantity > 1.`,
|
||||
};
|
||||
}
|
||||
|
||||
const allowedSet = new Set(allowedWarnings);
|
||||
const blockingWarnings = currentPhase.plans.flatMap((plan) =>
|
||||
plan.warnings
|
||||
.filter((warning) => !allowedSet.has(warning.type))
|
||||
.map(
|
||||
(warning) => ({ planId: plan.product.id, type: warning.type }),
|
||||
),
|
||||
);
|
||||
if (blockingWarnings.length > 0) {
|
||||
return {
|
||||
eligible: false,
|
||||
reason: "plan_warnings",
|
||||
details: blockingWarnings
|
||||
.map((w) => `${w.planId}: ${w.type}`)
|
||||
.join("; "),
|
||||
};
|
||||
}
|
||||
|
||||
return { eligible: true };
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
import type {
|
||||
Entitlement,
|
||||
FullCusProduct,
|
||||
Price,
|
||||
SyncBillingContext,
|
||||
} from "@autumn/shared";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import { initScheduledCustomerProduct } from "@/internal/billing/v2/utils/initFullCustomerProduct/initScheduledCustomerProduct";
|
||||
|
||||
export type ComputedSchedulePhase = {
|
||||
startsAt: number;
|
||||
endsAt: number | null;
|
||||
customerProductIds: string[];
|
||||
};
|
||||
|
||||
export type FuturePhasesResult = {
|
||||
insertCustomerProducts: FullCusProduct[];
|
||||
customPrices: Price[];
|
||||
customEntitlements: Entitlement[];
|
||||
scheduledPhases: ComputedSchedulePhase[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Build cusProducts (status=Scheduled) for every non-immediate phase plus
|
||||
* the per-phase descriptors that `persistSyncPhases` writes after execute.
|
||||
*/
|
||||
export const computeSyncFuturePhases = ({
|
||||
ctx,
|
||||
syncContext,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
syncContext: SyncBillingContext;
|
||||
}): FuturePhasesResult => {
|
||||
const {
|
||||
futurePhases,
|
||||
fullCustomer,
|
||||
currentEpochMs,
|
||||
stripeSubscription,
|
||||
stripeSchedule,
|
||||
} = syncContext;
|
||||
|
||||
const insertCustomerProducts: FullCusProduct[] = [];
|
||||
const customPrices: Price[] = [];
|
||||
const customEntitlements: Entitlement[] = [];
|
||||
const scheduledPhases: ComputedSchedulePhase[] = [];
|
||||
|
||||
for (const phaseContext of futurePhases) {
|
||||
const phaseIds: string[] = [];
|
||||
|
||||
for (const productContext of phaseContext.productContexts) {
|
||||
const cusProduct = initScheduledCustomerProduct({
|
||||
ctx,
|
||||
fullCustomer,
|
||||
fullProduct: productContext.fullProduct,
|
||||
featureQuantities: productContext.featureQuantities,
|
||||
startsAt: phaseContext.startsAt,
|
||||
endsAt: phaseContext.endsAt,
|
||||
currentEpochMs,
|
||||
subscriptionId: stripeSubscription?.id,
|
||||
subscriptionScheduleId: stripeSchedule?.id,
|
||||
});
|
||||
insertCustomerProducts.push(cusProduct);
|
||||
phaseIds.push(cusProduct.id);
|
||||
customPrices.push(...productContext.customPrices);
|
||||
customEntitlements.push(...productContext.customEntitlements);
|
||||
}
|
||||
|
||||
scheduledPhases.push({
|
||||
startsAt: phaseContext.startsAt,
|
||||
endsAt: phaseContext.endsAt,
|
||||
customerProductIds: phaseIds,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
insertCustomerProducts,
|
||||
customPrices,
|
||||
customEntitlements,
|
||||
scheduledPhases,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,98 @@
|
||||
import {
|
||||
type AutumnBillingPlan,
|
||||
CusProductStatus,
|
||||
type Entitlement,
|
||||
type FullCusProduct,
|
||||
type Price,
|
||||
type SyncBillingContext,
|
||||
} from "@autumn/shared";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import { initImmediateSyncCustomerProduct } from "./initImmediateSyncCustomerProduct";
|
||||
|
||||
type CustomerProductUpdate = NonNullable<
|
||||
AutumnBillingPlan["updateCustomerProducts"]
|
||||
>[number];
|
||||
|
||||
export type ImmediatePhaseResult = {
|
||||
insertCustomerProducts: FullCusProduct[];
|
||||
updateCustomerProducts: CustomerProductUpdate[];
|
||||
customPrices: Price[];
|
||||
customEntitlements: Entitlement[];
|
||||
};
|
||||
|
||||
const expireCustomerProduct = ({
|
||||
customerProduct,
|
||||
currentEpochMs,
|
||||
}: {
|
||||
customerProduct: FullCusProduct;
|
||||
currentEpochMs: number;
|
||||
}): CustomerProductUpdate => ({
|
||||
customerProduct,
|
||||
updates: {
|
||||
status: CusProductStatus.Expired,
|
||||
ended_at: currentEpochMs,
|
||||
canceled: true,
|
||||
canceled_at: currentEpochMs,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Build the immediate-phase cusProducts to insert plus the existing
|
||||
* cusProducts to expire (when `expire_previous` was set on the input plan).
|
||||
*
|
||||
* Returns an empty result when the sync has no immediate phase or no live
|
||||
* Stripe subscription to derive lifecycle metadata from.
|
||||
*/
|
||||
export const computeSyncImmediatePhase = ({
|
||||
ctx,
|
||||
syncContext,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
syncContext: SyncBillingContext;
|
||||
}): ImmediatePhaseResult => {
|
||||
const { immediatePhase, fullCustomer, stripeSubscription, currentEpochMs } =
|
||||
syncContext;
|
||||
if (!immediatePhase || !stripeSubscription) {
|
||||
return {
|
||||
insertCustomerProducts: [],
|
||||
updateCustomerProducts: [],
|
||||
customPrices: [],
|
||||
customEntitlements: [],
|
||||
};
|
||||
}
|
||||
|
||||
const insertCustomerProducts: FullCusProduct[] = [];
|
||||
const updateCustomerProducts: CustomerProductUpdate[] = [];
|
||||
const customPrices: Price[] = [];
|
||||
const customEntitlements: Entitlement[] = [];
|
||||
|
||||
for (const productContext of immediatePhase.productContexts) {
|
||||
insertCustomerProducts.push(
|
||||
initImmediateSyncCustomerProduct({
|
||||
ctx,
|
||||
fullCustomer,
|
||||
productContext,
|
||||
stripeSubscription,
|
||||
currentEpochMs,
|
||||
}),
|
||||
);
|
||||
customPrices.push(...productContext.customPrices);
|
||||
customEntitlements.push(...productContext.customEntitlements);
|
||||
|
||||
if (productContext.currentCustomerProduct) {
|
||||
updateCustomerProducts.push(
|
||||
expireCustomerProduct({
|
||||
customerProduct: productContext.currentCustomerProduct,
|
||||
currentEpochMs,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
insertCustomerProducts,
|
||||
updateCustomerProducts,
|
||||
customPrices,
|
||||
customEntitlements,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { AutumnBillingPlan, SyncBillingContext } from "@autumn/shared";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import { initSubscriptionFromStripe } from "@/internal/subscriptions/utils/initSubscriptionFromStripe";
|
||||
import {
|
||||
type ComputedSchedulePhase,
|
||||
computeSyncFuturePhases,
|
||||
} from "./computeSyncFuturePhases";
|
||||
import { computeSyncImmediatePhase } from "./computeSyncImmediatePhase";
|
||||
|
||||
export type { ComputedSchedulePhase } from "./computeSyncFuturePhases";
|
||||
|
||||
export type ComputedSyncPlan = {
|
||||
autumnBillingPlan: AutumnBillingPlan;
|
||||
/**
|
||||
* Phase descriptors for `persistSyncPhases` to write. Empty when the
|
||||
* sync is single-phase (immediate-only or schedule-only with one phase) —
|
||||
* in that case no Autumn schedule is created.
|
||||
*/
|
||||
phases: ComputedSchedulePhase[];
|
||||
};
|
||||
|
||||
const hasMultiplePhases = ({
|
||||
syncContext,
|
||||
}: {
|
||||
syncContext: SyncBillingContext;
|
||||
}): boolean =>
|
||||
(syncContext.immediatePhase ? 1 : 0) + syncContext.futurePhases.length > 1;
|
||||
|
||||
/** Compose the AutumnBillingPlan from the immediate + future phase computations. */
|
||||
export const computeSyncPlan = ({
|
||||
ctx,
|
||||
syncContext,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
syncContext: SyncBillingContext;
|
||||
}): ComputedSyncPlan => {
|
||||
const immediate = computeSyncImmediatePhase({ ctx, syncContext });
|
||||
const future = computeSyncFuturePhases({ ctx, syncContext });
|
||||
|
||||
const upsertSubscription = syncContext.stripeSubscription
|
||||
? initSubscriptionFromStripe({
|
||||
ctx,
|
||||
stripeSubscription: syncContext.stripeSubscription,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const autumnBillingPlan: AutumnBillingPlan = {
|
||||
customerId:
|
||||
syncContext.fullCustomer.id ?? syncContext.fullCustomer.internal_id,
|
||||
insertCustomerProducts: [
|
||||
...immediate.insertCustomerProducts,
|
||||
...future.insertCustomerProducts,
|
||||
],
|
||||
updateCustomerProducts:
|
||||
immediate.updateCustomerProducts.length > 0
|
||||
? immediate.updateCustomerProducts
|
||||
: undefined,
|
||||
customPrices: [...immediate.customPrices, ...future.customPrices],
|
||||
customEntitlements: [
|
||||
...immediate.customEntitlements,
|
||||
...future.customEntitlements,
|
||||
],
|
||||
upsertSubscription,
|
||||
};
|
||||
|
||||
// Single-phase sync (no schedule) → don't materialize any Autumn schedule.
|
||||
if (!hasMultiplePhases({ syncContext })) {
|
||||
return { autumnBillingPlan, phases: [] };
|
||||
}
|
||||
|
||||
const immediateDescriptor: ComputedSchedulePhase | null =
|
||||
syncContext.immediatePhase
|
||||
? {
|
||||
startsAt: syncContext.immediatePhase.startsAt,
|
||||
endsAt: syncContext.immediatePhase.endsAt,
|
||||
customerProductIds: immediate.insertCustomerProducts.map(
|
||||
(cp) => cp.id,
|
||||
),
|
||||
}
|
||||
: null;
|
||||
|
||||
const phases = immediateDescriptor
|
||||
? [immediateDescriptor, ...future.scheduledPhases]
|
||||
: future.scheduledPhases;
|
||||
|
||||
return { autumnBillingPlan, phases };
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
import {
|
||||
BillingVersion,
|
||||
type FullCusProduct,
|
||||
type FullCustomer,
|
||||
secondsToMs,
|
||||
type SyncProductContext,
|
||||
} from "@autumn/shared";
|
||||
import type Stripe from "stripe";
|
||||
import { stripeSubscriptionToAutumnStatus } from "@/external/stripe/subscriptions/utils/convertStripeSubscription";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import {
|
||||
getCancelFieldsFromStripe,
|
||||
getTrialEndsAtFromStripe,
|
||||
} from "@/internal/billing/v2/actions/sync/utils/initSyncFromStripe";
|
||||
import { initFullCustomerProduct } from "@/internal/billing/v2/utils/initFullCustomerProduct/initFullCustomerProduct";
|
||||
|
||||
/**
|
||||
* Build the immediate-phase cusProduct row for one plan instance, mirroring
|
||||
* the legacy `processSyncMapping` flow:
|
||||
* - inherit trial/cancel timestamps from the Stripe subscription
|
||||
* - anchor the reset cycle to the Stripe billing_cycle_anchor
|
||||
* - link the Stripe subscription id
|
||||
* - apply prepaid feature quantities + customize-derived custom prices/ents
|
||||
*/
|
||||
export const initImmediateSyncCustomerProduct = ({
|
||||
ctx,
|
||||
fullCustomer,
|
||||
productContext,
|
||||
stripeSubscription,
|
||||
currentEpochMs,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
fullCustomer: FullCustomer;
|
||||
productContext: SyncProductContext;
|
||||
stripeSubscription: Stripe.Subscription;
|
||||
currentEpochMs: number;
|
||||
}): FullCusProduct => {
|
||||
const { plan, fullProduct, featureQuantities } = productContext;
|
||||
|
||||
const trialEndsAt = getTrialEndsAtFromStripe({ stripeSubscription });
|
||||
const { canceledAt, endedAt } = getCancelFieldsFromStripe({
|
||||
stripeSubscription,
|
||||
});
|
||||
const resetCycleAnchorMs = secondsToMs(stripeSubscription.billing_cycle_anchor);
|
||||
|
||||
return initFullCustomerProduct({
|
||||
ctx,
|
||||
initContext: {
|
||||
fullCustomer,
|
||||
fullProduct,
|
||||
featureQuantities,
|
||||
resetCycleAnchor: resetCycleAnchorMs,
|
||||
now: currentEpochMs,
|
||||
freeTrial: null,
|
||||
trialEndsAt,
|
||||
billingVersion: BillingVersion.V2,
|
||||
},
|
||||
initOptions: {
|
||||
subscriptionId: stripeSubscription.id,
|
||||
isCustom: Boolean(plan.customize),
|
||||
canceledAt,
|
||||
endedAt,
|
||||
startsAt: stripeSubscription.start_date
|
||||
? secondsToMs(stripeSubscription.start_date)
|
||||
: undefined,
|
||||
keepSubscriptionIds: true,
|
||||
status: stripeSubscriptionToAutumnStatus({
|
||||
stripeStatus: stripeSubscription.status,
|
||||
}),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import type Stripe from "stripe";
|
||||
import { stripeSubscriptionToScheduleId } from "@/external/stripe/subscriptions/utils/convertStripeSubscription";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import { normalizeSubscriptionPhases } from "@/internal/billing/v2/providers/stripe/utils/sync/stripeItemSnapshot/normalizeSubscriptionPhases";
|
||||
import { findAutumnMatchForStripeItem } from "@/internal/billing/v2/providers/stripe/utils/sync/stripeToAutumn/findAutumnMatchForStripeItem";
|
||||
import { ProductService } from "@/internal/products/ProductService";
|
||||
import { rollupMatchedPlans } from "./rollupMatchedPlans";
|
||||
import type { PhaseMatch, SubscriptionMatch } from "./types";
|
||||
|
||||
/**
|
||||
* Detect how a Stripe subscription and/or schedule maps to Autumn plans.
|
||||
*
|
||||
* - subscription only → single open-ended phase from sub.items
|
||||
* - schedule only → one phase per schedule.phases (e.g. future-
|
||||
* dated schedule with no subscription yet)
|
||||
* - subscription + schedule → schedule phases (subscription provides id)
|
||||
*
|
||||
* Pipeline: normalize → per-item match → rollup.
|
||||
*/
|
||||
export const detectSubscriptionMatch = async ({
|
||||
ctx,
|
||||
subscription,
|
||||
schedule,
|
||||
nowSec,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
subscription?: Stripe.Subscription;
|
||||
schedule?: Stripe.SubscriptionSchedule;
|
||||
nowSec?: number;
|
||||
}): Promise<SubscriptionMatch> => {
|
||||
if (!subscription && !schedule) {
|
||||
throw new Error(
|
||||
"detectSubscriptionMatch requires a subscription or a schedule",
|
||||
);
|
||||
}
|
||||
|
||||
const phaseSnapshots = normalizeSubscriptionPhases({
|
||||
subscription,
|
||||
schedule,
|
||||
nowSec,
|
||||
});
|
||||
|
||||
const fullProducts = await ProductService.listFull({
|
||||
db: ctx.db,
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
});
|
||||
|
||||
const phaseMatches: PhaseMatch[] = phaseSnapshots.map((snapshot) => {
|
||||
const itemDiffs = snapshot.items.map((item) =>
|
||||
findAutumnMatchForStripeItem({ item, fullProducts }),
|
||||
);
|
||||
const plans = rollupMatchedPlans({ itemDiffs });
|
||||
return {
|
||||
start_date: snapshot.start_date,
|
||||
end_date: snapshot.end_date,
|
||||
is_current: snapshot.is_current,
|
||||
item_diffs: itemDiffs,
|
||||
plans,
|
||||
};
|
||||
});
|
||||
|
||||
const stripeScheduleId =
|
||||
schedule?.id ??
|
||||
stripeSubscriptionToScheduleId({ stripeSubscription: subscription }) ??
|
||||
null;
|
||||
|
||||
return {
|
||||
stripe_subscription_id: subscription?.id ?? null,
|
||||
stripe_schedule_id: stripeScheduleId,
|
||||
phaseMatches,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,257 @@
|
||||
import {
|
||||
BillingInterval,
|
||||
type CustomizePlanV1,
|
||||
type FullProduct,
|
||||
isFixedPrice,
|
||||
stripeToAtmnAmount,
|
||||
} from "@autumn/shared";
|
||||
import type { StripeItemSnapshot } from "@/internal/billing/v2/providers/stripe/utils/sync/stripeItemSnapshot/types";
|
||||
import type {
|
||||
ItemDiff,
|
||||
MatchedPlan,
|
||||
PlanBase,
|
||||
PlanExtra,
|
||||
PlanFeature,
|
||||
PlanWarning,
|
||||
} from "./types";
|
||||
|
||||
type CustomBasePrice = NonNullable<CustomizePlanV1["price"]>;
|
||||
|
||||
const STRIPE_TO_AUTUMN_INTERVAL: Record<string, BillingInterval> = {
|
||||
week: BillingInterval.Week,
|
||||
month: BillingInterval.Month,
|
||||
year: BillingInterval.Year,
|
||||
};
|
||||
|
||||
const stripeItemToBasePrice = ({
|
||||
item,
|
||||
}: {
|
||||
item: StripeItemSnapshot;
|
||||
}): CustomBasePrice | null => {
|
||||
if (item.unit_amount === null) return null;
|
||||
if (!item.recurring_interval) return null;
|
||||
const interval = STRIPE_TO_AUTUMN_INTERVAL[item.recurring_interval];
|
||||
if (!interval) return null;
|
||||
return {
|
||||
amount: stripeToAtmnAmount({
|
||||
amount: item.unit_amount,
|
||||
currency: item.currency ?? "usd",
|
||||
}),
|
||||
interval,
|
||||
stripe_price_id: item.stripe_price_id,
|
||||
};
|
||||
};
|
||||
|
||||
const productFromDiff = ({ diff }: { diff: ItemDiff }): FullProduct | null => {
|
||||
if (diff.match.kind === "none") return null;
|
||||
return diff.match.product;
|
||||
};
|
||||
|
||||
const groupByProduct = ({
|
||||
itemDiffs,
|
||||
}: {
|
||||
itemDiffs: ItemDiff[];
|
||||
}): Map<string, { product: FullProduct; diffs: ItemDiff[] }> => {
|
||||
const byInternalId = new Map<
|
||||
string,
|
||||
{ product: FullProduct; diffs: ItemDiff[] }
|
||||
>();
|
||||
for (const diff of itemDiffs) {
|
||||
const product = productFromDiff({ diff });
|
||||
if (!product) continue;
|
||||
const existing = byInternalId.get(product.internal_id) ?? {
|
||||
product,
|
||||
diffs: [],
|
||||
};
|
||||
existing.diffs.push(diff);
|
||||
byInternalId.set(product.internal_id, existing);
|
||||
}
|
||||
return byInternalId;
|
||||
};
|
||||
|
||||
const partitionDiffs = ({
|
||||
diffs,
|
||||
autumnBasePriceId,
|
||||
}: {
|
||||
diffs: ItemDiff[];
|
||||
autumnBasePriceId: string | null;
|
||||
}) => {
|
||||
const matchedBase: ItemDiff[] = [];
|
||||
const customBase: ItemDiff[] = [];
|
||||
const features: ItemDiff[] = [];
|
||||
for (const diff of diffs) {
|
||||
const m = diff.match;
|
||||
if (m.kind === "autumn_price") {
|
||||
if (autumnBasePriceId && m.price.id === autumnBasePriceId) {
|
||||
matchedBase.push(diff);
|
||||
} else {
|
||||
features.push(diff);
|
||||
}
|
||||
} else if (m.kind === "autumn_product") {
|
||||
customBase.push(diff);
|
||||
}
|
||||
}
|
||||
return { matchedBase, customBase, features };
|
||||
};
|
||||
|
||||
type BaseDecision = {
|
||||
base: PlanBase;
|
||||
customize?: CustomizePlanV1;
|
||||
consumedCustomBase: ItemDiff | null;
|
||||
warnings: PlanWarning[];
|
||||
};
|
||||
|
||||
const decideBase = ({
|
||||
matchedBase,
|
||||
customBase,
|
||||
autumnBasePrice,
|
||||
}: {
|
||||
matchedBase: ItemDiff[];
|
||||
customBase: ItemDiff[];
|
||||
autumnBasePrice: { id: string } | null;
|
||||
}): BaseDecision => {
|
||||
const warnings: PlanWarning[] = [];
|
||||
|
||||
if (matchedBase.length > 0) {
|
||||
const [chosen] = matchedBase;
|
||||
if (chosen.match.kind !== "autumn_price") {
|
||||
throw new Error("matchedBase diff lost its autumn_price match");
|
||||
}
|
||||
return {
|
||||
base: {
|
||||
kind: "matched",
|
||||
stripe_item_id: chosen.stripe.id,
|
||||
autumn_price_id: chosen.match.price.id,
|
||||
},
|
||||
consumedCustomBase: null,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
if (customBase.length > 0) {
|
||||
const [chosen] = customBase;
|
||||
const basePrice = stripeItemToBasePrice({ item: chosen.stripe });
|
||||
if (!basePrice) {
|
||||
return {
|
||||
base: autumnBasePrice ? { kind: "dropped" } : { kind: "absent" },
|
||||
customize: autumnBasePrice ? { price: null } : undefined,
|
||||
consumedCustomBase: null,
|
||||
warnings: autumnBasePrice ? [{ type: "base_price_dropped" }] : [],
|
||||
};
|
||||
}
|
||||
return {
|
||||
base: { kind: "custom", stripe_item_id: chosen.stripe.id },
|
||||
customize: { price: basePrice },
|
||||
consumedCustomBase: chosen,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
if (autumnBasePrice) {
|
||||
return {
|
||||
base: { kind: "dropped" },
|
||||
customize: { price: null },
|
||||
consumedCustomBase: null,
|
||||
warnings: [{ type: "base_price_dropped" }],
|
||||
};
|
||||
}
|
||||
|
||||
return { base: { kind: "absent" }, consumedCustomBase: null, warnings };
|
||||
};
|
||||
|
||||
const lookupBaseStripeItem = ({
|
||||
base,
|
||||
diffs,
|
||||
}: {
|
||||
base: PlanBase;
|
||||
diffs: ItemDiff[];
|
||||
}): StripeItemSnapshot | null => {
|
||||
if (base.kind === "dropped" || base.kind === "absent") return null;
|
||||
const found = diffs.find((d) => d.stripe.id === base.stripe_item_id);
|
||||
return found?.stripe ?? null;
|
||||
};
|
||||
|
||||
const isAddOn = ({ product }: { product: FullProduct }): boolean =>
|
||||
product.is_add_on === true;
|
||||
|
||||
const rollupOnePlan = ({
|
||||
product,
|
||||
diffs,
|
||||
}: {
|
||||
product: FullProduct;
|
||||
diffs: ItemDiff[];
|
||||
}): MatchedPlan => {
|
||||
const autumnBasePrice = product.prices.find(isFixedPrice) ?? null;
|
||||
|
||||
const { matchedBase, customBase, features } = partitionDiffs({
|
||||
diffs,
|
||||
autumnBasePriceId: autumnBasePrice?.id ?? null,
|
||||
});
|
||||
|
||||
const decision = decideBase({
|
||||
matchedBase,
|
||||
customBase,
|
||||
autumnBasePrice,
|
||||
});
|
||||
|
||||
const extraDiffs = customBase.filter(
|
||||
(d) => d !== decision.consumedCustomBase,
|
||||
);
|
||||
|
||||
const planFeatures: PlanFeature[] = features.map((d) => {
|
||||
if (d.match.kind !== "autumn_price") {
|
||||
throw new Error("feature diff lost its autumn_price match");
|
||||
}
|
||||
return {
|
||||
stripe_item_id: d.stripe.id,
|
||||
autumn_price_id: d.match.price.id,
|
||||
};
|
||||
});
|
||||
|
||||
const planExtras: PlanExtra[] = extraDiffs.map((d) => ({
|
||||
stripe_item_id: d.stripe.id,
|
||||
}));
|
||||
|
||||
const warnings = [...decision.warnings];
|
||||
if (planExtras.length > 0) {
|
||||
warnings.push({
|
||||
type: "extra_items_under_plan",
|
||||
stripe_item_ids: planExtras.map((e) => e.stripe_item_id),
|
||||
});
|
||||
}
|
||||
|
||||
const baseStripeItem = lookupBaseStripeItem({ base: decision.base, diffs });
|
||||
const quantity =
|
||||
baseStripeItem?.quantity ?? features[0]?.stripe.quantity ?? 1;
|
||||
|
||||
if (baseStripeItem && quantity > 1 && !isAddOn({ product })) {
|
||||
warnings.push({ type: "base_plan_quantity_gt_one", quantity });
|
||||
}
|
||||
|
||||
return {
|
||||
product,
|
||||
quantity,
|
||||
base: decision.base,
|
||||
features: planFeatures,
|
||||
extras: planExtras,
|
||||
customize: decision.customize,
|
||||
warnings,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Roll up per-item diffs into per-plan matches. Reads each diff's embedded
|
||||
* FullProduct directly — no separate product map required.
|
||||
*/
|
||||
export const rollupMatchedPlans = ({
|
||||
itemDiffs,
|
||||
}: {
|
||||
itemDiffs: ItemDiff[];
|
||||
}): MatchedPlan[] => {
|
||||
const byProduct = groupByProduct({ itemDiffs });
|
||||
const plans: MatchedPlan[] = [];
|
||||
for (const { product, diffs } of byProduct.values()) {
|
||||
plans.push(rollupOnePlan({ product, diffs }));
|
||||
}
|
||||
return plans;
|
||||
};
|
||||
88
server/src/internal/billing/v2/actions/sync/detect/types.ts
Normal file
88
server/src/internal/billing/v2/actions/sync/detect/types.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import type { CustomizePlanV1, FullProduct, Price } from "@autumn/shared";
|
||||
import type {
|
||||
PriceMatchCondition,
|
||||
ProductMatchCondition,
|
||||
} from "@/internal/billing/v2/providers/stripe/utils/sync/matchUtils/matchConditions";
|
||||
import type { StripeItemSnapshot } from "@/internal/billing/v2/providers/stripe/utils/sync/stripeItemSnapshot/types";
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Item-level: per Stripe item, what did it match against in Autumn?
|
||||
* Pure stripe-side fact. No sibling-aware decisions.
|
||||
*
|
||||
* Each variant mirrors which matchUtils finder produced the match, with
|
||||
* the matched Autumn resource(s) embedded directly so consumers never need
|
||||
* a side lookup map.
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
export type ItemMatch =
|
||||
| {
|
||||
kind: "autumn_price";
|
||||
matched_on: PriceMatchCondition;
|
||||
price: Price;
|
||||
product: FullProduct;
|
||||
}
|
||||
| {
|
||||
kind: "autumn_product";
|
||||
matched_on: ProductMatchCondition;
|
||||
product: FullProduct;
|
||||
}
|
||||
| { kind: "none" };
|
||||
|
||||
export type ItemDiff = {
|
||||
stripe: StripeItemSnapshot;
|
||||
match: ItemMatch;
|
||||
};
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Plan-level: per Autumn product, the rolled-up structural verdict
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
export type PlanBase =
|
||||
| { kind: "matched"; stripe_item_id: string; autumn_price_id: string }
|
||||
| { kind: "custom"; stripe_item_id: string }
|
||||
| { kind: "adopted"; stripe_item_id: string }
|
||||
| { kind: "dropped" }
|
||||
| { kind: "absent" };
|
||||
|
||||
export type PlanFeature = {
|
||||
stripe_item_id: string;
|
||||
autumn_price_id: string;
|
||||
};
|
||||
|
||||
export type PlanExtra = {
|
||||
stripe_item_id: string;
|
||||
};
|
||||
|
||||
export type PlanWarning =
|
||||
| { type: "base_price_dropped" }
|
||||
| { type: "base_price_adopted"; stripe_item_id: string }
|
||||
| { type: "extra_items_under_plan"; stripe_item_ids: string[] }
|
||||
| { type: "base_plan_quantity_gt_one"; quantity: number };
|
||||
|
||||
export type MatchedPlan = {
|
||||
product: FullProduct;
|
||||
quantity: number;
|
||||
base: PlanBase;
|
||||
features: PlanFeature[];
|
||||
extras: PlanExtra[];
|
||||
customize?: CustomizePlanV1;
|
||||
warnings: PlanWarning[];
|
||||
};
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Phase / subscription level
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
export type PhaseMatch = {
|
||||
start_date: number;
|
||||
end_date: number | null;
|
||||
is_current: boolean;
|
||||
item_diffs: ItemDiff[];
|
||||
plans: MatchedPlan[];
|
||||
};
|
||||
|
||||
export type SubscriptionMatch = {
|
||||
stripe_subscription_id: string | null;
|
||||
stripe_schedule_id: string | null;
|
||||
phaseMatches: PhaseMatch[];
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { SyncBillingContext } from "@autumn/shared";
|
||||
|
||||
/**
|
||||
* Validate sync inputs against the detection result. Throws RecaseError on
|
||||
* any unrecoverable problem; otherwise no-op.
|
||||
*
|
||||
* STUB — checks to add later:
|
||||
* - Mapping plan_ids exist in the catalog
|
||||
* - Each mapping points at a stripe sub/schedule we actually fetched
|
||||
* - Detection PlanWarnings are all in `acknowledgedWarnings`
|
||||
* - Customer has a Stripe id
|
||||
*/
|
||||
export const handleSyncErrors = (_args: {
|
||||
syncContext: SyncBillingContext;
|
||||
}): void => {
|
||||
// no-op
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
import { formatMs, type SyncBillingContext } from "@autumn/shared";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import { addToExtraLogs } from "@/utils/logging/addToExtraLogs";
|
||||
|
||||
const formatPhase = ({
|
||||
startsAt,
|
||||
productCount,
|
||||
}: {
|
||||
startsAt: number;
|
||||
productCount: number;
|
||||
}) => `${formatMs(startsAt)} (${productCount} plan${productCount === 1 ? "" : "s"})`;
|
||||
|
||||
export const logSyncContext = ({
|
||||
ctx,
|
||||
syncContext,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
syncContext: SyncBillingContext;
|
||||
}) => {
|
||||
const {
|
||||
customer_id,
|
||||
stripeSubscription,
|
||||
stripeSchedule,
|
||||
immediatePhase,
|
||||
futurePhases,
|
||||
currentEpochMs,
|
||||
acknowledgedWarnings,
|
||||
} = syncContext;
|
||||
|
||||
addToExtraLogs({
|
||||
ctx,
|
||||
extras: {
|
||||
syncContext: {
|
||||
customer: customer_id,
|
||||
stripe: `${stripeSubscription?.id ?? "no sub"} | ${stripeSchedule?.id ?? "no schedule"}`,
|
||||
currentEpochMs: formatMs(currentEpochMs),
|
||||
immediatePhase: immediatePhase
|
||||
? formatPhase({
|
||||
startsAt: immediatePhase.startsAt,
|
||||
productCount: immediatePhase.productContexts.length,
|
||||
})
|
||||
: "none",
|
||||
futurePhases:
|
||||
futurePhases.length > 0
|
||||
? futurePhases
|
||||
.map((p) =>
|
||||
formatPhase({
|
||||
startsAt: p.startsAt,
|
||||
productCount: p.productContexts.length,
|
||||
}),
|
||||
)
|
||||
.join(" -> ")
|
||||
: "none",
|
||||
acknowledgedWarnings:
|
||||
acknowledgedWarnings.length > 0
|
||||
? acknowledgedWarnings.join(", ")
|
||||
: "none",
|
||||
productContexts: [
|
||||
...(immediatePhase ? immediatePhase.productContexts : []),
|
||||
...futurePhases.flatMap((p) => p.productContexts),
|
||||
]
|
||||
.map((pc) => {
|
||||
const customizeFlags = [
|
||||
pc.plan.customize?.price !== undefined && "price",
|
||||
pc.plan.customize?.items && "items",
|
||||
pc.plan.customize?.free_trial !== undefined && "trial",
|
||||
].filter(Boolean);
|
||||
const customize =
|
||||
customizeFlags.length > 0
|
||||
? ` customize=[${customizeFlags.join(",")}]`
|
||||
: "";
|
||||
const expire = pc.currentCustomerProduct
|
||||
? ` expire=${pc.currentCustomerProduct.id}`
|
||||
: "";
|
||||
const entity = pc.plan.internal_entity_id
|
||||
? ` entity=${pc.plan.internal_entity_id}`
|
||||
: "";
|
||||
return `${pc.fullProduct.id}${customize}${expire}${entity}`;
|
||||
})
|
||||
.join(" | "),
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
import { type AutumnBillingPlan, formatMs } from "@autumn/shared";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import { addToExtraLogs } from "@/utils/logging/addToExtraLogs";
|
||||
import type { ComputedSchedulePhase } from "../compute/computeSyncFuturePhases";
|
||||
|
||||
const formatCustomerProduct = (cp: {
|
||||
product_id: string;
|
||||
product: { name: string };
|
||||
}) => `${cp.product.name} (${cp.product_id})`;
|
||||
|
||||
export const logSyncPlan = ({
|
||||
ctx,
|
||||
autumnBillingPlan,
|
||||
phases,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
autumnBillingPlan: AutumnBillingPlan;
|
||||
phases: ComputedSchedulePhase[];
|
||||
}) => {
|
||||
addToExtraLogs({
|
||||
ctx,
|
||||
extras: {
|
||||
syncPlan: {
|
||||
insertCustomerProducts:
|
||||
autumnBillingPlan.insertCustomerProducts
|
||||
.map(formatCustomerProduct)
|
||||
.join(", ") || "none",
|
||||
|
||||
updateCustomerProducts:
|
||||
(autumnBillingPlan.updateCustomerProducts ?? [])
|
||||
.map(
|
||||
(u) =>
|
||||
`${formatCustomerProduct(u.customerProduct)} -> ${u.updates.status ?? "n/a"}`,
|
||||
)
|
||||
.join(", ") || "none",
|
||||
|
||||
customPrices:
|
||||
(autumnBillingPlan.customPrices ?? []).length > 0
|
||||
? `${(autumnBillingPlan.customPrices ?? []).length} custom price(s)`
|
||||
: "none",
|
||||
|
||||
customEntitlements:
|
||||
(autumnBillingPlan.customEntitlements ?? []).length > 0
|
||||
? `${(autumnBillingPlan.customEntitlements ?? []).length} custom ent(s)`
|
||||
: "none",
|
||||
|
||||
upsertSubscription:
|
||||
autumnBillingPlan.upsertSubscription?.stripe_id ?? "none",
|
||||
|
||||
schedulePhases:
|
||||
phases.length > 0
|
||||
? phases
|
||||
.map(
|
||||
(p) =>
|
||||
`${formatMs(p.startsAt)} (${p.customerProductIds.length} cusProduct${p.customerProductIds.length === 1 ? "" : "s"})`,
|
||||
)
|
||||
.join(" -> ")
|
||||
: "none",
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,199 @@
|
||||
import {
|
||||
ErrCode,
|
||||
type FullCusProduct,
|
||||
type FullCustomer,
|
||||
RecaseError,
|
||||
type SyncBillingContext,
|
||||
type SyncParamsV1,
|
||||
type SyncPhaseContext,
|
||||
type SyncPlanInstance,
|
||||
type SyncProductContext,
|
||||
} from "@autumn/shared";
|
||||
import type Stripe from "stripe";
|
||||
import { createStripeCli } from "@/external/connect/createStripeCli";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import { setupAttachProductContext } from "@/internal/billing/v2/actions/attach/setup/setupAttachProductContext";
|
||||
import { setupAttachTransitionContext } from "@/internal/billing/v2/actions/attach/setup/setupAttachTransitionContext";
|
||||
import { setupFeatureQuantitiesContext } from "@/internal/billing/v2/setup/setupFeatureQuantitiesContext";
|
||||
import { setupFullCustomerContext } from "@/internal/billing/v2/setup/setupFullCustomerContext";
|
||||
|
||||
const fetchStripeSubscription = async ({
|
||||
ctx,
|
||||
stripeSubscriptionId,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
stripeSubscriptionId?: string;
|
||||
}): Promise<Stripe.Subscription | null> => {
|
||||
if (!stripeSubscriptionId) return null;
|
||||
const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env });
|
||||
return stripeCli.subscriptions.retrieve(stripeSubscriptionId);
|
||||
};
|
||||
|
||||
const fetchStripeSchedule = async ({
|
||||
ctx,
|
||||
stripeScheduleId,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
stripeScheduleId?: string;
|
||||
}): Promise<Stripe.SubscriptionSchedule | null> => {
|
||||
if (!stripeScheduleId) return null;
|
||||
const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env });
|
||||
return stripeCli.subscriptionSchedules.retrieve(stripeScheduleId);
|
||||
};
|
||||
|
||||
const buildProductContext = async ({
|
||||
ctx,
|
||||
fullCustomer,
|
||||
plan,
|
||||
isImmediate,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
fullCustomer: FullCustomer;
|
||||
plan: SyncPlanInstance;
|
||||
isImmediate: boolean;
|
||||
}): Promise<SyncProductContext> => {
|
||||
const {
|
||||
fullProduct,
|
||||
customPrices = [],
|
||||
customEnts: customEntitlements = [],
|
||||
} = await setupAttachProductContext({ ctx, params: plan });
|
||||
|
||||
const featureQuantities = setupFeatureQuantitiesContext({
|
||||
ctx,
|
||||
featureQuantitiesParams: { feature_quantities: plan.feature_quantities },
|
||||
fullProduct,
|
||||
initializeUndefinedQuantities: true,
|
||||
});
|
||||
|
||||
let currentCustomerProduct: FullCusProduct | undefined;
|
||||
if (isImmediate && plan.expire_previous) {
|
||||
const transition = setupAttachTransitionContext({
|
||||
fullCustomer,
|
||||
attachProduct: fullProduct,
|
||||
});
|
||||
currentCustomerProduct = transition.currentCustomerProduct;
|
||||
}
|
||||
|
||||
return {
|
||||
plan,
|
||||
fullProduct,
|
||||
customPrices,
|
||||
customEntitlements,
|
||||
featureQuantities,
|
||||
currentCustomerProduct,
|
||||
};
|
||||
};
|
||||
|
||||
const resolvePhaseStart = ({
|
||||
startsAt,
|
||||
currentEpochMs,
|
||||
}: {
|
||||
startsAt: number | "now";
|
||||
currentEpochMs: number;
|
||||
}): number => (startsAt === "now" ? currentEpochMs : startsAt);
|
||||
|
||||
/**
|
||||
* Setup the sync billing context. Mirrors createSchedule's setup but with
|
||||
* Stripe subscription/schedule as the input rather than payment-method
|
||||
* driven flow.
|
||||
*/
|
||||
export const setupSyncContext = async ({
|
||||
ctx,
|
||||
params,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
params: SyncParamsV1;
|
||||
}): Promise<SyncBillingContext> => {
|
||||
if (!params.stripe_subscription_id && !params.stripe_schedule_id) {
|
||||
throw new RecaseError({
|
||||
message:
|
||||
"sync requires either stripe_subscription_id or stripe_schedule_id",
|
||||
code: ErrCode.InvalidRequest,
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const fullCustomer = await setupFullCustomerContext({
|
||||
ctx,
|
||||
params: { customer_id: params.customer_id },
|
||||
});
|
||||
|
||||
const [stripeSubscription, stripeSchedule] = await Promise.all([
|
||||
fetchStripeSubscription({
|
||||
ctx,
|
||||
stripeSubscriptionId: params.stripe_subscription_id,
|
||||
}),
|
||||
fetchStripeSchedule({
|
||||
ctx,
|
||||
stripeScheduleId: params.stripe_schedule_id,
|
||||
}),
|
||||
]);
|
||||
|
||||
const currentEpochMs = Date.now();
|
||||
const inputPhases = params.phases ?? [];
|
||||
|
||||
const phaseContexts: SyncPhaseContext[] = await Promise.all(
|
||||
inputPhases.map(async (phase, index) => {
|
||||
const startsAt = resolvePhaseStart({
|
||||
startsAt: phase.starts_at,
|
||||
currentEpochMs,
|
||||
});
|
||||
const nextPhase = inputPhases[index + 1];
|
||||
const endsAt = nextPhase
|
||||
? resolvePhaseStart({
|
||||
startsAt: nextPhase.starts_at,
|
||||
currentEpochMs,
|
||||
})
|
||||
: null;
|
||||
|
||||
const isImmediatePhase = phase.starts_at === "now";
|
||||
|
||||
const productContextsPerPlan = await Promise.all(
|
||||
phase.plans.map((plan) =>
|
||||
buildProductContext({
|
||||
ctx,
|
||||
fullCustomer,
|
||||
plan,
|
||||
isImmediate: isImmediatePhase,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
// Expand add-on plans with quantity > 1 into N independent
|
||||
// product contexts so the executor inserts one cusProduct per
|
||||
// add-on instance. Non-add-on plans always emit a single context
|
||||
// regardless of quantity.
|
||||
const productContexts = productContextsPerPlan.flatMap(
|
||||
(productContext) => {
|
||||
const requested = productContext.plan.quantity ?? 1;
|
||||
const shouldExpand =
|
||||
productContext.fullProduct.is_add_on === true && requested > 1;
|
||||
return shouldExpand
|
||||
? Array.from({ length: requested }, () => productContext)
|
||||
: [productContext];
|
||||
},
|
||||
);
|
||||
|
||||
return { startsAt, endsAt, productContexts };
|
||||
}),
|
||||
);
|
||||
|
||||
const firstPhaseIsImmediate = inputPhases[0]?.starts_at === "now";
|
||||
const immediatePhase = firstPhaseIsImmediate
|
||||
? (phaseContexts[0] ?? null)
|
||||
: null;
|
||||
const futurePhases = firstPhaseIsImmediate
|
||||
? phaseContexts.slice(1)
|
||||
: phaseContexts;
|
||||
|
||||
return {
|
||||
customer_id: params.customer_id,
|
||||
fullCustomer,
|
||||
stripeSubscription,
|
||||
stripeSchedule,
|
||||
immediatePhase,
|
||||
futurePhases,
|
||||
currentEpochMs,
|
||||
acknowledgedWarnings: params.acknowledge_warnings ?? [],
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,117 @@
|
||||
import {
|
||||
secondsToMs,
|
||||
type SyncParamsV1,
|
||||
type SyncPhase,
|
||||
type SyncPlanInstance,
|
||||
} from "@autumn/shared";
|
||||
import type Stripe from "stripe";
|
||||
import { createStripeCli } from "@/external/connect/createStripeCli";
|
||||
import { getStripeActiveSubscriptionSchedule } from "@/external/stripe/subscriptionSchedules";
|
||||
import { stripeSubscriptionToScheduleId } from "@/external/stripe/subscriptions/utils/convertStripeSubscription";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import { buildFeatureQuantities } from "./buildSyncParams/buildFeatureQuantities";
|
||||
import { detectSubscriptionMatch } from "./detect/detectSubscriptionMatch";
|
||||
import type {
|
||||
ItemDiff,
|
||||
MatchedPlan,
|
||||
PhaseMatch,
|
||||
SubscriptionMatch,
|
||||
} from "./detect/types";
|
||||
|
||||
const matchedPlanToSyncPlan = ({
|
||||
matchedPlan,
|
||||
itemDiffs,
|
||||
}: {
|
||||
matchedPlan: MatchedPlan;
|
||||
itemDiffs: ItemDiff[];
|
||||
}): SyncPlanInstance => {
|
||||
const featureQuantities = buildFeatureQuantities({ matchedPlan, itemDiffs });
|
||||
return {
|
||||
plan_id: matchedPlan.product.id,
|
||||
quantity: matchedPlan.quantity,
|
||||
customize: matchedPlan.customize,
|
||||
expire_previous: true,
|
||||
feature_quantities:
|
||||
featureQuantities.length > 0 ? featureQuantities : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const phaseMatchToSyncPhase = ({
|
||||
phaseMatch,
|
||||
}: {
|
||||
phaseMatch: PhaseMatch;
|
||||
}): SyncPhase => ({
|
||||
// PhaseMatch.start_date is Stripe-native (seconds). SyncPhase.starts_at
|
||||
// is ms epoch (or the "now" sentinel for the immediate phase).
|
||||
starts_at: phaseMatch.is_current
|
||||
? "now"
|
||||
: secondsToMs(phaseMatch.start_date),
|
||||
plans: phaseMatch.plans.map((matchedPlan) =>
|
||||
matchedPlanToSyncPlan({
|
||||
matchedPlan,
|
||||
itemDiffs: phaseMatch.item_diffs,
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
/**
|
||||
* Run subscription detection and shape the result into a `SyncParamsV1`
|
||||
* draft. Returns BOTH the raw `SubscriptionMatch` (for eligibility checks
|
||||
* and proposal display extras) and the derived params.
|
||||
*
|
||||
* Single canonical path used by:
|
||||
* - `syncProposalsV2` — surfaces `params` to the dashboard for editing
|
||||
* - `autoSyncFromSubscription` — feeds `params` into `syncV2` when
|
||||
* `canAutoSync` says yes.
|
||||
*/
|
||||
export const subscriptionToSyncParams = async ({
|
||||
ctx,
|
||||
customerId,
|
||||
subscription,
|
||||
schedule,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
customerId: string;
|
||||
subscription?: Stripe.Subscription;
|
||||
/** Optional pre-fetched schedule. When omitted and `subscription` references
|
||||
* a schedule, it's fetched (with `phases.items.price` expanded — required
|
||||
* by `normalizePhaseItem` to resolve `stripe_product_id`). */
|
||||
schedule?: Stripe.SubscriptionSchedule;
|
||||
}): Promise<{
|
||||
match: SubscriptionMatch;
|
||||
params: SyncParamsV1;
|
||||
schedule: Stripe.SubscriptionSchedule | null;
|
||||
}> => {
|
||||
let resolvedSchedule = schedule;
|
||||
if (!resolvedSchedule) {
|
||||
const scheduleId = stripeSubscriptionToScheduleId({
|
||||
stripeSubscription: subscription,
|
||||
});
|
||||
if (scheduleId) {
|
||||
resolvedSchedule = await getStripeActiveSubscriptionSchedule({
|
||||
stripeClient: createStripeCli({ org: ctx.org, env: ctx.env }),
|
||||
subscriptionScheduleId: scheduleId,
|
||||
expand: ["phases.items.price"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const match = await detectSubscriptionMatch({
|
||||
ctx,
|
||||
subscription,
|
||||
schedule: resolvedSchedule,
|
||||
});
|
||||
|
||||
const phases: SyncPhase[] = match.phaseMatches
|
||||
.filter((phase) => phase.plans.length > 0)
|
||||
.map((phaseMatch) => phaseMatchToSyncPhase({ phaseMatch }));
|
||||
|
||||
const params: SyncParamsV1 = {
|
||||
customer_id: customerId,
|
||||
stripe_subscription_id: subscription?.id,
|
||||
stripe_schedule_id: match.stripe_schedule_id ?? undefined,
|
||||
phases,
|
||||
};
|
||||
|
||||
return { match, params, schedule: resolvedSchedule ?? null };
|
||||
};
|
||||
@@ -1,188 +0,0 @@
|
||||
# Stripe Sync Matching Draft
|
||||
|
||||
## Status
|
||||
The broader `customer.subscription.created` auto-sync feature is paused. This draft preserves the current research so we can resume later, while the immediate focus shifts to consolidating Stripe -> Autumn matching utilities.
|
||||
|
||||
## Current Direction
|
||||
We should build the new canonical utilities first and defer migration of existing callers until a later, safer phase. The first implementation pass should therefore be additive only:
|
||||
- create the new utility home under [server/src/internal/billing/v2/providers/stripe/utils/sync](server/src/internal/billing/v2/providers/stripe/utils/sync)
|
||||
- define canonical matcher types, priority semantics, and normalized Stripe input shapes
|
||||
- add Stripe-keyed lookup builders that support the full identifier set
|
||||
- leave existing sync, invoice, checkout, and legacy helpers untouched for now
|
||||
|
||||
## Paused Auto-Sync Draft
|
||||
- Event in scope: `customer.subscription.created`
|
||||
- Intended skip guard: subscription metadata only, using an Autumn-owned marker such as `autumn_event`
|
||||
- Same-group behavior: if an external Stripe subscription matches a product in a group with an existing active Autumn product, expire the old product and insert the new one
|
||||
- Future implementation should follow the newer webhook handler pattern used by [server/src/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/handleStripeSubscriptionUpdated.ts](server/src/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/handleStripeSubscriptionUpdated.ts) instead of the current stub at [server/src/external/stripe/webhookHandlers/handleSubCreated.ts](server/src/external/stripe/webhookHandlers/handleSubCreated.ts)
|
||||
|
||||
## Why This Needs Cleanup First
|
||||
Stripe -> Autumn matching is currently split across sync actions, invoice-line persistence, legacy subscription helpers, checkout helpers, log helpers, and service lookups. The same Stripe identifiers are interpreted differently depending on the path:
|
||||
- `stripe_price_id`
|
||||
- `stripe_empty_price_id`
|
||||
- `stripe_prepaid_price_v2_id`
|
||||
- `stripe_product_id`
|
||||
- `product.processor.id`
|
||||
- metadata like `autumn_line_item_id` and `autumn_customer_price_id`
|
||||
|
||||
That makes the auto-sync feature too risky to build first.
|
||||
|
||||
## Main Matching Surfaces
|
||||
|
||||
### 1. Sync Proposal Matching
|
||||
- [server/src/internal/billing/v2/actions/sync/utils/matchSubscriptionItemToAutumn.ts](server/src/internal/billing/v2/actions/sync/utils/matchSubscriptionItemToAutumn.ts)
|
||||
- [server/src/internal/billing/v2/actions/sync/utils/matchStripeSubscriptionsToProducts.ts](server/src/internal/billing/v2/actions/sync/utils/matchStripeSubscriptionsToProducts.ts)
|
||||
- [server/src/internal/products/prices/PriceService.ts](server/src/internal/products/prices/PriceService.ts)
|
||||
- [server/src/internal/products/ProductService.ts](server/src/internal/products/ProductService.ts)
|
||||
|
||||
Current behavior:
|
||||
- sync proposals batch-load Autumn prices/products from Stripe ids
|
||||
- matching priority is effectively `stripe_price_id` -> price-level `stripe_product_id` -> product-level `processor.id`
|
||||
- the sync path currently depends on lookup maps more than canonical matching helpers
|
||||
|
||||
Current gap:
|
||||
- `PriceService.getByStripeId` and `PriceService.getByStripeIds` support `stripe_price_id` and `stripe_empty_price_id`, but not `stripe_prepaid_price_v2_id`
|
||||
|
||||
### 2. Invoice Line Matching
|
||||
- [shared/utils/billingUtils/invoicingUtils/lineItemUtils/billingLineItemMatchesStripeLineItem.ts](shared/utils/billingUtils/invoicingUtils/lineItemUtils/billingLineItemMatchesStripeLineItem.ts)
|
||||
- [shared/utils/billingUtils/invoicingUtils/lineItemUtils/filterBillingLineItemsByStripeLineItem.ts](shared/utils/billingUtils/invoicingUtils/lineItemUtils/filterBillingLineItemsByStripeLineItem.ts)
|
||||
- [shared/utils/billingUtils/invoicingUtils/lineItemUtils/findBillingLineItemByStripeLineItem.ts](shared/utils/billingUtils/invoicingUtils/lineItemUtils/findBillingLineItemByStripeLineItem.ts)
|
||||
- [server/src/internal/billing/v2/providers/stripe/utils/invoiceLines/convertToDbLineItem/stripeLineItemGroupToDbLineItems.ts](server/src/internal/billing/v2/providers/stripe/utils/invoiceLines/convertToDbLineItem/stripeLineItemGroupToDbLineItems.ts)
|
||||
- [server/src/internal/billing/v2/workflows/storeInvoiceLineItems/fetchSubscriptionItemsMetadata.ts](server/src/internal/billing/v2/workflows/storeInvoiceLineItems/fetchSubscriptionItemsMetadata.ts)
|
||||
|
||||
Current behavior:
|
||||
- invoice matching is metadata-first
|
||||
- then it matches by Stripe price id
|
||||
- then it matches by product processor id or `price.config.stripe_product_id`
|
||||
|
||||
Current gaps:
|
||||
- invoice matching supports `stripe_prepaid_price_v2_id`
|
||||
- invoice matching does not consistently include `stripe_empty_price_id`
|
||||
- `findBillingLineItemByStripeLineItem` does not accept subscription item metadata, while `filterBillingLineItemsByStripeLineItem` does
|
||||
|
||||
### 3. Legacy Subscription and Schedule Matching
|
||||
- [server/src/external/stripe/stripeSubUtils/stripeSubItemUtils.ts](server/src/external/stripe/stripeSubUtils/stripeSubItemUtils.ts)
|
||||
- [server/src/internal/customers/attach/mergeUtils/paramsToSubItems.ts](server/src/internal/customers/attach/mergeUtils/paramsToSubItems.ts)
|
||||
- [server/src/internal/customers/attach/mergeUtils/paramsToScheduleItems.ts](server/src/internal/customers/attach/mergeUtils/paramsToScheduleItems.ts)
|
||||
- [server/src/external/stripe/subscriptions/subscriptionItems/utils/findSubscriptionItemByAutumnPrice.ts](server/src/external/stripe/subscriptions/subscriptionItems/utils/findSubscriptionItemByAutumnPrice.ts)
|
||||
- [server/src/external/stripe/checkoutSessions/utils/findCheckoutLineItem.ts](server/src/external/stripe/checkoutSessions/utils/findCheckoutLineItem.ts)
|
||||
|
||||
Current behavior:
|
||||
- this area contains several older or narrower matchers
|
||||
- some include `stripe_empty_price_id`
|
||||
- some include `stripe_prepaid_price_v2_id`
|
||||
- some only compare `stripe_price_id`
|
||||
- some are marked deprecated but are still used
|
||||
|
||||
### 4. Logging and Lookup Helpers
|
||||
- [server/src/internal/billing/v2/utils/billingContextPriceLookup.ts](server/src/internal/billing/v2/utils/billingContextPriceLookup.ts)
|
||||
- [server/src/external/stripe/subscriptionSchedules/utils/logStripeSchedulePhaseUtils.ts](server/src/external/stripe/subscriptionSchedules/utils/logStripeSchedulePhaseUtils.ts)
|
||||
|
||||
Current behavior:
|
||||
- these helpers are lightweight and mostly in-memory
|
||||
- they encode still more matching logic for log formatting and phase debugging
|
||||
|
||||
## Duplications and Inconsistencies
|
||||
- Sync proposal matching and invoice matching use different priority ladders.
|
||||
- Runtime matchers explicitly mention `stripe_prepaid_price_v2_id`, but service-backed sync lookup does not.
|
||||
- Checkout and subscription-item helpers include `stripe_empty_price_id` more consistently than invoice helpers.
|
||||
- Product-level fallback sometimes means `product.processor.id`, sometimes `price.config.stripe_product_id`, and sometimes either.
|
||||
- Metadata-first matching is available for invoice lines and checkout lines, but not in the sync matcher.
|
||||
- Deprecated legacy helpers in [server/src/external/stripe/stripeSubUtils/stripeSubItemUtils.ts](server/src/external/stripe/stripeSubUtils/stripeSubItemUtils.ts) still power live flows.
|
||||
|
||||
## Proposed Destination
|
||||
Move Stripe -> Autumn sync and matching utilities into:
|
||||
|
||||
- [server/src/internal/billing/v2/providers/stripe/utils/sync](server/src/internal/billing/v2/providers/stripe/utils/sync)
|
||||
|
||||
This should become the canonical home for inbound Stripe correlation logic.
|
||||
|
||||
## Proposed Structure
|
||||
|
||||
### `identity/`
|
||||
Pure matching predicates with no DB calls.
|
||||
|
||||
Suggested responsibilities:
|
||||
- compare a Stripe price id against an Autumn price
|
||||
- compare a Stripe product id against an Autumn price/product
|
||||
- compare a Stripe subscription item against an Autumn price/product candidate
|
||||
- compare a Stripe invoice line against an Autumn line item candidate
|
||||
- expose one explicit match priority model instead of several incompatible enums
|
||||
|
||||
Likely future residents:
|
||||
- extracted logic from `matchSubscriptionItemToAutumn`
|
||||
- extracted logic from `billingLineItemMatchesStripeLineItem`
|
||||
- extracted logic from `findSubscriptionItemByAutumnPrice`
|
||||
- extracted logic from `findCheckoutLineItemByAutumnPrice`
|
||||
- extracted logic from `autumnStripePricesMatch`
|
||||
|
||||
### `indexes/`
|
||||
Server-side loaders and Stripe-keyed indexes that prepare lookup maps.
|
||||
|
||||
Suggested responsibilities:
|
||||
- build `priceByStripePriceId`
|
||||
- build `priceByStripeProductId`
|
||||
- build `productByStripeProductId`
|
||||
- centralize support for `stripe_prepaid_price_v2_id`
|
||||
- keep org and env scoping explicit where required
|
||||
|
||||
Likely future residents:
|
||||
- sync-oriented indexing now split across `PriceService`, `ProductService`, and `matchStripeSubscriptionsToProducts`
|
||||
|
||||
### `adapters/`
|
||||
Convert Stripe objects into normalized sync inputs before matching.
|
||||
|
||||
Suggested responsibilities:
|
||||
- normalize Stripe subscription items
|
||||
- normalize Stripe invoice lines plus optional subscription item metadata
|
||||
- normalize checkout line items
|
||||
- normalize schedule phase items
|
||||
|
||||
This keeps matcher code independent from raw Stripe API shape differences.
|
||||
|
||||
### `orchestrators/`
|
||||
Higher-level workflows that use normalized inputs plus indexes.
|
||||
|
||||
Suggested responsibilities:
|
||||
- build sync proposals from Stripe subscriptions
|
||||
- match grouped invoice lines to Autumn billing line items
|
||||
- find Stripe line items or subscription items for a given Autumn object in write paths
|
||||
|
||||
Likely future residents:
|
||||
- the current proposal builder
|
||||
- invoice line conversion helpers
|
||||
- selected operational helpers now scattered under `external/stripe`
|
||||
|
||||
## Migration Principles
|
||||
- Keep `providers/stripe/utils/sync` as the source of truth for Stripe -> Autumn matching.
|
||||
- Let shared code own only the pieces that are truly server-agnostic and safe to reuse without DB access.
|
||||
- Do not move orchestration-heavy code into shared if it depends on Stripe SDK types plus server services.
|
||||
- Migrate callers gradually by first extracting pure matchers, then updating existing sites to delegate to them.
|
||||
- Remove or deprecate legacy helpers only after all live callers are off them.
|
||||
|
||||
## Recommended First Pass
|
||||
1. Define one canonical identifier vocabulary and one matching priority model.
|
||||
2. Build a new sync lookup layer that supports `stripe_prepaid_price_v2_id` in addition to the current ids.
|
||||
3. Extract pure predicates from the current sync matcher and invoice matcher into the new `identity` area.
|
||||
4. Add normalized adapters for subscription items, invoice lines, checkout line items, and schedule items.
|
||||
5. Document how current helpers will migrate later, but do not change live callers yet.
|
||||
6. Revisit migration only after the new utility surface is stable and reviewed.
|
||||
|
||||
## Files To Revisit During Implementation
|
||||
- [server/src/internal/billing/v2/actions/sync/utils/matchSubscriptionItemToAutumn.ts](server/src/internal/billing/v2/actions/sync/utils/matchSubscriptionItemToAutumn.ts)
|
||||
- [server/src/internal/billing/v2/actions/sync/utils/matchStripeSubscriptionsToProducts.ts](server/src/internal/billing/v2/actions/sync/utils/matchStripeSubscriptionsToProducts.ts)
|
||||
- [server/src/internal/products/prices/PriceService.ts](server/src/internal/products/prices/PriceService.ts)
|
||||
- [shared/utils/billingUtils/invoicingUtils/lineItemUtils/billingLineItemMatchesStripeLineItem.ts](shared/utils/billingUtils/invoicingUtils/lineItemUtils/billingLineItemMatchesStripeLineItem.ts)
|
||||
- [server/src/internal/billing/v2/providers/stripe/utils/invoiceLines/convertToDbLineItem/stripeLineItemGroupToDbLineItems.ts](server/src/internal/billing/v2/providers/stripe/utils/invoiceLines/convertToDbLineItem/stripeLineItemGroupToDbLineItems.ts)
|
||||
- [server/src/external/stripe/subscriptions/subscriptionItems/utils/findSubscriptionItemByAutumnPrice.ts](server/src/external/stripe/subscriptions/subscriptionItems/utils/findSubscriptionItemByAutumnPrice.ts)
|
||||
- [server/src/external/stripe/checkoutSessions/utils/findCheckoutLineItem.ts](server/src/external/stripe/checkoutSessions/utils/findCheckoutLineItem.ts)
|
||||
- [server/src/external/stripe/stripeSubUtils/stripeSubItemUtils.ts](server/src/external/stripe/stripeSubUtils/stripeSubItemUtils.ts)
|
||||
- [server/src/internal/billing/v2/utils/billingContextPriceLookup.ts](server/src/internal/billing/v2/utils/billingContextPriceLookup.ts)
|
||||
|
||||
## Dedicated Utility Organization Plan
|
||||
The next implementation plan should focus only on:
|
||||
- creating the new `providers/stripe/utils/sync` structure
|
||||
- defining canonical matcher APIs
|
||||
- building additive utilities first without caller rewiring
|
||||
- deciding what stays in `shared` versus what lives server-side
|
||||
- documenting which legacy helpers become compatibility wrappers versus which are deleted later
|
||||
115
server/src/internal/billing/v2/actions/sync/syncProposalsV2.ts
Normal file
115
server/src/internal/billing/v2/actions/sync/syncProposalsV2.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import {
|
||||
ErrCode,
|
||||
type FullCusProduct,
|
||||
RecaseError,
|
||||
type SyncProposalsV2Params,
|
||||
type SyncProposalsV2Response,
|
||||
type SyncProposalV2,
|
||||
} from "@autumn/shared";
|
||||
import type Stripe from "stripe";
|
||||
import { createStripeCli } from "@/external/connect/createStripeCli";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import { CusService } from "@/internal/customers/CusService";
|
||||
import { subscriptionToSyncParams } from "./subscriptionToSyncParams";
|
||||
|
||||
const findAlreadyLinkedProductId = ({
|
||||
stripeSubscriptionId,
|
||||
customerProducts,
|
||||
}: {
|
||||
stripeSubscriptionId: string;
|
||||
customerProducts: FullCusProduct[];
|
||||
}): string | null => {
|
||||
const linked = customerProducts.find((cp) =>
|
||||
cp.subscription_ids?.includes(stripeSubscriptionId),
|
||||
);
|
||||
return linked?.product?.id ?? null;
|
||||
};
|
||||
|
||||
const buildProposal = async ({
|
||||
ctx,
|
||||
customerId,
|
||||
subscription,
|
||||
customerProducts,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
customerId: string;
|
||||
subscription: Stripe.Subscription;
|
||||
customerProducts: FullCusProduct[];
|
||||
}): Promise<SyncProposalV2> => {
|
||||
const { params, schedule } = await subscriptionToSyncParams({
|
||||
ctx,
|
||||
customerId,
|
||||
subscription,
|
||||
});
|
||||
|
||||
return {
|
||||
stripe_subscription_id: params.stripe_subscription_id,
|
||||
stripe_schedule_id: params.stripe_schedule_id,
|
||||
phases: params.phases ?? [],
|
||||
stripe_subscription: subscription,
|
||||
stripe_schedule: schedule,
|
||||
already_linked_product_id: findAlreadyLinkedProductId({
|
||||
stripeSubscriptionId: subscription.id,
|
||||
customerProducts,
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* V2 sync proposals — for each Stripe subscription, runs detection +
|
||||
* `subscriptionToSyncParams` to produce a draft `SyncParamsV1` and packages
|
||||
* it with display extras. Frontend can mutate `phases` and pass straight to
|
||||
* `/billing.sync`.
|
||||
*/
|
||||
export const syncProposalsV2 = async ({
|
||||
ctx,
|
||||
params,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
params: SyncProposalsV2Params;
|
||||
}): Promise<SyncProposalsV2Response> => {
|
||||
const { org, env } = ctx;
|
||||
|
||||
const fullCustomer = await CusService.getFull({
|
||||
ctx,
|
||||
idOrInternalId: params.customer_id,
|
||||
withSubs: true,
|
||||
});
|
||||
|
||||
const stripeCustomerId = fullCustomer.processor?.id;
|
||||
if (!stripeCustomerId) {
|
||||
throw new RecaseError({
|
||||
message: "Customer has no linked Stripe customer",
|
||||
code: ErrCode.InvalidRequest,
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const stripeCli = createStripeCli({ org, env });
|
||||
const subscriptionList = await stripeCli.subscriptions.list({
|
||||
customer: stripeCustomerId,
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
if (subscriptionList.data.length === 0) {
|
||||
return { customer_id: params.customer_id, proposals: [] };
|
||||
}
|
||||
|
||||
// Stripe caps `expand` at 4 levels, so retrieve each subscription with
|
||||
// `items.data.price.product` (4 levels) for the UI.
|
||||
const proposals = await Promise.all(
|
||||
subscriptionList.data.map(async ({ id }) => {
|
||||
const subscription = await stripeCli.subscriptions.retrieve(id, {
|
||||
expand: ["items.data.price.product"],
|
||||
});
|
||||
return buildProposal({
|
||||
ctx,
|
||||
customerId: params.customer_id,
|
||||
subscription,
|
||||
customerProducts: fullCustomer.customer_products,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
return { customer_id: params.customer_id, proposals };
|
||||
};
|
||||
92
server/src/internal/billing/v2/actions/sync/syncV2.ts
Normal file
92
server/src/internal/billing/v2/actions/sync/syncV2.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import type { SyncParamsV1 } from "@autumn/shared";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import { persistCreateSchedule } from "@/internal/billing/v2/actions/createSchedule/utils/persistCreateSchedule";
|
||||
import { executeAutumnBillingPlan } from "@/internal/billing/v2/execute/executeAutumnBillingPlan";
|
||||
import { computeSyncPlan } from "./compute/computeSyncPlan";
|
||||
import { handleSyncErrors } from "./errors/handleSyncErrors";
|
||||
import { logSyncContext } from "./logs/logSyncContext";
|
||||
import { logSyncPlan } from "./logs/logSyncPlan";
|
||||
import { setupSyncContext } from "./setup/setupSyncContext";
|
||||
|
||||
export type SyncV2PersistedPhase = {
|
||||
phase_id: string;
|
||||
starts_at: number;
|
||||
customer_product_ids: string[];
|
||||
};
|
||||
|
||||
export type SyncV2Result = {
|
||||
customer_id: string;
|
||||
stripe_subscription_id: string | null;
|
||||
stripe_schedule_id: string | null;
|
||||
inserted_cus_product_ids: string[];
|
||||
expired_cus_product_ids: string[];
|
||||
schedule_id: string | null;
|
||||
scheduled_phases: SyncV2PersistedPhase[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Sync a Stripe subscription/schedule into Autumn state.
|
||||
*
|
||||
* Mirrors the v2 action convention used by createSchedule.ts:
|
||||
* 1. setup — fetch sub, schedule, customer, products
|
||||
* 2. errors — validate inputs against detection result
|
||||
* 3. compute — apply caller overrides and produce an AutumnBillingPlan
|
||||
* 4. execute — run the billing plan (cusProduct inserts/updates)
|
||||
* 5. persist — write any scheduled phase rows (reuses createSchedule's
|
||||
* `persistCreateSchedule` so the schedule + schedule_phases
|
||||
* tables are written identically across actions)
|
||||
*/
|
||||
export const syncV2 = async ({
|
||||
ctx,
|
||||
params,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
params: SyncParamsV1;
|
||||
}): Promise<SyncV2Result> => {
|
||||
// 1. Setup
|
||||
const syncContext = await setupSyncContext({ ctx, params });
|
||||
logSyncContext({ ctx, syncContext });
|
||||
|
||||
// 2. Errors
|
||||
handleSyncErrors({ syncContext });
|
||||
|
||||
// 3. Compute
|
||||
const { autumnBillingPlan, phases } = computeSyncPlan({ ctx, syncContext });
|
||||
logSyncPlan({ ctx, autumnBillingPlan, phases });
|
||||
|
||||
// 4. Execute
|
||||
await executeAutumnBillingPlan({ ctx, autumnBillingPlan });
|
||||
|
||||
// 5. Persist scheduled phases (only when sync produced more than one phase)
|
||||
let scheduleId: string | null = null;
|
||||
let scheduledPhases: SyncV2PersistedPhase[] = [];
|
||||
if (phases.length > 0) {
|
||||
const persisted = await persistCreateSchedule({
|
||||
ctx,
|
||||
customerId: syncContext.customer_id,
|
||||
currentEpochMs: syncContext.currentEpochMs,
|
||||
fullCustomer: syncContext.fullCustomer,
|
||||
phases,
|
||||
});
|
||||
scheduleId = persisted.scheduleId;
|
||||
scheduledPhases = persisted.insertedPhases;
|
||||
}
|
||||
|
||||
return {
|
||||
customer_id: syncContext.customer_id,
|
||||
stripe_subscription_id: syncContext.stripeSubscription?.id ?? null,
|
||||
stripe_schedule_id: syncContext.stripeSchedule?.id ?? null,
|
||||
inserted_cus_product_ids: autumnBillingPlan.insertCustomerProducts.map(
|
||||
(cp) => cp.id,
|
||||
),
|
||||
expired_cus_product_ids: (autumnBillingPlan.updateCustomerProducts ?? [])
|
||||
.concat(
|
||||
autumnBillingPlan.updateCustomerProduct
|
||||
? [autumnBillingPlan.updateCustomerProduct]
|
||||
: [],
|
||||
)
|
||||
.map((u) => u.customerProduct.id),
|
||||
schedule_id: scheduleId,
|
||||
scheduled_phases: scheduledPhases,
|
||||
};
|
||||
};
|
||||
19
server/src/internal/billing/v2/handlers/handleRestore.ts
Normal file
19
server/src/internal/billing/v2/handlers/handleRestore.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { RestoreParamsV1Schema, Scopes } from "@autumn/shared";
|
||||
import { createRoute } from "@/honoMiddlewares/routeHandler";
|
||||
import { billingActions } from "@/internal/billing/v2/actions";
|
||||
|
||||
export const handleRestore = createRoute({
|
||||
scopes: [Scopes.Billing.Write],
|
||||
body: RestoreParamsV1Schema,
|
||||
handler: async (c) => {
|
||||
const ctx = c.get("ctx");
|
||||
const body = c.req.valid("json");
|
||||
|
||||
const result = await billingActions.restore({
|
||||
ctx,
|
||||
params: body,
|
||||
});
|
||||
|
||||
return c.json(result, 200);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Scopes, SyncProposalsV2ParamsSchema } from "@autumn/shared";
|
||||
import { createRoute } from "@/honoMiddlewares/routeHandler";
|
||||
import { billingActions } from "@/internal/billing/v2/actions";
|
||||
|
||||
export const handleSyncProposalsV2 = createRoute({
|
||||
scopes: [Scopes.Billing.Read],
|
||||
body: SyncProposalsV2ParamsSchema,
|
||||
handler: async (c) => {
|
||||
const ctx = c.get("ctx");
|
||||
const body = c.req.valid("json");
|
||||
|
||||
const result = await billingActions.syncProposalsV2({
|
||||
ctx,
|
||||
params: body,
|
||||
});
|
||||
|
||||
return c.json(result, 200);
|
||||
},
|
||||
});
|
||||
19
server/src/internal/billing/v2/handlers/handleSyncV2.ts
Normal file
19
server/src/internal/billing/v2/handlers/handleSyncV2.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Scopes, SyncParamsV1Schema } from "@autumn/shared";
|
||||
import { createRoute } from "@/honoMiddlewares/routeHandler";
|
||||
import { billingActions } from "@/internal/billing/v2/actions";
|
||||
|
||||
export const handleSyncV2 = createRoute({
|
||||
scopes: [Scopes.Billing.Write],
|
||||
body: SyncParamsV1Schema,
|
||||
handler: async (c) => {
|
||||
const ctx = c.get("ctx");
|
||||
const body = c.req.valid("json");
|
||||
|
||||
const result = await billingActions.syncV2({
|
||||
ctx,
|
||||
params: body,
|
||||
});
|
||||
|
||||
return c.json(result, 200);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
type FixedPriceConfig,
|
||||
isFixedPrice,
|
||||
type Price,
|
||||
type Product,
|
||||
type UsagePriceConfig,
|
||||
} from "@autumn/shared";
|
||||
import { getStripePriceIdsForAutumnPrice } from "./getStripePriceIdsForAutumnPrice";
|
||||
import type { PriceMatchCondition } from "./matchConditions";
|
||||
|
||||
/**
|
||||
* Returns the Stripe product id that this Autumn price is *natively* keyed
|
||||
* to (for prepaid/usage prices that store `stripe_product_id` on their
|
||||
* config). Fixed prices intentionally return null — they are matched
|
||||
* exclusively via `stripe_price_id` at priority 1; any other Stripe price
|
||||
* under the same Stripe product should fall through to the priority-3
|
||||
* product-level match so the rollup can flag it as a custom base.
|
||||
*/
|
||||
const stripeProductIdForAutumnPrice = ({
|
||||
price,
|
||||
}: {
|
||||
price: Price;
|
||||
}): string | null => {
|
||||
if (isFixedPrice(price)) return null;
|
||||
const config = price.config as FixedPriceConfig | UsagePriceConfig;
|
||||
return config.stripe_product_id ?? null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Find the highest-priority match condition between an Autumn price and a
|
||||
* pool of Stripe ids (typically collected from a subscription's items).
|
||||
*
|
||||
* Priority:
|
||||
* 1. stripe_price_id (exact price match)
|
||||
* 2. stripe_product_id (prepaid/usage price explicitly keyed by product)
|
||||
*
|
||||
* Returns null when neither id type intersects.
|
||||
*/
|
||||
export const findStripeMatchForAutumnPrice = ({
|
||||
price,
|
||||
stripePriceIds,
|
||||
stripeProductIds,
|
||||
}: {
|
||||
price: Price;
|
||||
product: Product;
|
||||
stripePriceIds: Set<string>;
|
||||
stripeProductIds: Set<string>;
|
||||
}): PriceMatchCondition | null => {
|
||||
const matchedPriceId = getStripePriceIdsForAutumnPrice({ price }).find((id) =>
|
||||
stripePriceIds.has(id),
|
||||
);
|
||||
if (matchedPriceId) {
|
||||
return { type: "stripe_price_id", stripe_price_id: matchedPriceId };
|
||||
}
|
||||
|
||||
const stripeProductId = stripeProductIdForAutumnPrice({ price });
|
||||
if (stripeProductId && stripeProductIds.has(stripeProductId)) {
|
||||
return {
|
||||
type: "stripe_product_id",
|
||||
stripe_product_id: stripeProductId,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import { type Product, productToStripeId } from "@autumn/shared";
|
||||
import type { ProductMatchCondition } from "./matchConditions";
|
||||
|
||||
/**
|
||||
* Find the match condition (if any) between an Autumn product and a pool
|
||||
* of Stripe product ids. Returns null when the product's processor id is
|
||||
* absent or not in the pool.
|
||||
*/
|
||||
export const findStripeMatchForAutumnProduct = ({
|
||||
product,
|
||||
stripeProductIds,
|
||||
}: {
|
||||
product: Product;
|
||||
stripeProductIds: Set<string>;
|
||||
}): ProductMatchCondition | null => {
|
||||
const stripeProductId = productToStripeId({ product });
|
||||
if (!stripeProductId) return null;
|
||||
if (!stripeProductIds.has(stripeProductId)) return null;
|
||||
return { type: "stripe_product_id", stripe_product_id: stripeProductId };
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Describes how an Autumn price/product was matched against a Stripe id.
|
||||
* Returned by the find* helpers in this folder so callers know which
|
||||
* Stripe id (and which kind) put a given Autumn resource on the candidate
|
||||
* list.
|
||||
*/
|
||||
|
||||
export type PriceMatchCondition =
|
||||
| { type: "stripe_price_id"; stripe_price_id: string }
|
||||
| { type: "stripe_product_id"; stripe_product_id: string };
|
||||
|
||||
export type ProductMatchCondition = {
|
||||
type: "stripe_product_id";
|
||||
stripe_product_id: string;
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
import type Stripe from "stripe";
|
||||
import type { StripeItemSnapshot, StripeItemTier } from "./types";
|
||||
|
||||
const resolveStripeProductId = ({
|
||||
product,
|
||||
}: {
|
||||
product: string | Stripe.Product | Stripe.DeletedProduct | null | undefined;
|
||||
}): string | null => {
|
||||
if (!product) return null;
|
||||
return typeof product === "string" ? product : (product.id ?? null);
|
||||
};
|
||||
|
||||
const extractTiers = ({
|
||||
price,
|
||||
}: {
|
||||
price: Stripe.Price;
|
||||
}): StripeItemTier[] | null => {
|
||||
if (!price.tiers) return null;
|
||||
return price.tiers.map((tier) => ({
|
||||
up_to: tier.up_to,
|
||||
unit_amount: tier.unit_amount,
|
||||
flat_amount: tier.flat_amount,
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalize a Stripe.SubscriptionSchedule.Phase.Item to a StripeItemSnapshot.
|
||||
*
|
||||
* Phase items don't carry a stable Stripe ID, so the caller supplies a
|
||||
* synthetic id (typically `${phaseIndex}:${itemIndex}`).
|
||||
*
|
||||
* If the price was not expanded, only the price ID is available; pricing
|
||||
* fields (amount, currency, tiers) will be null and must be enriched later.
|
||||
*/
|
||||
export const normalizePhaseItem = ({
|
||||
phaseItem,
|
||||
syntheticId,
|
||||
}: {
|
||||
phaseItem: Stripe.SubscriptionSchedule.Phase.Item;
|
||||
syntheticId: string;
|
||||
}): StripeItemSnapshot | null => {
|
||||
const rawPrice = phaseItem.price as string | Stripe.Price | undefined;
|
||||
if (!rawPrice) return null;
|
||||
|
||||
const expandedPrice = typeof rawPrice === "object" ? rawPrice : null;
|
||||
const stripePriceId =
|
||||
typeof rawPrice === "string" ? rawPrice : (expandedPrice?.id ?? null);
|
||||
if (!stripePriceId) return null;
|
||||
|
||||
const stripeProductId = expandedPrice
|
||||
? resolveStripeProductId({ product: expandedPrice.product })
|
||||
: null;
|
||||
|
||||
// When unexpanded, we don't have product_id — the snapshot requires it.
|
||||
// Caller must expand `phases.items.price` (or enrich post-normalize)
|
||||
// before classification can succeed.
|
||||
if (!stripeProductId) return null;
|
||||
|
||||
return {
|
||||
id: syntheticId,
|
||||
stripe_price_id: stripePriceId,
|
||||
stripe_product_id: stripeProductId,
|
||||
unit_amount: expandedPrice?.unit_amount ?? null,
|
||||
currency: expandedPrice?.currency ?? null,
|
||||
quantity: phaseItem.quantity ?? 1,
|
||||
billing_scheme:
|
||||
(expandedPrice?.billing_scheme as "per_unit" | "tiered") ?? null,
|
||||
tiers_mode:
|
||||
(expandedPrice?.tiers_mode as "graduated" | "volume") ?? null,
|
||||
tiers: expandedPrice ? extractTiers({ price: expandedPrice }) : null,
|
||||
recurring_interval: expandedPrice?.recurring?.interval ?? null,
|
||||
recurring_usage_type:
|
||||
(expandedPrice?.recurring?.usage_type as "licensed" | "metered") ?? null,
|
||||
metadata: phaseItem.metadata ?? {},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import type Stripe from "stripe";
|
||||
import type { StripeItemSnapshot, StripeItemTier } from "./types";
|
||||
|
||||
const resolveStripeProductId = ({
|
||||
product,
|
||||
}: {
|
||||
product: string | Stripe.Product | Stripe.DeletedProduct | null | undefined;
|
||||
}): string | null => {
|
||||
if (!product) return null;
|
||||
return typeof product === "string" ? product : (product.id ?? null);
|
||||
};
|
||||
|
||||
const extractTiers = ({
|
||||
price,
|
||||
}: {
|
||||
price: Stripe.Price;
|
||||
}): StripeItemTier[] | null => {
|
||||
if (!price.tiers) return null;
|
||||
return price.tiers.map((tier) => ({
|
||||
up_to: tier.up_to,
|
||||
unit_amount: tier.unit_amount,
|
||||
flat_amount: tier.flat_amount,
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalize a Stripe.SubscriptionItem to the canonical StripeItemSnapshot
|
||||
* shape consumed by sync detection. Returns null for degenerate items
|
||||
* (missing price or product) so the caller can filter them out.
|
||||
*/
|
||||
export const normalizeSubscriptionItem = ({
|
||||
stripeItem,
|
||||
}: {
|
||||
stripeItem: Stripe.SubscriptionItem;
|
||||
}): StripeItemSnapshot | null => {
|
||||
const price = stripeItem.price;
|
||||
if (!price) return null;
|
||||
|
||||
const stripePriceId = price.id;
|
||||
const stripeProductId = resolveStripeProductId({ product: price.product });
|
||||
if (!stripePriceId || !stripeProductId) return null;
|
||||
|
||||
return {
|
||||
id: stripeItem.id,
|
||||
stripe_price_id: stripePriceId,
|
||||
stripe_product_id: stripeProductId,
|
||||
unit_amount: price.unit_amount ?? null,
|
||||
currency: price.currency ?? null,
|
||||
quantity: stripeItem.quantity ?? 1,
|
||||
billing_scheme: (price.billing_scheme as "per_unit" | "tiered") ?? null,
|
||||
tiers_mode: (price.tiers_mode as "graduated" | "volume") ?? null,
|
||||
tiers: extractTiers({ price }),
|
||||
recurring_interval: price.recurring?.interval ?? null,
|
||||
recurring_usage_type:
|
||||
(price.recurring?.usage_type as "licensed" | "metered") ?? null,
|
||||
metadata: stripeItem.metadata ?? {},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
import type Stripe from "stripe";
|
||||
import { isStripeSubscriptionSchedulePhaseCurrent } from "@/external/stripe/subscriptionSchedules/utils/classifyStripeSubscriptionScheduleUtils";
|
||||
import { stripeSubscriptionToStartDate } from "@/external/stripe/subscriptions/utils/convertStripeSubscription";
|
||||
import { normalizePhaseItem } from "./normalizePhaseItem";
|
||||
import { normalizeSubscriptionItem } from "./normalizeSubscriptionItem";
|
||||
import type { PhaseSnapshot } from "./types";
|
||||
|
||||
/**
|
||||
* Build PhaseSnapshot[] from a Stripe subscription, schedule, or both.
|
||||
*
|
||||
* - schedule supplied → one snapshot per schedule phase
|
||||
* - subscription only → single open-ended snapshot from subscription.items
|
||||
*
|
||||
* At least one input is required.
|
||||
*/
|
||||
export const normalizeSubscriptionPhases = ({
|
||||
subscription,
|
||||
schedule,
|
||||
nowSec = Math.floor(Date.now() / 1000),
|
||||
}: {
|
||||
subscription?: Stripe.Subscription;
|
||||
schedule?: Stripe.SubscriptionSchedule | null;
|
||||
nowSec?: number;
|
||||
}): PhaseSnapshot[] => {
|
||||
if (!schedule && !subscription) {
|
||||
throw new Error(
|
||||
"normalizeSubscriptionPhases requires a subscription or a schedule",
|
||||
);
|
||||
}
|
||||
|
||||
if (!schedule) {
|
||||
const items = subscription!.items.data
|
||||
.map((stripeItem) => normalizeSubscriptionItem({ stripeItem }))
|
||||
.filter((item): item is NonNullable<typeof item> => item !== null);
|
||||
|
||||
return [
|
||||
{
|
||||
start_date: stripeSubscriptionToStartDate({
|
||||
stripeSubscription: subscription!,
|
||||
}),
|
||||
end_date: null,
|
||||
is_current: true,
|
||||
items,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return schedule.phases.map((phase, phaseIndex) => {
|
||||
const items = phase.items
|
||||
.map((phaseItem, itemIndex) =>
|
||||
normalizePhaseItem({
|
||||
phaseItem,
|
||||
syntheticId: `${phaseIndex}:${itemIndex}`,
|
||||
}),
|
||||
)
|
||||
.filter((item): item is NonNullable<typeof item> => item !== null);
|
||||
|
||||
return {
|
||||
start_date: phase.start_date,
|
||||
end_date: phase.end_date ?? null,
|
||||
is_current: isStripeSubscriptionSchedulePhaseCurrent({
|
||||
phase,
|
||||
nowSeconds: nowSec,
|
||||
}),
|
||||
items,
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import type Stripe from "stripe";
|
||||
|
||||
export type StripeItemTier = {
|
||||
up_to: number | null;
|
||||
unit_amount: number | null;
|
||||
flat_amount: number | null;
|
||||
};
|
||||
|
||||
export type StripeItemSnapshot = {
|
||||
id: string;
|
||||
stripe_price_id: string;
|
||||
stripe_product_id: string;
|
||||
unit_amount: number | null;
|
||||
currency: string | null;
|
||||
quantity: number;
|
||||
billing_scheme: "per_unit" | "tiered" | null;
|
||||
tiers_mode: "graduated" | "volume" | null;
|
||||
tiers: StripeItemTier[] | null;
|
||||
recurring_interval: Stripe.Price.Recurring.Interval | null;
|
||||
recurring_usage_type: "licensed" | "metered" | null;
|
||||
metadata: Stripe.Metadata;
|
||||
};
|
||||
|
||||
export type PhaseSnapshot = {
|
||||
start_date: number;
|
||||
end_date: number | null;
|
||||
is_current: boolean;
|
||||
items: StripeItemSnapshot[];
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { FullProduct } from "@autumn/shared";
|
||||
import type {
|
||||
ItemDiff,
|
||||
ItemMatch,
|
||||
} from "@/internal/billing/v2/actions/sync/detect/types";
|
||||
import { findStripeMatchForAutumnPrice } from "../matchUtils/findStripeMatchForAutumnPrice";
|
||||
import { findStripeMatchForAutumnProduct } from "../matchUtils/findStripeMatchForAutumnProduct";
|
||||
import type { StripeItemSnapshot } from "../stripeItemSnapshot/types";
|
||||
|
||||
/**
|
||||
* Match a single StripeItemSnapshot against the supplied Autumn products.
|
||||
*
|
||||
* Walks every Autumn price (priority 1+2) before falling back to the
|
||||
* product-level match (priority 3). The matched Autumn resource(s) are
|
||||
* embedded on the returned ItemMatch so callers never need to re-look-up
|
||||
* by id.
|
||||
*
|
||||
* Pure: no I/O, no sibling-aware decisions.
|
||||
*/
|
||||
export const findAutumnMatchForStripeItem = ({
|
||||
item,
|
||||
fullProducts,
|
||||
}: {
|
||||
item: StripeItemSnapshot;
|
||||
fullProducts: FullProduct[];
|
||||
}): ItemDiff => {
|
||||
const stripePriceIds = new Set([item.stripe_price_id]);
|
||||
const stripeProductIds = new Set([item.stripe_product_id]);
|
||||
|
||||
for (const product of fullProducts) {
|
||||
for (const price of product.prices) {
|
||||
const matched_on = findStripeMatchForAutumnPrice({
|
||||
price,
|
||||
product,
|
||||
stripePriceIds,
|
||||
stripeProductIds,
|
||||
});
|
||||
if (matched_on) {
|
||||
return {
|
||||
stripe: item,
|
||||
match: {
|
||||
kind: "autumn_price",
|
||||
matched_on,
|
||||
price,
|
||||
product,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const product of fullProducts) {
|
||||
const matched_on = findStripeMatchForAutumnProduct({
|
||||
product,
|
||||
stripeProductIds,
|
||||
});
|
||||
if (matched_on) {
|
||||
return {
|
||||
stripe: item,
|
||||
match: { kind: "autumn_product", matched_on, product },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const noMatch: ItemMatch = { kind: "none" };
|
||||
return { stripe: item, match: noMatch };
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
BillingVersion,
|
||||
CusProductStatus,
|
||||
type FeatureOptions,
|
||||
type FullCusProduct,
|
||||
type FullCustomer,
|
||||
type FullProduct,
|
||||
} from "@autumn/shared";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import { initFullCustomerProduct } from "./initFullCustomerProduct";
|
||||
|
||||
/**
|
||||
* Build a Scheduled cusProduct for a future-dated phase.
|
||||
*
|
||||
* Shared between createSchedule's `computeScheduledCustomerProducts` and
|
||||
* sync's `computeSyncFuturePhases` — the per-product init logic is identical
|
||||
* regardless of the action that produced the phase context.
|
||||
*/
|
||||
export const initScheduledCustomerProduct = ({
|
||||
ctx,
|
||||
fullCustomer,
|
||||
fullProduct,
|
||||
featureQuantities,
|
||||
startsAt,
|
||||
endsAt,
|
||||
currentEpochMs,
|
||||
subscriptionId,
|
||||
subscriptionScheduleId,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
fullCustomer: FullCustomer;
|
||||
fullProduct: FullProduct;
|
||||
featureQuantities: FeatureOptions[];
|
||||
startsAt: number;
|
||||
endsAt: number | null | undefined;
|
||||
currentEpochMs: number;
|
||||
/** When syncing from an existing Stripe sub/schedule, link the resulting
|
||||
* scheduled cusProduct back to it so the customer-products view shows the
|
||||
* Stripe linkage and downstream actions (cancel, restore) can find it. */
|
||||
subscriptionId?: string;
|
||||
subscriptionScheduleId?: string;
|
||||
}): FullCusProduct => {
|
||||
return initFullCustomerProduct({
|
||||
ctx,
|
||||
initContext: {
|
||||
fullCustomer,
|
||||
fullProduct,
|
||||
featureQuantities,
|
||||
resetCycleAnchor: startsAt,
|
||||
freeTrial: null,
|
||||
now: currentEpochMs,
|
||||
billingVersion: BillingVersion.V2,
|
||||
},
|
||||
initOptions: {
|
||||
startsAt,
|
||||
endedAt: endsAt ?? undefined,
|
||||
status: CusProductStatus.Scheduled,
|
||||
subscriptionId,
|
||||
subscriptionScheduleId,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -47,7 +47,9 @@ const RATE_LIMIT_ROUTE_GROUPS: RateLimitRouteGroup[] = [
|
||||
route({ method: "POST", url: "/v1/billing.setup_payment" }),
|
||||
route({ method: "POST", url: "/v1/billing.open_customer_portal" }),
|
||||
route({ method: "POST", url: "/v1/billing.sync_proposals" }),
|
||||
route({ method: "POST", url: "/v1/billing.sync_proposals_v2" }),
|
||||
route({ method: "POST", url: "/v1/billing.sync" }),
|
||||
route({ method: "POST", url: "/v1/billing.sync_v2" }),
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -2,7 +2,12 @@ import type { TestGroup } from "./types";
|
||||
|
||||
export const temp: TestGroup = {
|
||||
name: "temp",
|
||||
description: "customer.subscription.created auto-sync + skip-sync coverage",
|
||||
description:
|
||||
"sub.created auto-sync + sync param detection + restore (Autumn → Stripe)",
|
||||
tier: "domain",
|
||||
paths: ["integration/billing/stripe-webhooks/subscription-created"],
|
||||
paths: [
|
||||
// "integration/billing/stripe-webhooks/subscription-created",
|
||||
"integration/billing/sync/to-sync-params",
|
||||
// "integration/billing/restore",
|
||||
],
|
||||
};
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Restore Advanced Tests
|
||||
*
|
||||
* Test 3: Pro + recurring add-on. Remove the add-on item from Stripe, restore
|
||||
* should put it back.
|
||||
* Test 4: Pro + prepaid messages. Set the prepaid quantity to a wrong value,
|
||||
* restore should set it back to Autumn's expected quantity.
|
||||
*/
|
||||
|
||||
import { expect, test } from "bun:test";
|
||||
import { OnDecrease, OnIncrease } from "@autumn/shared";
|
||||
import { TestFeature } from "@tests/setup/v2Features";
|
||||
import { items } from "@tests/utils/fixtures/items";
|
||||
import { products } from "@tests/utils/fixtures/products";
|
||||
import type { TestContext } from "@tests/utils/testInitUtils/createTestContext";
|
||||
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
|
||||
import chalk from "chalk";
|
||||
import { CusService } from "@/internal/customers/CusService";
|
||||
import { ProductService } from "@/internal/products/ProductService";
|
||||
import { expectStripeSubscriptionCorrect } from "../utils/expectStripeSubCorrect/expectStripeSubscriptionCorrect";
|
||||
import {
|
||||
corruptStripeSubscription,
|
||||
listActiveStripeSubscriptions,
|
||||
} from "./utils/corruptStripeSubscription";
|
||||
|
||||
const stripeCustomerIdFor = async ({
|
||||
ctx,
|
||||
customerId,
|
||||
}: {
|
||||
ctx: TestContext;
|
||||
customerId: string;
|
||||
}) => {
|
||||
const fullCustomer = await CusService.getFull({
|
||||
ctx,
|
||||
idOrInternalId: customerId,
|
||||
});
|
||||
const stripeCustomerId = fullCustomer.processor?.id;
|
||||
if (!stripeCustomerId) {
|
||||
throw new Error(`Customer ${customerId} has no Stripe customer ID`);
|
||||
}
|
||||
return stripeCustomerId;
|
||||
};
|
||||
|
||||
const allStripePriceIdsFor = async ({
|
||||
ctx,
|
||||
productId,
|
||||
}: {
|
||||
ctx: TestContext;
|
||||
productId: string;
|
||||
}) => {
|
||||
const fullProduct = await ProductService.getFull({
|
||||
db: ctx.db,
|
||||
idOrInternalId: productId,
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
});
|
||||
const ids: string[] = [];
|
||||
for (const price of fullProduct.prices) {
|
||||
const id =
|
||||
price.config.stripe_price_id ?? price.config.stripe_empty_price_id;
|
||||
if (id) ids.push(id);
|
||||
}
|
||||
return ids;
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 3: Pro + recurring add-on, remove add-on item from Stripe, restore
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("restore-advanced 3: pro + recurring add-on, remove add-on item, restore")}`, async () => {
|
||||
const customerId = "restore-advanced-addon";
|
||||
|
||||
const proMessages = items.monthlyMessages({ includedUsage: 200 });
|
||||
const pro = products.pro({ id: "pro", items: [proMessages] });
|
||||
|
||||
const addonWords = items.monthlyWords({ includedUsage: 200 });
|
||||
const addOn = products.recurringAddOn({
|
||||
id: "recurring-addon",
|
||||
items: [addonWords],
|
||||
});
|
||||
|
||||
const { autumnV2_2, ctx } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [pro, addOn] }),
|
||||
],
|
||||
actions: [
|
||||
s.billing.attach({ productId: pro.id }),
|
||||
s.billing.attach({ productId: addOn.id, timeout: 4000 }),
|
||||
],
|
||||
});
|
||||
|
||||
const stripeCustomerId = await stripeCustomerIdFor({ ctx, customerId });
|
||||
const subs = await listActiveStripeSubscriptions({ ctx, stripeCustomerId });
|
||||
expect(subs.length).toBe(1);
|
||||
const sub = subs[0];
|
||||
|
||||
// Remove the add-on's Stripe item from the subscription.
|
||||
const addonPriceIds = await allStripePriceIdsFor({
|
||||
ctx,
|
||||
productId: addOn.id,
|
||||
});
|
||||
await corruptStripeSubscription({
|
||||
ctx,
|
||||
subscriptionId: sub.id,
|
||||
mutations: { removeItemPriceIds: addonPriceIds },
|
||||
});
|
||||
|
||||
await autumnV2_2.billing.restore({ customer_id: customerId });
|
||||
|
||||
await expectStripeSubscriptionCorrect({ ctx, customerId });
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 4: Pro with prepaid messages — corrupt the prepaid quantity, restore
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("restore-advanced 4: pro + prepaid messages, wrong quantity, restore")}`, async () => {
|
||||
const customerId = "restore-advanced-prepaid";
|
||||
const billingUnits = 100;
|
||||
const includedUsage = 100;
|
||||
|
||||
const prepaid = items.prepaidMessages({
|
||||
includedUsage,
|
||||
billingUnits,
|
||||
price: 10,
|
||||
config: {
|
||||
on_increase: OnIncrease.ProrateImmediately,
|
||||
on_decrease: OnDecrease.ProrateImmediately,
|
||||
},
|
||||
});
|
||||
const fixed = items.monthlyPrice({ price: 20 });
|
||||
const pro = products.base({ id: "pro", items: [prepaid, fixed] });
|
||||
|
||||
const initialPacks = 4;
|
||||
const totalUnits = includedUsage + initialPacks * billingUnits;
|
||||
|
||||
const { autumnV2_2, ctx } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [pro] }),
|
||||
],
|
||||
actions: [
|
||||
s.billing.attach({
|
||||
productId: pro.id,
|
||||
options: [{ feature_id: TestFeature.Messages, quantity: totalUnits }],
|
||||
timeout: 4000,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const stripeCustomerId = await stripeCustomerIdFor({ ctx, customerId });
|
||||
const subs = await listActiveStripeSubscriptions({ ctx, stripeCustomerId });
|
||||
expect(subs.length).toBe(1);
|
||||
const sub = subs[0];
|
||||
|
||||
const proPriceIds = await allStripePriceIdsFor({ ctx, productId: pro.id });
|
||||
|
||||
// Find the prepaid item on Stripe (one of the price ids will be on the sub
|
||||
// with quantity > 0 and not equal to the fixed-base price item). The
|
||||
// simplest corruption: set every Stripe item quantity to a wrong value.
|
||||
const wrongQuantityUpdates = sub.items.data
|
||||
.filter((item) => proPriceIds.includes(item.price.id))
|
||||
.filter((item) => item.quantity !== undefined)
|
||||
.map((item) => ({
|
||||
priceId: item.price.id,
|
||||
quantity: (item.quantity ?? 1) + 3,
|
||||
}));
|
||||
|
||||
await corruptStripeSubscription({
|
||||
ctx,
|
||||
subscriptionId: sub.id,
|
||||
mutations: { setItemQuantities: wrongQuantityUpdates },
|
||||
});
|
||||
|
||||
await autumnV2_2.billing.restore({ customer_id: customerId });
|
||||
|
||||
await expectStripeSubscriptionCorrect({ ctx, customerId });
|
||||
});
|
||||
184
server/tests/integration/billing/restore/restore-basic.test.ts
Normal file
184
server/tests/integration/billing/restore/restore-basic.test.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Restore Basic Tests
|
||||
*
|
||||
* The `restore` billing action takes Autumn's customer_products as the source
|
||||
* of truth and reshapes Stripe to match. Tests here drift Stripe out of sync
|
||||
* deliberately, then verify restore brings it back.
|
||||
*
|
||||
* Test 1: Two entities (pro + premium). Corrupt entity 1's sub items, restore.
|
||||
* Test 2: Schedule scenario — release schedule manually, restore recreates it.
|
||||
*/
|
||||
|
||||
import { expect, test } from "bun:test";
|
||||
import { TestFeature } from "@tests/setup/v2Features";
|
||||
import { items } from "@tests/utils/fixtures/items";
|
||||
import { products } from "@tests/utils/fixtures/products";
|
||||
import type { TestContext } from "@tests/utils/testInitUtils/createTestContext";
|
||||
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
|
||||
import chalk from "chalk";
|
||||
import { CusService } from "@/internal/customers/CusService";
|
||||
import { ProductService } from "@/internal/products/ProductService";
|
||||
import { expectStripeSubscriptionCorrect } from "../utils/expectStripeSubCorrect/expectStripeSubscriptionCorrect";
|
||||
import {
|
||||
corruptStripeSubscription,
|
||||
listActiveStripeSubscriptions,
|
||||
} from "./utils/corruptStripeSubscription";
|
||||
|
||||
const stripeCustomerIdFor = async ({
|
||||
ctx,
|
||||
customerId,
|
||||
}: {
|
||||
ctx: TestContext;
|
||||
customerId: string;
|
||||
}) => {
|
||||
const fullCustomer = await CusService.getFull({
|
||||
ctx,
|
||||
idOrInternalId: customerId,
|
||||
});
|
||||
const stripeCustomerId = fullCustomer.processor?.id;
|
||||
if (!stripeCustomerId) {
|
||||
throw new Error(`Customer ${customerId} has no Stripe customer ID`);
|
||||
}
|
||||
return stripeCustomerId;
|
||||
};
|
||||
|
||||
const firstStripePriceIdFor = async ({
|
||||
ctx,
|
||||
productId,
|
||||
}: {
|
||||
ctx: TestContext;
|
||||
productId: string;
|
||||
}) => {
|
||||
const fullProduct = await ProductService.getFull({
|
||||
db: ctx.db,
|
||||
idOrInternalId: productId,
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
});
|
||||
for (const price of fullProduct.prices) {
|
||||
const id =
|
||||
price.config.stripe_price_id ?? price.config.stripe_empty_price_id;
|
||||
if (id) return id;
|
||||
}
|
||||
throw new Error(`No Stripe price id on product ${productId}`);
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 1: Two entities, corrupt one sub, restore
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("restore-basic 1: two entities pro+premium, corrupt one, restore")}`, async () => {
|
||||
const customerId = "restore-basic-two-entities";
|
||||
|
||||
const proMessages = items.monthlyMessages({ includedUsage: 200 });
|
||||
const pro = products.pro({ id: "pro", items: [proMessages] });
|
||||
|
||||
const premiumMessages = items.monthlyMessages({ includedUsage: 1000 });
|
||||
const premium = products.premium({ id: "premium", items: [premiumMessages] });
|
||||
|
||||
const { autumnV2_2, ctx, entities } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [pro, premium] }),
|
||||
s.entities({ count: 2, featureId: TestFeature.Users }),
|
||||
],
|
||||
actions: [
|
||||
s.billing.attach({ productId: pro.id, entityIndex: 0 }),
|
||||
s.billing.attach({
|
||||
productId: premium.id,
|
||||
entityIndex: 1,
|
||||
timeout: 4000,
|
||||
}),
|
||||
],
|
||||
});
|
||||
expect(entities.length).toBe(2);
|
||||
|
||||
// Corrupt the pro sub item by setting its quantity to a wrong value. Each
|
||||
// entity may share a single Stripe subscription (entity-scoped items), so we
|
||||
// avoid mutations that depend on the sub topology — a per-item quantity bump
|
||||
// is a clean diff restore should reverse.
|
||||
const stripeCustomerId = await stripeCustomerIdFor({ ctx, customerId });
|
||||
const subs = await listActiveStripeSubscriptions({ ctx, stripeCustomerId });
|
||||
const proStripePriceId = await firstStripePriceIdFor({
|
||||
ctx,
|
||||
productId: pro.id,
|
||||
});
|
||||
|
||||
const proSub = subs.find((sub) =>
|
||||
sub.items.data.some((it) => it.price.id === proStripePriceId),
|
||||
);
|
||||
if (!proSub) throw new Error("Pro sub not found in Stripe");
|
||||
|
||||
await corruptStripeSubscription({
|
||||
ctx,
|
||||
subscriptionId: proSub.id,
|
||||
mutations: {
|
||||
setItemQuantities: [{ priceId: proStripePriceId, quantity: 7 }],
|
||||
},
|
||||
});
|
||||
|
||||
// Restore — should remove the junk item.
|
||||
await autumnV2_2.billing.restore({ customer_id: customerId });
|
||||
|
||||
await expectStripeSubscriptionCorrect({ ctx, customerId });
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 2: With schedule — release schedule manually, restore recreates it
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("restore-basic 2: scheduled downgrade, schedule released externally, restore recreates")}`, async () => {
|
||||
const customerId = "restore-basic-released-schedule";
|
||||
|
||||
const proMessages = items.monthlyMessages({ includedUsage: 200 });
|
||||
const pro = products.pro({ id: "pro", items: [proMessages] });
|
||||
|
||||
const premiumMessages = items.monthlyMessages({ includedUsage: 1000 });
|
||||
const premium = products.premium({ id: "premium", items: [premiumMessages] });
|
||||
|
||||
const { autumnV2_2, ctx, entities } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [pro, premium] }),
|
||||
s.entities({ count: 2, featureId: TestFeature.Users }),
|
||||
],
|
||||
actions: [
|
||||
s.billing.attach({ productId: pro.id, entityIndex: 0 }),
|
||||
s.billing.attach({ productId: premium.id, entityIndex: 1 }),
|
||||
// Schedule downgrade premium → pro on entity 1
|
||||
s.billing.attach({
|
||||
productId: pro.id,
|
||||
entityIndex: 1,
|
||||
timeout: 4000,
|
||||
}),
|
||||
],
|
||||
});
|
||||
expect(entities.length).toBe(2);
|
||||
|
||||
// Locate the entity-1 (premium) Stripe subscription which now has a schedule
|
||||
const stripeCustomerId = await stripeCustomerIdFor({ ctx, customerId });
|
||||
const subs = await listActiveStripeSubscriptions({ ctx, stripeCustomerId });
|
||||
|
||||
const premiumStripePriceId = await firstStripePriceIdFor({
|
||||
ctx,
|
||||
productId: premium.id,
|
||||
});
|
||||
const premiumSub = subs.find((sub) =>
|
||||
sub.items.data.some((it) => it.price.id === premiumStripePriceId),
|
||||
);
|
||||
if (!premiumSub) throw new Error("Premium sub not found");
|
||||
|
||||
// Release the schedule directly via Stripe (corrupts state)
|
||||
await corruptStripeSubscription({
|
||||
ctx,
|
||||
subscriptionId: premiumSub.id,
|
||||
mutations: { releaseSchedule: true },
|
||||
});
|
||||
|
||||
// Restore — should re-create the schedule
|
||||
await autumnV2_2.billing.restore({ customer_id: customerId });
|
||||
|
||||
await expectStripeSubscriptionCorrect({ ctx, customerId });
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Restore Schedule Tests
|
||||
*
|
||||
* Verifies that `restore` rebuilds multi-phase Stripe schedules created via
|
||||
* /billing.create_schedule when a developer releases the schedule directly.
|
||||
*
|
||||
* Test 5: Two-phase schedule (pro+addon now, pro-only in +30d) — release the
|
||||
* schedule via Stripe, restore should re-create the schedule with both
|
||||
* phases intact.
|
||||
*/
|
||||
|
||||
import { expect, test } from "bun:test";
|
||||
import { type CreateScheduleParamsV0Input, ms } from "@autumn/shared";
|
||||
import { items } from "@tests/utils/fixtures/items";
|
||||
import { products } from "@tests/utils/fixtures/products";
|
||||
import type { TestContext } from "@tests/utils/testInitUtils/createTestContext";
|
||||
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
|
||||
import chalk from "chalk";
|
||||
import { CusService } from "@/internal/customers/CusService";
|
||||
import { expectStripeSubscriptionCorrect } from "../utils/expectStripeSubCorrect/expectStripeSubscriptionCorrect";
|
||||
import {
|
||||
corruptStripeSubscription,
|
||||
listActiveStripeSubscriptions,
|
||||
} from "./utils/corruptStripeSubscription";
|
||||
|
||||
const stripeCustomerIdFor = async ({
|
||||
ctx,
|
||||
customerId,
|
||||
}: {
|
||||
ctx: TestContext;
|
||||
customerId: string;
|
||||
}) => {
|
||||
const fullCustomer = await CusService.getFull({
|
||||
ctx,
|
||||
idOrInternalId: customerId,
|
||||
});
|
||||
const stripeCustomerId = fullCustomer.processor?.id;
|
||||
if (!stripeCustomerId) {
|
||||
throw new Error(`Customer ${customerId} has no Stripe customer ID`);
|
||||
}
|
||||
return stripeCustomerId;
|
||||
};
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("restore-schedule 5: two-phase schedule, release schedule, restore re-creates phases")}`, async () => {
|
||||
const customerId = "restore-schedule-multi-phase";
|
||||
|
||||
const pro = products.pro({
|
||||
id: "pro",
|
||||
items: [items.monthlyMessages({ includedUsage: 100 })],
|
||||
});
|
||||
const addon = products.recurringAddOn({
|
||||
id: "addon",
|
||||
items: [items.monthlyWords({ includedUsage: 25 })],
|
||||
});
|
||||
|
||||
const { autumnV1, autumnV2_2, ctx } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [pro, addon] }),
|
||||
],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
const now = Date.now();
|
||||
const params: CreateScheduleParamsV0Input = {
|
||||
customer_id: customerId,
|
||||
phases: [
|
||||
{
|
||||
starts_at: now,
|
||||
plans: [{ plan_id: pro.id }, { plan_id: addon.id }],
|
||||
},
|
||||
{
|
||||
starts_at: now + ms.days(30),
|
||||
plans: [{ plan_id: pro.id }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const response = await autumnV1.billing.createSchedule(params);
|
||||
expect(response.status).toBe("created");
|
||||
expect(response.phases).toHaveLength(2);
|
||||
|
||||
// Locate the Stripe sub for this customer (single sub backing both phases)
|
||||
const stripeCustomerId = await stripeCustomerIdFor({ ctx, customerId });
|
||||
const subs = await listActiveStripeSubscriptions({ ctx, stripeCustomerId });
|
||||
expect(subs.length).toBe(1);
|
||||
const sub = subs[0];
|
||||
|
||||
// Drift: release the schedule (caller "fixed" something in Stripe by hand).
|
||||
await corruptStripeSubscription({
|
||||
ctx,
|
||||
subscriptionId: sub.id,
|
||||
mutations: { releaseSchedule: true },
|
||||
});
|
||||
|
||||
// Restore should re-create the schedule and put both phases back.
|
||||
await autumnV2_2.billing.restore({ customer_id: customerId });
|
||||
|
||||
await expectStripeSubscriptionCorrect({ ctx, customerId });
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { TestContext } from "@tests/utils/testInitUtils/createTestContext";
|
||||
import type Stripe from "stripe";
|
||||
|
||||
/**
|
||||
* Mutates a Stripe subscription / its schedule to drift from Autumn's expected
|
||||
* state, so that the restore action has work to do. Test-only utility.
|
||||
*/
|
||||
export const corruptStripeSubscription = async ({
|
||||
ctx,
|
||||
subscriptionId,
|
||||
mutations,
|
||||
}: {
|
||||
ctx: TestContext;
|
||||
subscriptionId: string;
|
||||
mutations: {
|
||||
removeAllItems?: boolean;
|
||||
removeItemPriceIds?: string[];
|
||||
addItems?: Array<{ price: string; quantity?: number }>;
|
||||
setItemQuantities?: Array<{ priceId: string; quantity: number }>;
|
||||
releaseSchedule?: boolean;
|
||||
};
|
||||
}): Promise<Stripe.Subscription> => {
|
||||
const sub = await ctx.stripeCli.subscriptions.retrieve(subscriptionId);
|
||||
|
||||
if (mutations.releaseSchedule) {
|
||||
const scheduleId =
|
||||
typeof sub.schedule === "string" ? sub.schedule : sub.schedule?.id;
|
||||
if (scheduleId) {
|
||||
await ctx.stripeCli.subscriptionSchedules.release(scheduleId);
|
||||
}
|
||||
}
|
||||
|
||||
const updateItems: Stripe.SubscriptionUpdateParams.Item[] = [];
|
||||
|
||||
if (mutations.removeAllItems) {
|
||||
for (const item of sub.items.data) {
|
||||
updateItems.push({ id: item.id, deleted: true });
|
||||
}
|
||||
}
|
||||
|
||||
if (mutations.removeItemPriceIds?.length) {
|
||||
const priceIdSet = new Set(mutations.removeItemPriceIds);
|
||||
for (const item of sub.items.data) {
|
||||
if (priceIdSet.has(item.price.id)) {
|
||||
updateItems.push({ id: item.id, deleted: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (mutations.setItemQuantities?.length) {
|
||||
for (const { priceId, quantity } of mutations.setItemQuantities) {
|
||||
const item = sub.items.data.find((i) => i.price.id === priceId);
|
||||
if (item) updateItems.push({ id: item.id, quantity });
|
||||
}
|
||||
}
|
||||
|
||||
if (mutations.addItems?.length) {
|
||||
for (const add of mutations.addItems) {
|
||||
updateItems.push({ price: add.price, quantity: add.quantity ?? 1 });
|
||||
}
|
||||
}
|
||||
|
||||
if (updateItems.length === 0) {
|
||||
return ctx.stripeCli.subscriptions.retrieve(subscriptionId);
|
||||
}
|
||||
|
||||
return ctx.stripeCli.subscriptions.update(subscriptionId, {
|
||||
items: updateItems,
|
||||
proration_behavior: "none",
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the (single) primary Stripe subscription for a customer in the test
|
||||
* environment. Throws if there is more than one or zero.
|
||||
*/
|
||||
export const getStripeSubscriptionForCustomer = async ({
|
||||
ctx,
|
||||
stripeCustomerId,
|
||||
}: {
|
||||
ctx: TestContext;
|
||||
stripeCustomerId: string;
|
||||
}): Promise<Stripe.Subscription> => {
|
||||
const subs = await ctx.stripeCli.subscriptions.list({
|
||||
customer: stripeCustomerId,
|
||||
status: "all",
|
||||
limit: 100,
|
||||
});
|
||||
const active = subs.data.filter(
|
||||
(s) => s.status === "active" || s.status === "trialing",
|
||||
);
|
||||
if (active.length !== 1) {
|
||||
throw new Error(
|
||||
`Expected exactly 1 active Stripe subscription for customer ${stripeCustomerId}, found ${active.length}`,
|
||||
);
|
||||
}
|
||||
return active[0];
|
||||
};
|
||||
|
||||
export const listActiveStripeSubscriptions = async ({
|
||||
ctx,
|
||||
stripeCustomerId,
|
||||
}: {
|
||||
ctx: TestContext;
|
||||
stripeCustomerId: string;
|
||||
}): Promise<Stripe.Subscription[]> => {
|
||||
const subs = await ctx.stripeCli.subscriptions.list({
|
||||
customer: stripeCustomerId,
|
||||
status: "all",
|
||||
limit: 100,
|
||||
});
|
||||
return subs.data.filter(
|
||||
(s) => s.status === "active" || s.status === "trialing",
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { type ApiCustomerV3, AppEnv, type FullCustomer } from "@autumn/shared";
|
||||
import { type ApiCustomerV3, AppEnv } from "@autumn/shared";
|
||||
import {
|
||||
createStripeSubscriptionFromProduct,
|
||||
createStripeSubscriptionFromProducts,
|
||||
@@ -18,89 +18,10 @@ import { timeout } from "@tests/utils/genUtils";
|
||||
import ctx from "@tests/utils/testInitUtils/createTestContext";
|
||||
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
|
||||
import chalk from "chalk";
|
||||
import { handleStripeSubscriptionCreated } from "@/external/stripe/webhookHandlers/handleStripeSubscriptionCreated/handleStripeSubscriptionCreated";
|
||||
import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/stripeWebhookContext";
|
||||
import { CusService } from "@/internal/customers/CusService";
|
||||
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService";
|
||||
import { ProductService } from "@/internal/products/ProductService";
|
||||
|
||||
const makeFullCustomer = ({
|
||||
subscriptionIds = [],
|
||||
}: {
|
||||
subscriptionIds?: string[];
|
||||
} = {}): FullCustomer =>
|
||||
({
|
||||
id: "customer_external_id",
|
||||
internal_id: "customer_internal_id",
|
||||
customer_products:
|
||||
subscriptionIds.length > 0
|
||||
? [{ id: "customer_product_id", subscription_ids: subscriptionIds }]
|
||||
: [],
|
||||
}) as FullCustomer;
|
||||
|
||||
const makeGuardrailContext = ({
|
||||
fullCustomer,
|
||||
orgId = "org_123",
|
||||
orgSlug = "org-slug",
|
||||
subscriptionId = "sub_stripe_external",
|
||||
}: {
|
||||
fullCustomer?: FullCustomer;
|
||||
orgId?: string;
|
||||
orgSlug?: string;
|
||||
subscriptionId?: string;
|
||||
}): {
|
||||
ctx: StripeWebhookContext;
|
||||
retrieveCalls: string[];
|
||||
} => {
|
||||
const retrieveCalls: string[] = [];
|
||||
|
||||
return {
|
||||
retrieveCalls,
|
||||
ctx: {
|
||||
db: "db",
|
||||
org: { id: orgId, slug: orgSlug },
|
||||
env: AppEnv.Sandbox,
|
||||
fullCustomer,
|
||||
logger: {
|
||||
error: () => undefined,
|
||||
info: () => undefined,
|
||||
warn: () => undefined,
|
||||
},
|
||||
stripeCli: {
|
||||
subscriptions: {
|
||||
retrieve: async (stripeId: string) => {
|
||||
retrieveCalls.push(stripeId);
|
||||
throw new Error("Stripe retrieve should not be called");
|
||||
},
|
||||
},
|
||||
},
|
||||
stripeEvent: {
|
||||
type: "customer.subscription.created",
|
||||
data: {
|
||||
object: {
|
||||
id: subscriptionId,
|
||||
customer: "cus_stripe_external",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as StripeWebhookContext,
|
||||
};
|
||||
};
|
||||
|
||||
const withNodeEnv = async <T>(nodeEnv: string, callback: () => Promise<T>) => {
|
||||
const originalNodeEnv = process.env.NODE_ENV;
|
||||
process.env.NODE_ENV = nodeEnv;
|
||||
|
||||
try {
|
||||
return await callback();
|
||||
} finally {
|
||||
if (originalNodeEnv === undefined) {
|
||||
delete process.env.NODE_ENV;
|
||||
} else {
|
||||
process.env.NODE_ENV = originalNodeEnv;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
test(`${chalk.yellowBright("customer.subscription.created auto-sync: sync external Stripe sandbox sub")}`, async () => {
|
||||
const customerId = "sub-created-auto-sync";
|
||||
|
||||
@@ -153,37 +74,36 @@ test(`${chalk.yellowBright("customer.subscription.created auto-sync: sync extern
|
||||
});
|
||||
|
||||
test(`${chalk.yellowBright("customer.subscription.created auto-sync: skips unknown Autumn customer")}`, async () => {
|
||||
const { ctx, retrieveCalls } = makeGuardrailContext({});
|
||||
|
||||
await handleStripeSubscriptionCreated({ ctx });
|
||||
|
||||
expect(retrieveCalls).toEqual([]);
|
||||
});
|
||||
|
||||
test(`${chalk.yellowBright("customer.subscription.created auto-sync: skips already-linked subscription")}`, async () => {
|
||||
const subscriptionId = "sub_already_linked";
|
||||
const { ctx, retrieveCalls } = makeGuardrailContext({
|
||||
subscriptionId,
|
||||
fullCustomer: makeFullCustomer({ subscriptionIds: [subscriptionId] }),
|
||||
const stripeCustomer = await ctx.stripeCli.customers.create({
|
||||
email: "sub-created-auto-sync-unknown@example.com",
|
||||
});
|
||||
const stripeProduct = await ctx.stripeCli.products.create({
|
||||
name: "Sub Created Auto Sync Unknown Customer",
|
||||
});
|
||||
const stripeSubscription = await ctx.stripeCli.subscriptions.create({
|
||||
customer: stripeCustomer.id,
|
||||
items: [
|
||||
{
|
||||
price_data: {
|
||||
currency: "usd",
|
||||
product: stripeProduct.id,
|
||||
recurring: { interval: "month" },
|
||||
unit_amount: 4242,
|
||||
},
|
||||
},
|
||||
],
|
||||
payment_behavior: "default_incomplete",
|
||||
});
|
||||
|
||||
await handleStripeSubscriptionCreated({ ctx });
|
||||
await timeout(10000);
|
||||
|
||||
expect(retrieveCalls).toEqual([]);
|
||||
});
|
||||
|
||||
test(`${chalk.yellowBright("customer.subscription.created auto-sync: production gate skips disabled org")}`, async () => {
|
||||
const { ctx, retrieveCalls } = makeGuardrailContext({
|
||||
orgId: "org_sub_created_auto_sync_disabled",
|
||||
orgSlug: "sub-created-auto-sync-disabled",
|
||||
fullCustomer: makeFullCustomer(),
|
||||
const linkedCusProducts = await CusProductService.getByStripeSubId({
|
||||
db: ctx.db,
|
||||
stripeSubId: stripeSubscription.id,
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
});
|
||||
|
||||
await withNodeEnv("production", async () => {
|
||||
await handleStripeSubscriptionCreated({ ctx });
|
||||
});
|
||||
|
||||
expect(retrieveCalls).toEqual([]);
|
||||
expect(linkedCusProducts).toEqual([]);
|
||||
});
|
||||
|
||||
test(`${chalk.yellowBright("customer.subscription.created auto-sync: skips Stripe sandbox sub with no product match")}`, async () => {
|
||||
|
||||
@@ -0,0 +1,436 @@
|
||||
/**
|
||||
* subscriptionToSyncParams — basic detection cases
|
||||
*
|
||||
* Exercises the canonical "Stripe sub → SyncParamsV1" pipeline against a
|
||||
* variety of subscription shapes. Each test:
|
||||
* 1. Sets up an Autumn customer + a single Pro product
|
||||
* 2. Creates a Stripe subscription with specific items
|
||||
* 3. Calls `subscriptionToSyncParams` directly with the test ctx
|
||||
* 4. Asserts via `expectSyncParamsCorrect` + `expectSubscriptionMatchCorrect`
|
||||
*
|
||||
* NOTE: Cases 3, 4, 5 codify desired contract — they exercise
|
||||
* `feature_quantities` / custom-stripe-price-id propagation that may not be
|
||||
* fully wired through `subscriptionToSyncParams` yet. Failures here are the
|
||||
* spec for the next implementation pass.
|
||||
*/
|
||||
|
||||
import { test } from "bun:test";
|
||||
import { expectSubscriptionMatchCorrect } from "@tests/integration/billing/utils/sync/expectSubscriptionMatch";
|
||||
import { expectSyncParamsCorrect } from "@tests/integration/billing/utils/sync/expectSyncParams";
|
||||
import { TestFeature } from "@tests/setup/v2Features";
|
||||
import { items } from "@tests/utils/fixtures/items";
|
||||
import { products } from "@tests/utils/fixtures/products";
|
||||
import ctx from "@tests/utils/testInitUtils/createTestContext";
|
||||
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
|
||||
import chalk from "chalk";
|
||||
import { subscriptionToSyncParams } from "@/internal/billing/v2/actions/sync/subscriptionToSyncParams";
|
||||
import { createStripeSubscriptionFromProduct } from "../utils/syncTestUtils";
|
||||
import {
|
||||
createStripeFixedPriceUnderProduct,
|
||||
createStripeTieredPriceUnderProduct,
|
||||
fetchFullProduct,
|
||||
getBaseStripePriceId,
|
||||
getPrepaidStripeProductId,
|
||||
getProductStripeProductId,
|
||||
getStripeCustomerId,
|
||||
} from "../utils/syncProductHelpers";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// CASE 1: simple plan with base price, exact stripe_price_id match
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("to-sync-params: case 1 — base price exact match")}`, async () => {
|
||||
const customerId = "to-sync-params-1-base";
|
||||
const pro = products.pro({ id: "pro", items: [] });
|
||||
|
||||
await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [pro] }),
|
||||
],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
const subscription = await createStripeSubscriptionFromProduct({
|
||||
ctx,
|
||||
customerId,
|
||||
productId: pro.id,
|
||||
});
|
||||
|
||||
const { match, params } = await subscriptionToSyncParams({
|
||||
ctx,
|
||||
customerId,
|
||||
subscription,
|
||||
});
|
||||
|
||||
expectSyncParamsCorrect({
|
||||
params,
|
||||
customer_id: customerId,
|
||||
stripe_subscription_id: subscription.id,
|
||||
phases: [
|
||||
{
|
||||
starts_at: "now",
|
||||
plans: [{ plan_id: pro.id, quantity: 1, customize: null }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expectSubscriptionMatchCorrect({
|
||||
match,
|
||||
currentPhase: {
|
||||
plans: [{ plan_id: pro.id, base_kind: "matched" }],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// CASE 2: simple plan with custom base price under the same Stripe product
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("to-sync-params: case 2 — custom base price under same stripe product")}`, async () => {
|
||||
const customerId = "to-sync-params-2-custom-base";
|
||||
const pro = products.pro({ id: "pro", items: [] });
|
||||
|
||||
await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [pro] }),
|
||||
],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
const fullProduct = await fetchFullProduct({ ctx, productId: pro.id });
|
||||
const stripeProductId = getProductStripeProductId({ fullProduct });
|
||||
const stripeCustomerId = await getStripeCustomerId({ ctx, customerId });
|
||||
|
||||
// Build a custom Stripe price ($50/mo) under Pro's Stripe product.
|
||||
const customPrice = await createStripeFixedPriceUnderProduct({
|
||||
ctx,
|
||||
stripeProductId,
|
||||
unitAmount: 5000,
|
||||
});
|
||||
|
||||
const subscription = await ctx.stripeCli.subscriptions.create({
|
||||
customer: stripeCustomerId,
|
||||
items: [{ price: customPrice.id }],
|
||||
});
|
||||
|
||||
const { match, params } = await subscriptionToSyncParams({
|
||||
ctx,
|
||||
customerId,
|
||||
subscription,
|
||||
});
|
||||
|
||||
expectSyncParamsCorrect({
|
||||
params,
|
||||
customer_id: customerId,
|
||||
stripe_subscription_id: subscription.id,
|
||||
phases: [
|
||||
{
|
||||
starts_at: "now",
|
||||
plans: [
|
||||
{
|
||||
plan_id: pro.id,
|
||||
customize: {
|
||||
price: {
|
||||
amount: 50,
|
||||
interval: "month",
|
||||
stripe_price_id: customPrice.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expectSubscriptionMatchCorrect({
|
||||
match,
|
||||
currentPhase: {
|
||||
plans: [{ plan_id: pro.id, base_kind: "custom" }],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// CASE 3: plan with prepaid messages; sub omits the prepaid item → quantity 0
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("to-sync-params: case 3 — prepaid messages initialized to quantity 0")}`, async () => {
|
||||
const customerId = "to-sync-params-3-prepaid-zero";
|
||||
const pro = products.pro({
|
||||
id: "pro",
|
||||
items: [items.prepaidMessages({ price: 10, billingUnits: 100 })],
|
||||
});
|
||||
|
||||
await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [pro] }),
|
||||
],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
// Build a sub that ONLY uses Pro's base price — omit the prepaid item.
|
||||
const fullProduct = await fetchFullProduct({ ctx, productId: pro.id });
|
||||
const baseStripePriceId = getBaseStripePriceId({ fullProduct });
|
||||
const stripeCustomerId = await getStripeCustomerId({ ctx, customerId });
|
||||
|
||||
const subscription = await ctx.stripeCli.subscriptions.create({
|
||||
customer: stripeCustomerId,
|
||||
items: [{ price: baseStripePriceId }],
|
||||
});
|
||||
|
||||
const { params } = await subscriptionToSyncParams({
|
||||
ctx,
|
||||
customerId,
|
||||
subscription,
|
||||
});
|
||||
|
||||
expectSyncParamsCorrect({
|
||||
params,
|
||||
customer_id: customerId,
|
||||
stripe_subscription_id: subscription.id,
|
||||
phases: [
|
||||
{
|
||||
starts_at: "now",
|
||||
plans: [
|
||||
{
|
||||
plan_id: pro.id,
|
||||
customize: null,
|
||||
feature_quantities: [
|
||||
{ feature_id: TestFeature.Messages, quantity: 0 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// CASE 4: plan with prepaid messages; sub has base + custom fixed price
|
||||
// under prepaid Messages's stripe_product_id
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("to-sync-params: case 4 — custom fixed price under prepaid Messages stripe product")}`, async () => {
|
||||
const customerId = "to-sync-params-4-prepaid-custom-fixed";
|
||||
const pro = products.pro({
|
||||
id: "pro",
|
||||
items: [items.prepaidMessages({ price: 10, billingUnits: 100 })],
|
||||
});
|
||||
|
||||
await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [pro] }),
|
||||
],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
const fullProduct = await fetchFullProduct({ ctx, productId: pro.id });
|
||||
const baseStripePriceId = getBaseStripePriceId({ fullProduct });
|
||||
const prepaidStripeProductId = getPrepaidStripeProductId({ fullProduct });
|
||||
const stripeCustomerId = await getStripeCustomerId({ ctx, customerId });
|
||||
|
||||
// Custom fixed Stripe price under the prepaid Messages product
|
||||
const customPrepaidPrice = await createStripeFixedPriceUnderProduct({
|
||||
ctx,
|
||||
stripeProductId: prepaidStripeProductId,
|
||||
unitAmount: 1500, // $15/mo (vs. Autumn's $10 default)
|
||||
});
|
||||
|
||||
const subscription = await ctx.stripeCli.subscriptions.create({
|
||||
customer: stripeCustomerId,
|
||||
items: [{ price: baseStripePriceId }, { price: customPrepaidPrice.id }],
|
||||
});
|
||||
|
||||
const { match, params } = await subscriptionToSyncParams({
|
||||
ctx,
|
||||
customerId,
|
||||
subscription,
|
||||
});
|
||||
|
||||
expectSyncParamsCorrect({
|
||||
params,
|
||||
customer_id: customerId,
|
||||
stripe_subscription_id: subscription.id,
|
||||
phases: [
|
||||
{
|
||||
starts_at: "now",
|
||||
plans: [
|
||||
{
|
||||
plan_id: pro.id,
|
||||
feature_quantities: [
|
||||
{
|
||||
feature_id: TestFeature.Messages,
|
||||
stripe_price_id: customPrepaidPrice.id,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expectSubscriptionMatchCorrect({
|
||||
match,
|
||||
currentPhase: {
|
||||
plans: [{ plan_id: pro.id }],
|
||||
noUnmatchedItems: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// CASE 5: plan with prepaid messages; sub has base + tiered Stripe price
|
||||
// under prepaid Messages's stripe_product_id
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("to-sync-params: case 5 — tiered price under prepaid Messages stripe product")}`, async () => {
|
||||
const customerId = "to-sync-params-5-prepaid-tiered";
|
||||
const pro = products.pro({
|
||||
id: "pro",
|
||||
items: [items.prepaidMessages({ price: 10, billingUnits: 100 })],
|
||||
});
|
||||
|
||||
await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [pro] }),
|
||||
],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
const fullProduct = await fetchFullProduct({ ctx, productId: pro.id });
|
||||
const baseStripePriceId = getBaseStripePriceId({ fullProduct });
|
||||
const prepaidStripeProductId = getPrepaidStripeProductId({ fullProduct });
|
||||
const stripeCustomerId = await getStripeCustomerId({ ctx, customerId });
|
||||
|
||||
// Custom tiered Stripe price under the prepaid Messages product
|
||||
const customTieredPrice = await createStripeTieredPriceUnderProduct({
|
||||
ctx,
|
||||
stripeProductId: prepaidStripeProductId,
|
||||
tiersMode: "graduated",
|
||||
tiers: [
|
||||
{ up_to: 1000, unit_amount: 5 },
|
||||
{ up_to: "inf", unit_amount: 2 },
|
||||
],
|
||||
});
|
||||
|
||||
const subscription = await ctx.stripeCli.subscriptions.create({
|
||||
customer: stripeCustomerId,
|
||||
items: [{ price: baseStripePriceId }, { price: customTieredPrice.id }],
|
||||
});
|
||||
|
||||
const { match, params } = await subscriptionToSyncParams({
|
||||
ctx,
|
||||
customerId,
|
||||
subscription,
|
||||
});
|
||||
|
||||
expectSyncParamsCorrect({
|
||||
params,
|
||||
customer_id: customerId,
|
||||
stripe_subscription_id: subscription.id,
|
||||
phases: [
|
||||
{
|
||||
starts_at: "now",
|
||||
plans: [
|
||||
{
|
||||
plan_id: pro.id,
|
||||
feature_quantities: [
|
||||
{
|
||||
feature_id: TestFeature.Messages,
|
||||
stripe_price_id: customTieredPrice.id,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expectSubscriptionMatchCorrect({
|
||||
match,
|
||||
currentPhase: {
|
||||
plans: [{ plan_id: pro.id }],
|
||||
noUnmatchedItems: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// CASE 6: Pro + add-on; add-on Stripe item has quantity 2
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("to-sync-params: case 6 — add-on with stripe quantity 2")}`, async () => {
|
||||
const customerId = "to-sync-params-6-addon-quantity";
|
||||
const pro = products.pro({ id: "pro", items: [] });
|
||||
const addOn = products.recurringAddOn({ id: "addon", items: [] });
|
||||
|
||||
await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [pro, addOn] }),
|
||||
],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
const proFull = await fetchFullProduct({ ctx, productId: pro.id });
|
||||
const addOnFull = await fetchFullProduct({ ctx, productId: addOn.id });
|
||||
const proStripePriceId = getBaseStripePriceId({ fullProduct: proFull });
|
||||
const addOnStripePriceId = getBaseStripePriceId({
|
||||
fullProduct: addOnFull,
|
||||
});
|
||||
const stripeCustomerId = await getStripeCustomerId({ ctx, customerId });
|
||||
|
||||
// Subscribe to Pro (qty 1) + the add-on at quantity 2.
|
||||
const subscription = await ctx.stripeCli.subscriptions.create({
|
||||
customer: stripeCustomerId,
|
||||
items: [
|
||||
{ price: proStripePriceId },
|
||||
{ price: addOnStripePriceId, quantity: 2 },
|
||||
],
|
||||
});
|
||||
|
||||
const { match, params } = await subscriptionToSyncParams({
|
||||
ctx,
|
||||
customerId,
|
||||
subscription,
|
||||
});
|
||||
|
||||
console.log("Params:", JSON.stringify(params, null, 2));
|
||||
|
||||
expectSyncParamsCorrect({
|
||||
params,
|
||||
customer_id: customerId,
|
||||
stripe_subscription_id: subscription.id,
|
||||
phases: [
|
||||
{
|
||||
starts_at: "now",
|
||||
plans: [
|
||||
{ plan_id: pro.id, quantity: 1 },
|
||||
{ plan_id: addOn.id, quantity: 2 },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expectSubscriptionMatchCorrect({
|
||||
match,
|
||||
currentPhase: {
|
||||
plans: [
|
||||
{ plan_id: pro.id, base_kind: "matched" },
|
||||
{ plan_id: addOn.id, base_kind: "matched" },
|
||||
],
|
||||
noUnmatchedItems: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,323 @@
|
||||
/**
|
||||
* subscriptionToSyncParams — schedule cases
|
||||
*
|
||||
* Exercises detection against Stripe subscription schedules where the live
|
||||
* subscription (phase 0) plus future phases get folded into a multi-phase
|
||||
* `SyncParamsV1`. Each test:
|
||||
* 1. Sets up Autumn customer + product(s)
|
||||
* 2. Creates a Stripe subscription schedule with multiple phases
|
||||
* 3. Calls `subscriptionToSyncParams({ subscription, schedule })`
|
||||
* 4. Asserts on `params.phases` + match-side `phaseMatches`
|
||||
*
|
||||
* Cases:
|
||||
* A) One product (Pro), 3 phases, base price doubles each phase. Phase 0
|
||||
* uses Pro's catalog Stripe price (matched), phases 1+2 use custom
|
||||
* Stripe prices under Pro's stripe_product_id.
|
||||
*
|
||||
* B) Two products. Phase 0 uses Pro's catalog price; phase 1 uses
|
||||
* Premium's catalog price.
|
||||
*
|
||||
* C) Two products. Phase 0 uses Pro's catalog price; phase 1 uses a
|
||||
* CUSTOM Stripe price under Premium's stripe_product_id.
|
||||
*/
|
||||
|
||||
import { test } from "bun:test";
|
||||
import { expectSubscriptionMatchCorrect } from "@tests/integration/billing/utils/sync/expectSubscriptionMatch";
|
||||
import { expectSyncParamsCorrect } from "@tests/integration/billing/utils/sync/expectSyncParams";
|
||||
import { products } from "@tests/utils/fixtures/products";
|
||||
import ctx from "@tests/utils/testInitUtils/createTestContext";
|
||||
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
|
||||
import chalk from "chalk";
|
||||
import { subscriptionToSyncParams } from "@/internal/billing/v2/actions/sync/subscriptionToSyncParams";
|
||||
import {
|
||||
createStripeFixedPriceUnderProduct,
|
||||
createStripeSubscriptionSchedule,
|
||||
fetchFullProduct,
|
||||
getBaseStripePriceId,
|
||||
getProductStripeProductId,
|
||||
} from "../utils/syncProductHelpers";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// CASE A: Pro, 3 phases, base price doubles each phase ($20 → $40 → $80)
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("to-sync-params: schedule A — Pro, 3 phases doubling base")}`, async () => {
|
||||
const customerId = "to-sync-params-sched-a";
|
||||
const pro = products.pro({ id: "pro", items: [] });
|
||||
|
||||
await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [pro] }),
|
||||
],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
const proFull = await fetchFullProduct({ ctx, productId: pro.id });
|
||||
const proStandardPriceId = getBaseStripePriceId({ fullProduct: proFull });
|
||||
const proStripeProductId = getProductStripeProductId({
|
||||
fullProduct: proFull,
|
||||
});
|
||||
|
||||
// Custom Stripe prices under Pro's stripe product for phases 1 and 2
|
||||
const phase1Price = await createStripeFixedPriceUnderProduct({
|
||||
ctx,
|
||||
stripeProductId: proStripeProductId,
|
||||
unitAmount: 4000, // $40
|
||||
});
|
||||
const phase2Price = await createStripeFixedPriceUnderProduct({
|
||||
ctx,
|
||||
stripeProductId: proStripeProductId,
|
||||
unitAmount: 8000, // $80
|
||||
});
|
||||
|
||||
const { subscription, schedule } = await createStripeSubscriptionSchedule({
|
||||
ctx,
|
||||
customerId,
|
||||
phases: [
|
||||
{ items: [{ price: proStandardPriceId }] },
|
||||
{ items: [{ price: phase1Price.id }] },
|
||||
{ items: [{ price: phase2Price.id }] },
|
||||
],
|
||||
});
|
||||
|
||||
const { match, params } = await subscriptionToSyncParams({
|
||||
ctx,
|
||||
customerId,
|
||||
subscription,
|
||||
schedule,
|
||||
});
|
||||
|
||||
expectSyncParamsCorrect({
|
||||
params,
|
||||
customer_id: customerId,
|
||||
stripe_subscription_id: subscription.id,
|
||||
stripe_schedule_id: schedule.id,
|
||||
phases: [
|
||||
{
|
||||
starts_at: "now",
|
||||
plans: [{ plan_id: pro.id, customize: null }],
|
||||
},
|
||||
{
|
||||
plans: [
|
||||
{
|
||||
plan_id: pro.id,
|
||||
customize: {
|
||||
price: {
|
||||
amount: 40,
|
||||
interval: "month",
|
||||
stripe_price_id: phase1Price.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
plans: [
|
||||
{
|
||||
plan_id: pro.id,
|
||||
customize: {
|
||||
price: {
|
||||
amount: 80,
|
||||
interval: "month",
|
||||
stripe_price_id: phase2Price.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expectSubscriptionMatchCorrect({
|
||||
match,
|
||||
phaseMatches: [
|
||||
{
|
||||
is_current: true,
|
||||
plans: [{ plan_id: pro.id, base_kind: "matched" }],
|
||||
noUnmatchedItems: true,
|
||||
},
|
||||
{
|
||||
is_current: false,
|
||||
plans: [{ plan_id: pro.id, base_kind: "custom" }],
|
||||
noUnmatchedItems: true,
|
||||
},
|
||||
{
|
||||
is_current: false,
|
||||
plans: [{ plan_id: pro.id, base_kind: "custom" }],
|
||||
noUnmatchedItems: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// CASE B: schedule with phase 0 = Pro standard, phase 1 = Premium standard
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("to-sync-params: schedule B — Pro then Premium, both standard prices")}`, async () => {
|
||||
const customerId = "to-sync-params-sched-b";
|
||||
const pro = products.pro({ id: "pro", items: [] });
|
||||
const premium = products.premium({ id: "premium", items: [] });
|
||||
|
||||
await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [pro, premium] }),
|
||||
],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
const proFull = await fetchFullProduct({ ctx, productId: pro.id });
|
||||
const premiumFull = await fetchFullProduct({
|
||||
ctx,
|
||||
productId: premium.id,
|
||||
});
|
||||
const proPriceId = getBaseStripePriceId({ fullProduct: proFull });
|
||||
const premiumPriceId = getBaseStripePriceId({ fullProduct: premiumFull });
|
||||
|
||||
const { subscription, schedule } = await createStripeSubscriptionSchedule({
|
||||
ctx,
|
||||
customerId,
|
||||
phases: [
|
||||
{ items: [{ price: proPriceId }] },
|
||||
{ items: [{ price: premiumPriceId }] },
|
||||
],
|
||||
});
|
||||
|
||||
const { match, params } = await subscriptionToSyncParams({
|
||||
ctx,
|
||||
customerId,
|
||||
subscription,
|
||||
schedule,
|
||||
});
|
||||
|
||||
expectSyncParamsCorrect({
|
||||
params,
|
||||
customer_id: customerId,
|
||||
stripe_subscription_id: subscription.id,
|
||||
stripe_schedule_id: schedule.id,
|
||||
phases: [
|
||||
{
|
||||
starts_at: "now",
|
||||
plans: [{ plan_id: pro.id, customize: null }],
|
||||
},
|
||||
{
|
||||
plans: [{ plan_id: premium.id, customize: null }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expectSubscriptionMatchCorrect({
|
||||
match,
|
||||
phaseMatches: [
|
||||
{
|
||||
is_current: true,
|
||||
plans: [{ plan_id: pro.id, base_kind: "matched" }],
|
||||
noUnmatchedItems: true,
|
||||
},
|
||||
{
|
||||
is_current: false,
|
||||
plans: [{ plan_id: premium.id, base_kind: "matched" }],
|
||||
noUnmatchedItems: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// CASE C: phase 0 = Pro standard, phase 1 = custom price under Premium stripe product
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("to-sync-params: schedule C — Pro then custom-base under Premium")}`, async () => {
|
||||
const customerId = "to-sync-params-sched-c";
|
||||
const pro = products.pro({ id: "pro", items: [] });
|
||||
const premium = products.premium({ id: "premium", items: [] });
|
||||
|
||||
await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [pro, premium] }),
|
||||
],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
const proFull = await fetchFullProduct({ ctx, productId: pro.id });
|
||||
const premiumFull = await fetchFullProduct({
|
||||
ctx,
|
||||
productId: premium.id,
|
||||
});
|
||||
const proPriceId = getBaseStripePriceId({ fullProduct: proFull });
|
||||
const premiumStripeProductId = getProductStripeProductId({
|
||||
fullProduct: premiumFull,
|
||||
});
|
||||
|
||||
// Custom Stripe price under Premium's stripe product (priority-3 match)
|
||||
const customPremiumPrice = await createStripeFixedPriceUnderProduct({
|
||||
ctx,
|
||||
stripeProductId: premiumStripeProductId,
|
||||
unitAmount: 12000, // $120 — distinct from premium's catalog price
|
||||
});
|
||||
|
||||
const { subscription, schedule } = await createStripeSubscriptionSchedule({
|
||||
ctx,
|
||||
customerId,
|
||||
phases: [
|
||||
{ items: [{ price: proPriceId }] },
|
||||
{ items: [{ price: customPremiumPrice.id }] },
|
||||
],
|
||||
});
|
||||
|
||||
const { match, params } = await subscriptionToSyncParams({
|
||||
ctx,
|
||||
customerId,
|
||||
subscription,
|
||||
schedule,
|
||||
});
|
||||
|
||||
expectSyncParamsCorrect({
|
||||
params,
|
||||
customer_id: customerId,
|
||||
stripe_subscription_id: subscription.id,
|
||||
stripe_schedule_id: schedule.id,
|
||||
phases: [
|
||||
{
|
||||
starts_at: "now",
|
||||
plans: [{ plan_id: pro.id, customize: null }],
|
||||
},
|
||||
{
|
||||
plans: [
|
||||
{
|
||||
plan_id: premium.id,
|
||||
customize: {
|
||||
price: {
|
||||
amount: 120,
|
||||
interval: "month",
|
||||
stripe_price_id: customPremiumPrice.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expectSubscriptionMatchCorrect({
|
||||
match,
|
||||
phaseMatches: [
|
||||
{
|
||||
is_current: true,
|
||||
plans: [{ plan_id: pro.id, base_kind: "matched" }],
|
||||
noUnmatchedItems: true,
|
||||
},
|
||||
{
|
||||
is_current: false,
|
||||
plans: [{ plan_id: premium.id, base_kind: "custom" }],
|
||||
noUnmatchedItems: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
import { type FullProduct, isFixedPrice, isPrepaidPrice } from "@autumn/shared";
|
||||
import type { TestContext } from "@tests/utils/testInitUtils/createTestContext";
|
||||
import type Stripe from "stripe";
|
||||
import { CusService } from "@/internal/customers/CusService";
|
||||
import { ProductService } from "@/internal/products/ProductService";
|
||||
|
||||
export const fetchFullProduct = async ({
|
||||
ctx,
|
||||
productId,
|
||||
}: {
|
||||
ctx: TestContext;
|
||||
productId: string;
|
||||
}): Promise<FullProduct> =>
|
||||
ProductService.getFull({
|
||||
db: ctx.db,
|
||||
idOrInternalId: productId,
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
});
|
||||
|
||||
export const getStripeCustomerId = async ({
|
||||
ctx,
|
||||
customerId,
|
||||
}: {
|
||||
ctx: TestContext;
|
||||
customerId: string;
|
||||
}): Promise<string> => {
|
||||
const fullCustomer = await CusService.getFull({
|
||||
ctx,
|
||||
idOrInternalId: customerId,
|
||||
});
|
||||
const stripeCustomerId = fullCustomer.processor?.id;
|
||||
if (!stripeCustomerId) {
|
||||
throw new Error(`Customer ${customerId} has no Stripe customer ID`);
|
||||
}
|
||||
return stripeCustomerId;
|
||||
};
|
||||
|
||||
export const getProductStripeProductId = ({
|
||||
fullProduct,
|
||||
}: {
|
||||
fullProduct: FullProduct;
|
||||
}): string => {
|
||||
const id = fullProduct.processor?.id;
|
||||
if (!id) {
|
||||
throw new Error(`Product ${fullProduct.id} has no processor.id`);
|
||||
}
|
||||
return id;
|
||||
};
|
||||
|
||||
export const getBaseStripePriceId = ({
|
||||
fullProduct,
|
||||
}: {
|
||||
fullProduct: FullProduct;
|
||||
}): string => {
|
||||
const fixedPrice = fullProduct.prices.find((p) => isFixedPrice(p));
|
||||
if (!fixedPrice) {
|
||||
throw new Error(`Product ${fullProduct.id} has no fixed (base) price`);
|
||||
}
|
||||
const id = fixedPrice.config.stripe_price_id;
|
||||
if (!id) {
|
||||
throw new Error(`Base price on ${fullProduct.id} has no stripe_price_id`);
|
||||
}
|
||||
return id;
|
||||
};
|
||||
|
||||
/** Returns the Stripe product id used by the prepaid usage price. */
|
||||
export const getPrepaidStripeProductId = ({
|
||||
fullProduct,
|
||||
}: {
|
||||
fullProduct: FullProduct;
|
||||
}): string => {
|
||||
const prepaidPrice = fullProduct.prices.find((p) => isPrepaidPrice(p));
|
||||
if (!prepaidPrice) {
|
||||
throw new Error(`Product ${fullProduct.id} has no prepaid price`);
|
||||
}
|
||||
const id = prepaidPrice.config.stripe_product_id;
|
||||
if (!id) {
|
||||
throw new Error(
|
||||
`Prepaid price on ${fullProduct.id} has no stripe_product_id`,
|
||||
);
|
||||
}
|
||||
return id;
|
||||
};
|
||||
|
||||
export const createStripeFixedPriceUnderProduct = async ({
|
||||
ctx,
|
||||
stripeProductId,
|
||||
unitAmount,
|
||||
currency = "usd",
|
||||
interval = "month",
|
||||
}: {
|
||||
ctx: TestContext;
|
||||
stripeProductId: string;
|
||||
unitAmount: number;
|
||||
currency?: string;
|
||||
interval?: "day" | "week" | "month" | "year";
|
||||
}): Promise<Stripe.Price> =>
|
||||
ctx.stripeCli.prices.create({
|
||||
product: stripeProductId,
|
||||
unit_amount: unitAmount,
|
||||
currency,
|
||||
recurring: { interval },
|
||||
});
|
||||
|
||||
export const createStripeTieredPriceUnderProduct = async ({
|
||||
ctx,
|
||||
stripeProductId,
|
||||
tiers,
|
||||
tiersMode = "graduated",
|
||||
currency = "usd",
|
||||
interval = "month",
|
||||
}: {
|
||||
ctx: TestContext;
|
||||
stripeProductId: string;
|
||||
tiers: Stripe.PriceCreateParams.Tier[];
|
||||
tiersMode?: "graduated" | "volume";
|
||||
currency?: string;
|
||||
interval?: "day" | "week" | "month" | "year";
|
||||
}): Promise<Stripe.Price> =>
|
||||
ctx.stripeCli.prices.create({
|
||||
product: stripeProductId,
|
||||
currency,
|
||||
recurring: { interval, usage_type: "metered" },
|
||||
billing_scheme: "tiered",
|
||||
tiers_mode: tiersMode,
|
||||
tiers,
|
||||
});
|
||||
|
||||
/**
|
||||
* Create a Stripe subscription schedule with the supplied phases and return
|
||||
* both the live subscription (running phase 0) and the schedule object.
|
||||
* Each phase iterates once over its billing interval; the schedule releases
|
||||
* after the final phase.
|
||||
*/
|
||||
export const createStripeSubscriptionSchedule = async ({
|
||||
ctx,
|
||||
customerId,
|
||||
phases,
|
||||
}: {
|
||||
ctx: TestContext;
|
||||
customerId: string;
|
||||
phases: {
|
||||
items: { price: string; quantity?: number }[];
|
||||
iterations?: number;
|
||||
}[];
|
||||
}): Promise<{
|
||||
subscription: Stripe.Subscription;
|
||||
schedule: Stripe.SubscriptionSchedule;
|
||||
}> => {
|
||||
const stripeCustomerId = await getStripeCustomerId({ ctx, customerId });
|
||||
|
||||
const created = await ctx.stripeCli.subscriptionSchedules.create({
|
||||
customer: stripeCustomerId,
|
||||
start_date: "now",
|
||||
end_behavior: "release",
|
||||
phases: phases.map((phase) => ({
|
||||
items: phase.items,
|
||||
duration: { interval: "month", interval_count: phase.iterations ?? 1 },
|
||||
})),
|
||||
});
|
||||
|
||||
const subscriptionId =
|
||||
typeof created.subscription === "string"
|
||||
? created.subscription
|
||||
: created.subscription?.id;
|
||||
if (!subscriptionId) {
|
||||
throw new Error(
|
||||
`subscriptionSchedules.create did not return a subscription id (schedule ${created.id})`,
|
||||
);
|
||||
}
|
||||
|
||||
const [subscription, schedule] = await Promise.all([
|
||||
ctx.stripeCli.subscriptions.retrieve(subscriptionId),
|
||||
ctx.stripeCli.subscriptionSchedules.retrieve(created.id, {
|
||||
expand: ["phases.items.price"],
|
||||
}),
|
||||
]);
|
||||
|
||||
return { subscription, schedule };
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
import { expect } from "bun:test";
|
||||
import type {
|
||||
PlanBase,
|
||||
SubscriptionMatch,
|
||||
} from "@/internal/billing/v2/actions/sync/detect/types";
|
||||
|
||||
export type ExpectedMatchedPlan = {
|
||||
plan_id?: string;
|
||||
base_kind?: PlanBase["kind"];
|
||||
};
|
||||
|
||||
export type ExpectedPhaseMatch = {
|
||||
plans: ExpectedMatchedPlan[];
|
||||
is_current?: boolean;
|
||||
/** When true, asserts no Stripe items in this phase are unmatched. */
|
||||
noUnmatchedItems?: boolean;
|
||||
};
|
||||
|
||||
export type ExpectedCurrentPhase = {
|
||||
plans: ExpectedMatchedPlan[];
|
||||
/** When set, asserts no Stripe items in the current phase are unmatched. */
|
||||
noUnmatchedItems?: boolean;
|
||||
};
|
||||
|
||||
const expectPhaseMatchCorrect = ({
|
||||
phase,
|
||||
expected,
|
||||
}: {
|
||||
phase: SubscriptionMatch["phaseMatches"][number];
|
||||
expected: ExpectedPhaseMatch;
|
||||
}) => {
|
||||
if (expected.is_current !== undefined) {
|
||||
expect(phase.is_current).toBe(expected.is_current);
|
||||
}
|
||||
|
||||
expect(phase.plans).toHaveLength(expected.plans.length);
|
||||
|
||||
expected.plans.forEach((expectedPlan, planIndex) => {
|
||||
const plan = phase.plans[planIndex];
|
||||
if (expectedPlan.plan_id !== undefined) {
|
||||
expect(plan.product.id).toBe(expectedPlan.plan_id);
|
||||
}
|
||||
if (expectedPlan.base_kind !== undefined) {
|
||||
expect(plan.base.kind).toBe(expectedPlan.base_kind);
|
||||
}
|
||||
});
|
||||
|
||||
if (expected.noUnmatchedItems) {
|
||||
const unmatched = phase.item_diffs.filter((d) => d.match.kind === "none");
|
||||
expect(unmatched).toEqual([]);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Match-side counterpart to `expectSyncParamsCorrect` — asserts on the
|
||||
* detection result (`SubscriptionMatch`).
|
||||
*
|
||||
* Pass `currentPhase` to assert against the current phase only. Pass
|
||||
* `phaseMatches` to assert against every phase by index (the order matches
|
||||
* `match.phaseMatches`).
|
||||
*/
|
||||
export const expectSubscriptionMatchCorrect = ({
|
||||
match,
|
||||
currentPhase,
|
||||
phaseMatches,
|
||||
}: {
|
||||
match: SubscriptionMatch;
|
||||
currentPhase?: ExpectedCurrentPhase;
|
||||
phaseMatches?: ExpectedPhaseMatch[];
|
||||
}) => {
|
||||
if (currentPhase) {
|
||||
const phase = match.phaseMatches.find((p) => p.is_current);
|
||||
expect(phase).toBeDefined();
|
||||
expectPhaseMatchCorrect({
|
||||
phase: phase!,
|
||||
expected: {
|
||||
plans: currentPhase.plans,
|
||||
noUnmatchedItems: currentPhase.noUnmatchedItems,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (phaseMatches) {
|
||||
expect(match.phaseMatches).toHaveLength(phaseMatches.length);
|
||||
phaseMatches.forEach((expected, i) => {
|
||||
expectPhaseMatchCorrect({ phase: match.phaseMatches[i], expected });
|
||||
});
|
||||
}
|
||||
};
|
||||
128
server/tests/integration/billing/utils/sync/expectSyncParams.ts
Normal file
128
server/tests/integration/billing/utils/sync/expectSyncParams.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { expect } from "bun:test";
|
||||
import type { SyncParamsV1 } from "@autumn/shared";
|
||||
|
||||
export type ExpectedPlan = {
|
||||
plan_id: string;
|
||||
quantity?: number;
|
||||
internal_entity_id?: string;
|
||||
expire_previous?: boolean;
|
||||
customize?:
|
||||
| null
|
||||
| {
|
||||
price?:
|
||||
| null
|
||||
| {
|
||||
amount?: number;
|
||||
interval?: string;
|
||||
interval_count?: number;
|
||||
stripe_price_id?: string;
|
||||
};
|
||||
};
|
||||
feature_quantities?: {
|
||||
feature_id: string;
|
||||
quantity?: number;
|
||||
stripe_price_id?: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
export type ExpectedPhase = {
|
||||
/** Skip the assertion when undefined (useful for future-dated phases where the timestamp is dynamic). */
|
||||
starts_at?: number | "now";
|
||||
plans: ExpectedPlan[];
|
||||
};
|
||||
|
||||
const expectPlanCorrect = ({
|
||||
plan,
|
||||
expected,
|
||||
}: {
|
||||
plan: NonNullable<SyncParamsV1["phases"]>[number]["plans"][number];
|
||||
expected: ExpectedPlan;
|
||||
}) => {
|
||||
expect(plan.plan_id).toBe(expected.plan_id);
|
||||
|
||||
if (expected.quantity !== undefined) {
|
||||
expect(plan.quantity ?? 1).toBe(expected.quantity);
|
||||
}
|
||||
if (expected.internal_entity_id !== undefined) {
|
||||
expect(plan.internal_entity_id).toBe(expected.internal_entity_id);
|
||||
}
|
||||
if (expected.expire_previous !== undefined) {
|
||||
expect(plan.expire_previous).toBe(expected.expire_previous);
|
||||
}
|
||||
|
||||
if (expected.customize === undefined) {
|
||||
// not asserted
|
||||
} else if (expected.customize === null) {
|
||||
expect(plan.customize).toBeUndefined();
|
||||
} else {
|
||||
expect(plan.customize).toBeDefined();
|
||||
if (expected.customize.price === null) {
|
||||
expect(plan.customize?.price).toBeNull();
|
||||
} else if (expected.customize.price !== undefined) {
|
||||
expect(plan.customize?.price).toMatchObject(expected.customize.price);
|
||||
}
|
||||
}
|
||||
|
||||
if (expected.feature_quantities !== undefined) {
|
||||
expect(plan.feature_quantities).toBeDefined();
|
||||
for (const expectedFq of expected.feature_quantities) {
|
||||
const actual = plan.feature_quantities?.find(
|
||||
(fq) => fq.feature_id === expectedFq.feature_id,
|
||||
);
|
||||
expect(actual).toBeDefined();
|
||||
if (expectedFq.quantity !== undefined) {
|
||||
expect(actual?.quantity ?? 0).toBe(expectedFq.quantity);
|
||||
}
|
||||
if (expectedFq.stripe_price_id !== undefined) {
|
||||
expect(actual?.stripe_price_id).toBe(expectedFq.stripe_price_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Verify a `SyncParamsV1` matches expected identity + phases + plans shape.
|
||||
*
|
||||
* Each `expected.phases[i]` asserts `starts_at` and per-plan fields. Fields
|
||||
* that are `undefined` on the expected object are skipped. Pass
|
||||
* `customize: null` to assert no customize was set.
|
||||
*/
|
||||
export const expectSyncParamsCorrect = ({
|
||||
params,
|
||||
customer_id,
|
||||
stripe_subscription_id,
|
||||
stripe_schedule_id,
|
||||
phases,
|
||||
}: {
|
||||
params: SyncParamsV1;
|
||||
customer_id: string;
|
||||
stripe_subscription_id?: string;
|
||||
stripe_schedule_id?: string;
|
||||
phases: ExpectedPhase[];
|
||||
}) => {
|
||||
expect(params.customer_id).toBe(customer_id);
|
||||
|
||||
if (stripe_subscription_id !== undefined) {
|
||||
expect(params.stripe_subscription_id).toBe(stripe_subscription_id);
|
||||
}
|
||||
if (stripe_schedule_id !== undefined) {
|
||||
expect(params.stripe_schedule_id).toBe(stripe_schedule_id);
|
||||
}
|
||||
|
||||
expect(params.phases).toBeDefined();
|
||||
expect(params.phases).toHaveLength(phases.length);
|
||||
|
||||
phases.forEach((expectedPhase, phaseIndex) => {
|
||||
const phase = params.phases![phaseIndex];
|
||||
if (expectedPhase.starts_at !== undefined) {
|
||||
expect(phase.starts_at).toBe(expectedPhase.starts_at);
|
||||
}
|
||||
expect(phase.plans).toHaveLength(expectedPhase.plans.length);
|
||||
expectedPhase.plans.forEach((expectedPlan, planIndex) => {
|
||||
expectPlanCorrect({
|
||||
plan: phase.plans[planIndex],
|
||||
expected: expectedPlan,
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"id": "get-cus-multi-ent-v2",
|
||||
"created_at": 1776681093029,
|
||||
"created_at": 1777974355227,
|
||||
"name": "get-cus-multi-ent-v2",
|
||||
"email": "get-cus-multi-ent-v2@example.com",
|
||||
"fingerprint": null,
|
||||
"stripe_id": "cus_UMz5mvPwIP5PS3",
|
||||
"stripe_id": "cus_USajNwvM0pax2M",
|
||||
"env": "sandbox",
|
||||
"metadata": {},
|
||||
"send_email_receipts": false,
|
||||
@@ -15,13 +15,25 @@
|
||||
"group": "get-cus-multi-ent-v2",
|
||||
"status": "active",
|
||||
"canceled_at": null,
|
||||
"started_at": 1776681092000,
|
||||
"started_at": 1777974354000,
|
||||
"is_default": false,
|
||||
"is_add_on": false,
|
||||
"version": 1,
|
||||
"current_period_start": 1776681092000,
|
||||
"current_period_end": 1779273092000,
|
||||
"current_period_start": 1777974354000,
|
||||
"current_period_end": 1780652754000,
|
||||
"items": [
|
||||
{
|
||||
"type": "price",
|
||||
"feature_id": null,
|
||||
"feature": null,
|
||||
"interval": "month",
|
||||
"interval_count": 1,
|
||||
"price": 20,
|
||||
"display": {
|
||||
"primary_text": "$20",
|
||||
"secondary_text": "per month"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "feature",
|
||||
"feature_id": "dashboard",
|
||||
@@ -71,7 +83,7 @@
|
||||
"group": "get-cus-multi-ent-v2",
|
||||
"status": "active",
|
||||
"canceled_at": null,
|
||||
"started_at": 1776681113920,
|
||||
"started_at": 1777974379436,
|
||||
"is_default": false,
|
||||
"is_add_on": false,
|
||||
"version": 1,
|
||||
@@ -92,7 +104,7 @@
|
||||
"balance": 90,
|
||||
"usage": 10,
|
||||
"included_usage": 100,
|
||||
"next_reset_at": 1779273092000,
|
||||
"next_reset_at": 1780652754000,
|
||||
"overage_allowed": false,
|
||||
"breakdown": [
|
||||
{
|
||||
@@ -101,7 +113,7 @@
|
||||
"balance": 90,
|
||||
"usage": 10,
|
||||
"included_usage": 100,
|
||||
"next_reset_at": 1779273092000,
|
||||
"next_reset_at": 1780652754000,
|
||||
"expires_at": null,
|
||||
"overage_allowed": false
|
||||
}
|
||||
@@ -161,12 +173,12 @@
|
||||
"product_ids": [
|
||||
"cus-lvl_get-cus-multi-ent-v2"
|
||||
],
|
||||
"stripe_id": "in_1TOF756GVhH8pLcDfKcFgsHu",
|
||||
"stripe_id": "in_1TTfYB6GVhvcrrMKf6xxH63K",
|
||||
"status": "paid",
|
||||
"total": 20,
|
||||
"currency": "usd",
|
||||
"created_at": 1776681092000,
|
||||
"hosted_invoice_url": "http://localhost:8080/invoices/hosted_invoice_url/inv_3CcNrtgUERTv8MnmpqncpHXIwRk"
|
||||
"created_at": 1777974354000,
|
||||
"hosted_invoice_url": "http://localhost:8080/invoices/hosted_invoice_url/inv_3DIfAkOjiIKZGi9svU7RnJrrSxy"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -2,16 +2,16 @@
|
||||
"id": "get-cus-multi-ent-v2",
|
||||
"name": "get-cus-multi-ent-v2",
|
||||
"email": "get-cus-multi-ent-v2@example.com",
|
||||
"created_at": 1776681093029,
|
||||
"created_at": 1777974355227,
|
||||
"fingerprint": null,
|
||||
"stripe_id": "cus_UMz5mvPwIP5PS3",
|
||||
"stripe_id": "cus_USajNwvM0pax2M",
|
||||
"env": "sandbox",
|
||||
"metadata": {},
|
||||
"send_email_receipts": false,
|
||||
"billing_controls": {},
|
||||
"subscriptions": [
|
||||
{
|
||||
"id": "cus_prod_3CcNrGiJGlmGt6kWD0DmRWURUQw",
|
||||
"id": "cus_prod_3DIfA2BFN0oEtwT1ZjkWrehY58P",
|
||||
"plan_id": "cus-lvl_get-cus-multi-ent-v2",
|
||||
"auto_enable": false,
|
||||
"add_on": false,
|
||||
@@ -20,13 +20,13 @@
|
||||
"canceled_at": null,
|
||||
"expires_at": null,
|
||||
"trial_ends_at": null,
|
||||
"started_at": 1776681092000,
|
||||
"current_period_start": 1776681092000,
|
||||
"current_period_end": 1779273092000,
|
||||
"started_at": 1777974354000,
|
||||
"current_period_start": 1777974354000,
|
||||
"current_period_end": 1780652754000,
|
||||
"quantity": 1
|
||||
},
|
||||
{
|
||||
"id": "cus_prod_3CcNtXszM7noCNIdjl9H6gs8hdj",
|
||||
"id": "cus_prod_3DIfCpfcDc0YS0zEcf1WvNNQvBp",
|
||||
"plan_id": "ent-prod_get-cus-multi-ent-v2",
|
||||
"auto_enable": false,
|
||||
"add_on": false,
|
||||
@@ -35,7 +35,7 @@
|
||||
"canceled_at": null,
|
||||
"expires_at": null,
|
||||
"trial_ends_at": null,
|
||||
"started_at": 1776681113920,
|
||||
"started_at": 1777974379436,
|
||||
"current_period_start": null,
|
||||
"current_period_end": null,
|
||||
"quantity": 1
|
||||
@@ -52,11 +52,11 @@
|
||||
"unlimited": false,
|
||||
"overage_allowed": false,
|
||||
"max_purchase": null,
|
||||
"next_reset_at": 1779273092000,
|
||||
"next_reset_at": 1780652754000,
|
||||
"breakdown": [
|
||||
{
|
||||
"object": "balance_breakdown",
|
||||
"id": "cus_ent_3CcNrCvPuPFhzyhShn1g64uZeJ6",
|
||||
"id": "cus_ent_3DIfA2PIor7pWp92N3fowXUmmue",
|
||||
"plan_id": "cus-lvl_get-cus-multi-ent-v2",
|
||||
"included_grant": 100,
|
||||
"prepaid_grant": 0,
|
||||
@@ -65,7 +65,7 @@
|
||||
"unlimited": false,
|
||||
"reset": {
|
||||
"interval": "month",
|
||||
"resets_at": 1779273092000
|
||||
"resets_at": 1780652754000
|
||||
},
|
||||
"price": null,
|
||||
"expires_at": null,
|
||||
@@ -89,23 +89,24 @@
|
||||
"flags": {
|
||||
"dashboard": {
|
||||
"object": "flag",
|
||||
"id": "cus_ent_3CcNrCFvKwLC3y0XoUiLazztjwy",
|
||||
"id": "cus_ent_3DIfA2SgxKclfL3Px24NWnwwdag",
|
||||
"plan_id": "cus-lvl_get-cus-multi-ent-v2",
|
||||
"expires_at": null,
|
||||
"feature_id": "dashboard"
|
||||
}
|
||||
},
|
||||
"config": {},
|
||||
"invoices": [
|
||||
{
|
||||
"plan_ids": [
|
||||
"cus-lvl_get-cus-multi-ent-v2"
|
||||
],
|
||||
"stripe_id": "in_1TOF756GVhH8pLcDfKcFgsHu",
|
||||
"stripe_id": "in_1TTfYB6GVhvcrrMKf6xxH63K",
|
||||
"status": "paid",
|
||||
"total": 20,
|
||||
"currency": "usd",
|
||||
"created_at": 1776681092000,
|
||||
"hosted_invoice_url": "http://localhost:8080/invoices/hosted_invoice_url/inv_3CcNrtgUERTv8MnmpqncpHXIwRk"
|
||||
"created_at": 1777974354000,
|
||||
"hosted_invoice_url": "http://localhost:8080/invoices/hosted_invoice_url/inv_3DIfAkOjiIKZGi9svU7RnJrrSxy"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -2,16 +2,16 @@
|
||||
"id": "get-cus-multi-ent-v2",
|
||||
"name": "get-cus-multi-ent-v2",
|
||||
"email": "get-cus-multi-ent-v2@example.com",
|
||||
"created_at": 1776681093029,
|
||||
"created_at": 1777974355227,
|
||||
"fingerprint": null,
|
||||
"stripe_id": "cus_UMz5mvPwIP5PS3",
|
||||
"stripe_id": "cus_USajNwvM0pax2M",
|
||||
"env": "sandbox",
|
||||
"metadata": {},
|
||||
"send_email_receipts": false,
|
||||
"billing_controls": {},
|
||||
"subscriptions": [
|
||||
{
|
||||
"id": "cus_prod_3CcNrGiJGlmGt6kWD0DmRWURUQw",
|
||||
"id": "cus_prod_3DIfA2BFN0oEtwT1ZjkWrehY58P",
|
||||
"plan_id": "cus-lvl_get-cus-multi-ent-v2",
|
||||
"auto_enable": false,
|
||||
"add_on": false,
|
||||
@@ -20,13 +20,13 @@
|
||||
"canceled_at": null,
|
||||
"expires_at": null,
|
||||
"trial_ends_at": null,
|
||||
"started_at": 1776681092000,
|
||||
"current_period_start": 1776681092000,
|
||||
"current_period_end": 1779273092000,
|
||||
"started_at": 1777974354000,
|
||||
"current_period_start": 1777974354000,
|
||||
"current_period_end": 1780652754000,
|
||||
"quantity": 1
|
||||
},
|
||||
{
|
||||
"id": "cus_prod_3CcNtXszM7noCNIdjl9H6gs8hdj",
|
||||
"id": "cus_prod_3DIfCpfcDc0YS0zEcf1WvNNQvBp",
|
||||
"plan_id": "ent-prod_get-cus-multi-ent-v2",
|
||||
"auto_enable": false,
|
||||
"add_on": false,
|
||||
@@ -35,7 +35,7 @@
|
||||
"canceled_at": null,
|
||||
"expires_at": null,
|
||||
"trial_ends_at": null,
|
||||
"started_at": 1776681113920,
|
||||
"started_at": 1777974379436,
|
||||
"current_period_start": null,
|
||||
"current_period_end": null,
|
||||
"quantity": 1
|
||||
@@ -52,11 +52,11 @@
|
||||
"unlimited": false,
|
||||
"overage_allowed": false,
|
||||
"max_purchase": null,
|
||||
"next_reset_at": 1779273092000,
|
||||
"next_reset_at": 1780652754000,
|
||||
"breakdown": [
|
||||
{
|
||||
"object": "balance_breakdown",
|
||||
"id": "cus_ent_3CcNrCvPuPFhzyhShn1g64uZeJ6",
|
||||
"id": "cus_ent_3DIfA2PIor7pWp92N3fowXUmmue",
|
||||
"plan_id": "cus-lvl_get-cus-multi-ent-v2",
|
||||
"included_grant": 100,
|
||||
"prepaid_grant": 0,
|
||||
@@ -65,7 +65,7 @@
|
||||
"unlimited": false,
|
||||
"reset": {
|
||||
"interval": "month",
|
||||
"resets_at": 1779273092000
|
||||
"resets_at": 1780652754000
|
||||
},
|
||||
"price": null,
|
||||
"expires_at": null,
|
||||
@@ -89,23 +89,24 @@
|
||||
"flags": {
|
||||
"dashboard": {
|
||||
"object": "flag",
|
||||
"id": "cus_ent_3CcNrCFvKwLC3y0XoUiLazztjwy",
|
||||
"id": "cus_ent_3DIfA2SgxKclfL3Px24NWnwwdag",
|
||||
"plan_id": "cus-lvl_get-cus-multi-ent-v2",
|
||||
"expires_at": null,
|
||||
"feature_id": "dashboard"
|
||||
}
|
||||
},
|
||||
"config": {},
|
||||
"invoices": [
|
||||
{
|
||||
"plan_ids": [
|
||||
"cus-lvl_get-cus-multi-ent-v2"
|
||||
],
|
||||
"stripe_id": "in_1TOF756GVhH8pLcDfKcFgsHu",
|
||||
"stripe_id": "in_1TTfYB6GVhvcrrMKf6xxH63K",
|
||||
"status": "paid",
|
||||
"total": 20,
|
||||
"currency": "usd",
|
||||
"created_at": 1776681092000,
|
||||
"hosted_invoice_url": "http://localhost:8080/invoices/hosted_invoice_url/inv_3CcNrtgUERTv8MnmpqncpHXIwRk"
|
||||
"created_at": 1777974354000,
|
||||
"hosted_invoice_url": "http://localhost:8080/invoices/hosted_invoice_url/inv_3DIfAkOjiIKZGi9svU7RnJrrSxy"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -11,6 +11,11 @@ export const FeatureQuantityParamsV0Schema = z
|
||||
adjustable: z.boolean().optional().meta({
|
||||
description: "Whether the customer can adjust the quantity.",
|
||||
}),
|
||||
stripe_price_id: z.string().optional().meta({
|
||||
description:
|
||||
"Stripe price id this prepaid feature is billed under. Set by sync flows when the Stripe sub references a price different from the catalog default.",
|
||||
internal: true,
|
||||
}),
|
||||
})
|
||||
.meta({
|
||||
title: "FeatureQuantity",
|
||||
|
||||
@@ -13,6 +13,7 @@ export * from "./customLineItem";
|
||||
export * from "./featureQuantity/featureQuantityParamsV0";
|
||||
export * from "./featureQuantity/mappers/featureQuantityParamsToCusProductOptions";
|
||||
export * from "./invoiceModeParams";
|
||||
export * from "./multi/index";
|
||||
export * from "./redirectMode";
|
||||
export * from "./refundLastPayment";
|
||||
export * from "./transitionRules";
|
||||
|
||||
1
shared/api/billing/common/multi/index.ts
Normal file
1
shared/api/billing/common/multi/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./multiPlanInstance";
|
||||
36
shared/api/billing/common/multi/multiPlanInstance.ts
Normal file
36
shared/api/billing/common/multi/multiPlanInstance.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { CustomizePlanV1Schema } from "@api/billing/common/customizePlan/customizePlanV1";
|
||||
import { FeatureQuantityParamsV0Schema } from "@api/billing/common/featureQuantity/featureQuantityParamsV0";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
/**
|
||||
* Per-plan attach intent shared across multi-plan billing actions
|
||||
* (sync, multi-attach, create-schedule, future actions).
|
||||
*
|
||||
* Identifies one Autumn plan to attach plus optional per-plan overrides.
|
||||
* Action-specific fields (entity binding, expire flags, subscription
|
||||
* targeting, etc.) live on extending schemas.
|
||||
*/
|
||||
export const MultiPlanInstanceSchema = z
|
||||
.object({
|
||||
plan_id: z.string().meta({
|
||||
description: "Autumn plan id to attach.",
|
||||
}),
|
||||
version: z.number().optional().meta({
|
||||
description: "Optional explicit plan version.",
|
||||
}),
|
||||
customize: CustomizePlanV1Schema.optional().meta({
|
||||
description:
|
||||
"Override the plan's price, items, or free trial. Wins over anything detection inferred for the same plan.",
|
||||
}),
|
||||
feature_quantities: z.array(FeatureQuantityParamsV0Schema).optional().meta({
|
||||
description:
|
||||
"Prepaid feature quantities to set on the resulting customer product.",
|
||||
}),
|
||||
})
|
||||
.meta({
|
||||
title: "MultiPlanInstance",
|
||||
description:
|
||||
"Per-plan attach intent shared across multi-plan billing actions.",
|
||||
});
|
||||
|
||||
export type MultiPlanInstance = z.infer<typeof MultiPlanInstanceSchema>;
|
||||
@@ -20,9 +20,14 @@ export * from "./openBillingPortal/openBillingPortalResponse";
|
||||
export * from "./setupPayment/setupPaymentParamsV0";
|
||||
export * from "./setupPayment/setupPaymentParamsV1";
|
||||
|
||||
// Restore
|
||||
export * from "./restore/restoreParamsV1";
|
||||
|
||||
// Sync
|
||||
export * from "./sync/syncParamsV0";
|
||||
export * from "./sync/syncParamsV1";
|
||||
export * from "./sync/syncProposalsParamsV0";
|
||||
export * from "./sync/syncProposalsV2";
|
||||
|
||||
// Update Subscription
|
||||
export * from "./updateSubscription/previewUpdateSubscriptionResponse";
|
||||
|
||||
28
shared/api/billing/restore/restoreParamsV1.ts
Normal file
28
shared/api/billing/restore/restoreParamsV1.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const RestoreParamsV1Schema = z.object({
|
||||
customer_id: z.string().meta({
|
||||
description:
|
||||
"Autumn customer whose Stripe state should be restored to match Autumn's customer_products.",
|
||||
}),
|
||||
});
|
||||
|
||||
export type RestoreParamsV1 = z.infer<typeof RestoreParamsV1Schema>;
|
||||
|
||||
export const RestoreSubscriptionResultSchema = z.object({
|
||||
stripe_subscription_id: z.string().nullable(),
|
||||
stripe_schedule_id: z.string().nullable(),
|
||||
sub_action: z.enum(["update", "noop"]),
|
||||
schedule_action: z.enum(["update", "create", "noop"]),
|
||||
});
|
||||
|
||||
export type RestoreSubscriptionResult = z.infer<
|
||||
typeof RestoreSubscriptionResultSchema
|
||||
>;
|
||||
|
||||
export const RestoreResponseSchema = z.object({
|
||||
customer_id: z.string(),
|
||||
restored: z.array(RestoreSubscriptionResultSchema),
|
||||
});
|
||||
|
||||
export type RestoreResponse = z.infer<typeof RestoreResponseSchema>;
|
||||
110
shared/api/billing/sync/syncParamsV1.ts
Normal file
110
shared/api/billing/sync/syncParamsV1.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { MultiPlanInstanceSchema } from "@api/billing/common/multi/multiPlanInstance";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
/**
|
||||
* Per-plan sync intent. Extends the shared `MultiPlanInstance` with
|
||||
* sync-specific overrides.
|
||||
*/
|
||||
export const SyncPlanInstanceSchema = MultiPlanInstanceSchema.extend({
|
||||
quantity: z.number().int().min(1).optional().meta({
|
||||
description:
|
||||
"Number of customer product instances to create from this plan entry. Defaults to 1. Used to express add-ons with quantity > 1.",
|
||||
}),
|
||||
internal_entity_id: z.string().optional().meta({
|
||||
description:
|
||||
"If set, the resulting customer product is bound to this entity.",
|
||||
internal: true,
|
||||
}),
|
||||
expire_previous: z.boolean().optional().meta({
|
||||
description:
|
||||
"If true, expire the customer's existing active customer product of the same plan family at sync time.",
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* One phase of plan instances. Use `starts_at: "now"` for the immediate
|
||||
* phase; numeric `starts_at` for future-dated phases. The first entry of
|
||||
* a SyncParamsV1 `phases` array typically carries `"now"`.
|
||||
*/
|
||||
export const SyncPhaseSchema = z.object({
|
||||
starts_at: z.union([z.number(), z.literal("now")]).meta({
|
||||
description:
|
||||
"Phase start (ms epoch) or the literal 'now' for the immediate phase.",
|
||||
}),
|
||||
plans: z.array(SyncPlanInstanceSchema).min(1).meta({
|
||||
description: "Plans to attach in this phase.",
|
||||
}),
|
||||
});
|
||||
|
||||
const isPhasesOrdered = (
|
||||
phases: z.infer<typeof SyncPhaseSchema>[],
|
||||
): boolean => {
|
||||
let lastNumeric: number | null = null;
|
||||
for (let i = 0; i < phases.length; i++) {
|
||||
const entry = phases[i];
|
||||
if (!entry) continue;
|
||||
// "now" is only allowed as the first entry
|
||||
if (entry.starts_at === "now") {
|
||||
if (i !== 0) return false;
|
||||
continue;
|
||||
}
|
||||
if (lastNumeric !== null && entry.starts_at <= lastNumeric) return false;
|
||||
lastNumeric = entry.starts_at;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Inputs to the sync-to-Autumn action.
|
||||
*
|
||||
* - `phases` omitted → pure auto-sync (run detection, use what it finds).
|
||||
* - `phases` supplied → caller-specified plan instances; layered on top
|
||||
* of detection. First entry with `starts_at: "now"`
|
||||
* is the immediate phase.
|
||||
*
|
||||
* `acknowledge_warnings` whitelists detection warning types the caller
|
||||
* accepts. Empty / omitted = strict (any detection warning fails).
|
||||
*/
|
||||
export const SyncParamsV1Schema = z
|
||||
.object({
|
||||
customer_id: z.string().meta({
|
||||
description: "Autumn customer to sync into.",
|
||||
}),
|
||||
|
||||
stripe_subscription_id: z.string().optional().meta({
|
||||
description: "Stripe subscription to sync from.",
|
||||
}),
|
||||
stripe_schedule_id: z.string().optional().meta({
|
||||
description:
|
||||
"Stripe subscription schedule to sync from (for future-dated schedules with no live subscription yet).",
|
||||
}),
|
||||
|
||||
phases: z.array(SyncPhaseSchema).optional().meta({
|
||||
description:
|
||||
"Caller-supplied plan instances grouped by phase. Omit for pure auto-sync.",
|
||||
}),
|
||||
|
||||
acknowledge_warnings: z.array(z.string()).optional().meta({
|
||||
description:
|
||||
"Detection warning types the caller accepts (e.g. 'extra_items_under_plan').",
|
||||
}),
|
||||
})
|
||||
.refine(
|
||||
(d) =>
|
||||
d.stripe_subscription_id !== undefined ||
|
||||
d.stripe_schedule_id !== undefined,
|
||||
{
|
||||
message:
|
||||
"Either stripe_subscription_id or stripe_schedule_id is required",
|
||||
path: ["stripe_subscription_id"],
|
||||
},
|
||||
)
|
||||
.refine((d) => !d.phases || isPhasesOrdered(d.phases), {
|
||||
message:
|
||||
"phases entries must be ordered: 'now' may only appear as the first entry, and remaining starts_at values must be strictly increasing",
|
||||
path: ["phases"],
|
||||
});
|
||||
|
||||
export type SyncPlanInstance = z.infer<typeof SyncPlanInstanceSchema>;
|
||||
export type SyncPhase = z.infer<typeof SyncPhaseSchema>;
|
||||
export type SyncParamsV1 = z.infer<typeof SyncParamsV1Schema>;
|
||||
54
shared/api/billing/sync/syncProposalsV2.ts
Normal file
54
shared/api/billing/sync/syncProposalsV2.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { z } from "zod/v4";
|
||||
import type Stripe from "stripe";
|
||||
import { SyncPhaseSchema } from "./syncParamsV1";
|
||||
|
||||
export const SyncProposalsV2ParamsSchema = z.object({
|
||||
customer_id: z.string(),
|
||||
});
|
||||
|
||||
export type SyncProposalsV2Params = z.infer<typeof SyncProposalsV2ParamsSchema>;
|
||||
|
||||
/**
|
||||
* One sync proposal — fluid with `SyncParamsV1` so the frontend can edit
|
||||
* the draft and pass it straight to `/billing.sync`.
|
||||
*
|
||||
* `phases` mirrors `SyncParamsV1.phases` exactly; `stripe_subscription_id`
|
||||
* and `stripe_schedule_id` identify the Stripe object the proposal targets.
|
||||
*
|
||||
* Display-only extras are the raw Stripe objects (so the UI can render
|
||||
* subscription items, schedules, etc. without re-fetching) plus the
|
||||
* `already_linked_product_id` summary.
|
||||
*/
|
||||
const BaseSyncProposalV2Schema = z.object({
|
||||
stripe_subscription_id: z.string().optional(),
|
||||
stripe_schedule_id: z.string().optional(),
|
||||
phases: z.array(SyncPhaseSchema),
|
||||
|
||||
/** Raw Stripe objects — type-cast on the consumer side. */
|
||||
stripe_subscription: z.unknown().nullable(),
|
||||
stripe_schedule: z.unknown().nullable(),
|
||||
|
||||
already_linked_product_id: z.string().nullable(),
|
||||
});
|
||||
|
||||
export const SyncProposalV2Schema = BaseSyncProposalV2Schema;
|
||||
|
||||
export type SyncProposalV2 = Omit<
|
||||
z.infer<typeof SyncProposalV2Schema>,
|
||||
"stripe_subscription" | "stripe_schedule"
|
||||
> & {
|
||||
stripe_subscription: Stripe.Subscription | null;
|
||||
stripe_schedule: Stripe.SubscriptionSchedule | null;
|
||||
};
|
||||
|
||||
export const SyncProposalsV2ResponseSchema = z.object({
|
||||
customer_id: z.string(),
|
||||
proposals: z.array(SyncProposalV2Schema),
|
||||
});
|
||||
|
||||
export type SyncProposalsV2Response = Omit<
|
||||
z.infer<typeof SyncProposalsV2ResponseSchema>,
|
||||
"proposals"
|
||||
> & {
|
||||
proposals: SyncProposalV2[];
|
||||
};
|
||||
@@ -31,6 +31,11 @@ export const BasePriceParamsSchema = BasePriceSchema.omit({
|
||||
price_id: z.string().optional().meta({
|
||||
internal: true,
|
||||
}),
|
||||
stripe_price_id: z.string().optional().meta({
|
||||
description:
|
||||
"Stripe price id this base price is billed under. Set by sync flows to capture the actual Stripe price when it differs from the catalog default.",
|
||||
internal: true,
|
||||
}),
|
||||
})
|
||||
.meta({
|
||||
title: "BasePrice",
|
||||
|
||||
@@ -3,4 +3,5 @@ export * from "./billingContext";
|
||||
export * from "./billingContextOverride";
|
||||
export * from "./createScheduleBillingContext";
|
||||
export * from "./multiAttachBillingContext";
|
||||
export * from "./syncBillingContext";
|
||||
export * from "./updateSubscriptionBillingContext";
|
||||
|
||||
45
shared/models/billingModels/context/syncBillingContext.ts
Normal file
45
shared/models/billingModels/context/syncBillingContext.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type Stripe from "stripe";
|
||||
import type { SyncParamsV1 } from "../../../api/billing/sync/syncParamsV1";
|
||||
import type { SyncPlanInstance } from "../../../api/billing/sync/syncParamsV1";
|
||||
import type {
|
||||
FeatureOptions,
|
||||
FullCusProduct,
|
||||
} from "../../cusProductModels/cusProductModels";
|
||||
import type { FullCustomer } from "../../cusModels/fullCusModel";
|
||||
import type { Entitlement } from "../../productModels/entModels/entModels";
|
||||
import type { Price } from "../../productModels/priceModels/priceModels";
|
||||
import type { FullProduct } from "../../productModels/productModels";
|
||||
|
||||
export interface SyncProductContext {
|
||||
plan: SyncPlanInstance;
|
||||
fullProduct: FullProduct;
|
||||
customPrices: Price[];
|
||||
customEntitlements: Entitlement[];
|
||||
featureQuantities: FeatureOptions[];
|
||||
/** Existing active cusProduct in the same product group, if `expire_previous` was set. */
|
||||
currentCustomerProduct?: FullCusProduct;
|
||||
}
|
||||
|
||||
export interface SyncPhaseContext {
|
||||
/** Resolved phase start in ms epoch. `"now"` is materialized to `currentEpochMs`. */
|
||||
startsAt: number;
|
||||
/** Resolved phase end in ms epoch — equals the next phase's `startsAt`, or null for the final phase. */
|
||||
endsAt: number | null;
|
||||
productContexts: SyncProductContext[];
|
||||
}
|
||||
|
||||
export interface SyncBillingContext {
|
||||
customer_id: string;
|
||||
fullCustomer: FullCustomer;
|
||||
|
||||
stripeSubscription: Stripe.Subscription | null;
|
||||
stripeSchedule: Stripe.SubscriptionSchedule | null;
|
||||
|
||||
/** First phase if its `starts_at` was `"now"`, else null. */
|
||||
immediatePhase: SyncPhaseContext | null;
|
||||
/** Remaining phases (or all phases if there is no immediate phase). */
|
||||
futurePhases: SyncPhaseContext[];
|
||||
|
||||
currentEpochMs: number;
|
||||
acknowledgedWarnings: NonNullable<SyncParamsV1["acknowledge_warnings"]>;
|
||||
}
|
||||
@@ -5,7 +5,10 @@ import type {
|
||||
EntitlementWithFeature,
|
||||
} from "../../models/productModels/entModels/entModels.js";
|
||||
import type { Price } from "../../models/productModels/priceModels/priceModels.js";
|
||||
import type { FullProduct } from "../../models/productModels/productModels.js";
|
||||
import type {
|
||||
FullProduct,
|
||||
Product,
|
||||
} from "../../models/productModels/productModels.js";
|
||||
|
||||
export const entToPrice = ({
|
||||
ent,
|
||||
@@ -69,6 +72,12 @@ export const entToOptions = ({
|
||||
);
|
||||
};
|
||||
|
||||
export const productToStripeId = ({
|
||||
product,
|
||||
}: {
|
||||
product: Product | FullProduct;
|
||||
}): string | null => product.processor?.id ?? null;
|
||||
|
||||
export const productToEnt = ({
|
||||
product,
|
||||
featureId,
|
||||
|
||||
@@ -57,6 +57,10 @@ export function AttachPlanSection({
|
||||
onEditPlan: handleEditPlan,
|
||||
gateDeletedItemsByDiff: true,
|
||||
readOnly: hideEditButton,
|
||||
adminIds: {
|
||||
stripe_product_id: product.stripe_id ?? null,
|
||||
internal_product_id: product.internal_id ?? null,
|
||||
},
|
||||
} as const;
|
||||
|
||||
const titleContent = readOnly ? (
|
||||
|
||||
@@ -58,6 +58,10 @@ export interface PlanItemsSectionProps {
|
||||
|
||||
gateDeletedItemsByDiff?: boolean;
|
||||
readOnly?: boolean;
|
||||
|
||||
adminIds?: import(
|
||||
"@/components/forms/shared/admin/AdminPlanIdsTooltip"
|
||||
).AdminPlanIds;
|
||||
}
|
||||
|
||||
export function PlanItemsSection({
|
||||
@@ -76,6 +80,7 @@ export function PlanItemsSection({
|
||||
trialConfig,
|
||||
gateDeletedItemsByDiff = false,
|
||||
readOnly = false,
|
||||
adminIds,
|
||||
}: PlanItemsSectionProps) {
|
||||
const originalItemsMap = new Map<string, ProductItem>(
|
||||
originalItems
|
||||
@@ -138,6 +143,7 @@ export function PlanItemsSection({
|
||||
priceChange={priceChange}
|
||||
product={product}
|
||||
currency={currency}
|
||||
adminIds={adminIds}
|
||||
/>
|
||||
<LayoutGroup>
|
||||
<motion.div
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/v2/tooltips/Tooltip";
|
||||
import { useAdmin } from "@/views/admin/hooks/useAdmin";
|
||||
|
||||
export type AdminPlanIds = {
|
||||
stripe_price_id?: string | null;
|
||||
stripe_product_id?: string | null;
|
||||
internal_product_id?: string | null;
|
||||
};
|
||||
|
||||
const Row = ({ label, value }: { label: string; value: string | null | undefined }) => {
|
||||
if (!value) return null;
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-[10px] uppercase tracking-wide text-t4">
|
||||
{label}
|
||||
</span>
|
||||
<code className="text-xs font-mono text-t1 break-all">{value}</code>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Wraps a child with an admin-only hover tooltip showing identifying IDs
|
||||
* for the displayed plan/price. No-op for non-admin users.
|
||||
*/
|
||||
export const AdminPlanIdsTooltip = ({
|
||||
children,
|
||||
ids,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
ids: AdminPlanIds;
|
||||
}) => {
|
||||
const { isAdmin } = useAdmin();
|
||||
|
||||
const hasAnyId = Boolean(
|
||||
ids.stripe_price_id || ids.stripe_product_id || ids.internal_product_id,
|
||||
);
|
||||
|
||||
if (!isAdmin || !hasAnyId) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="bottom"
|
||||
align="start"
|
||||
className="flex flex-col gap-2 max-w-sm"
|
||||
>
|
||||
<Row label="Stripe price id" value={ids.stripe_price_id} />
|
||||
<Row label="Stripe product id" value={ids.stripe_product_id} />
|
||||
<Row label="Autumn internal id" value={ids.internal_product_id} />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { FrontendProduct } from "@autumn/shared";
|
||||
import {
|
||||
AdminPlanIdsTooltip,
|
||||
type AdminPlanIds,
|
||||
} from "@/components/forms/shared/admin/AdminPlanIdsTooltip";
|
||||
import { PriceDisplay } from "@/components/forms/update-subscription-v2/components/PriceDisplay";
|
||||
|
||||
interface PriceChange {
|
||||
@@ -13,10 +17,12 @@ export function PlanPriceHeader({
|
||||
priceChange,
|
||||
product,
|
||||
currency,
|
||||
adminIds,
|
||||
}: {
|
||||
priceChange?: PriceChange | null;
|
||||
product: FrontendProduct | undefined;
|
||||
currency: string;
|
||||
adminIds?: AdminPlanIds;
|
||||
}) {
|
||||
const content = priceChange ? (
|
||||
<span className="flex items-center gap-1.5">
|
||||
@@ -32,9 +38,17 @@ export function PlanPriceHeader({
|
||||
<PriceDisplay product={product} currency={currency} />
|
||||
);
|
||||
|
||||
const wrapped = adminIds ? (
|
||||
<AdminPlanIdsTooltip ids={adminIds}>
|
||||
<span className="inline-flex">{content}</span>
|
||||
</AdminPlanIdsTooltip>
|
||||
) : (
|
||||
content
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 justify-between items-center mb-3">
|
||||
{content}
|
||||
{wrapped}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -104,6 +104,10 @@ export function EditPlanSection() {
|
||||
currency={currency}
|
||||
onEditPlan={handleEditPlan}
|
||||
priceChange={priceChange}
|
||||
adminIds={{
|
||||
stripe_product_id: product?.stripe_id ?? null,
|
||||
internal_product_id: product?.internal_id ?? null,
|
||||
}}
|
||||
/>
|
||||
</SheetSection>
|
||||
);
|
||||
|
||||
@@ -23,6 +23,7 @@ export type SheetType =
|
||||
| "balance-create"
|
||||
| "invoice-detail"
|
||||
| "sync-stripe"
|
||||
| "sync-stripe-v2"
|
||||
| "billing-auto-topup-add"
|
||||
| "billing-auto-topup-edit"
|
||||
| "billing-spend-limit-add"
|
||||
|
||||
@@ -48,10 +48,14 @@ function SubscriptionDetailItems({
|
||||
items,
|
||||
product,
|
||||
prepaidDisplayQuantities,
|
||||
adminIds,
|
||||
}: {
|
||||
items: ProductItem[];
|
||||
product: FrontendProduct;
|
||||
prepaidDisplayQuantities: Record<string, number>;
|
||||
adminIds?: import(
|
||||
"@/components/forms/shared/admin/AdminPlanIdsTooltip"
|
||||
).AdminPlanIds;
|
||||
}) {
|
||||
const sortedItems = useMemo(() => sortPlanItems({ items }), [items]);
|
||||
const { visibleItems, collapsedBooleanItems } = useMemo(
|
||||
@@ -80,7 +84,11 @@ function SubscriptionDetailItems({
|
||||
return (
|
||||
<SheetSection>
|
||||
<div className="flex gap-2 justify-between items-center h-6 mb-3">
|
||||
<BasePriceDisplay product={product} readOnly={true} />
|
||||
<BasePriceDisplay
|
||||
product={product}
|
||||
readOnly={true}
|
||||
adminIds={adminIds}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
@@ -139,6 +147,16 @@ export function SubscriptionDetailSheet() {
|
||||
prepaidItems,
|
||||
});
|
||||
|
||||
const baseCustomerPrice = cusProduct.customer_prices?.find(
|
||||
(cp: { price: { config?: { stripe_price_id?: string } } }) =>
|
||||
Boolean(cp.price?.config?.stripe_price_id),
|
||||
);
|
||||
const adminIds = {
|
||||
stripe_price_id: baseCustomerPrice?.price?.config?.stripe_price_id ?? null,
|
||||
stripe_product_id: cusProduct.product?.processor?.id ?? null,
|
||||
internal_product_id: cusProduct.product?.internal_id ?? null,
|
||||
};
|
||||
|
||||
const formatDate = (timestamp: number | null | undefined) => {
|
||||
if (!timestamp) return "—";
|
||||
return format(new Date(timestamp), "MMM d, yyyy, HH:mm");
|
||||
@@ -184,6 +202,7 @@ export function SubscriptionDetailSheet() {
|
||||
items={productV2.items}
|
||||
product={productV2}
|
||||
prepaidDisplayQuantities={prepaidDisplayQuantities}
|
||||
adminIds={adminIds}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,541 @@
|
||||
import {
|
||||
type FrontendProduct,
|
||||
FreeTrialDuration,
|
||||
productV2ToFrontendProduct,
|
||||
type SyncParamsV1,
|
||||
type SyncPhase,
|
||||
type SyncPlanInstance,
|
||||
type SyncProposalV2,
|
||||
} from "@autumn/shared";
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
ArrowSquareOutIcon,
|
||||
PlusIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useMemo, useState } from "react";
|
||||
import type Stripe from "stripe";
|
||||
import { buildCustomize } from "@/components/forms/create-schedule/hooks/useCreateScheduleRequestBody";
|
||||
import { ConfigRow } from "@/components/forms/shared/ConfigRow";
|
||||
import {
|
||||
getProductWithSupportedPlanFormValues,
|
||||
getSupportedPlanFormPatchFromDraftProduct,
|
||||
} from "@/components/forms/shared/utils/planCustomizationUtils";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Button } from "@/components/v2/buttons/Button";
|
||||
import { InlinePlanEditor } from "@/components/v2/inline-custom-plan-editor/InlinePlanEditor";
|
||||
import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery";
|
||||
import { useOrgStripeQuery } from "@/hooks/queries/useOrgStripeQuery";
|
||||
import { useProductsQuery } from "@/hooks/queries/useProductsQuery";
|
||||
import { useEnv } from "@/utils/envUtils";
|
||||
import {
|
||||
getStripeConnectViewAsLink,
|
||||
getStripeSubLink,
|
||||
} from "@/utils/linkUtils";
|
||||
import { useAdmin } from "@/views/admin/hooks/useAdmin";
|
||||
import { useMasterStripeAccount } from "@/views/admin/hooks/useMasterStripeAccount";
|
||||
import { useCusQuery } from "@/views/customers/customer/hooks/useCusQuery";
|
||||
import { useCustomerContext } from "@/views/customers2/customer/CustomerContext";
|
||||
import { type DraftPlan, SyncPlanRow } from "./SyncPlanRow";
|
||||
import { applyCustomizeToProduct } from "./syncPlanRowUtils";
|
||||
|
||||
const generateKey = () =>
|
||||
`p_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
type DisplayItem = {
|
||||
key: string;
|
||||
name: string;
|
||||
priceLabel: string;
|
||||
};
|
||||
|
||||
type PhaseSection = {
|
||||
phase: SyncPhase;
|
||||
displayItems: DisplayItem[];
|
||||
};
|
||||
|
||||
const formatPriceAmount = ({
|
||||
unitAmount,
|
||||
billingScheme,
|
||||
}: {
|
||||
unitAmount?: number | null;
|
||||
billingScheme?: string | null;
|
||||
}): string => {
|
||||
if (billingScheme === "tiered") return "tiered";
|
||||
if (unitAmount === null || unitAmount === undefined) return "—";
|
||||
return `$${(unitAmount / 100).toFixed(2)}`;
|
||||
};
|
||||
|
||||
const itemsFromStripeSubscription = ({
|
||||
sub,
|
||||
}: {
|
||||
sub: Stripe.Subscription;
|
||||
}): DisplayItem[] =>
|
||||
sub.items.data.map((item) => {
|
||||
const product = item.price?.product;
|
||||
const productName =
|
||||
typeof product === "object" && product && "name" in product
|
||||
? (product as { name: string }).name
|
||||
: (item.price?.id ?? "Unknown");
|
||||
return {
|
||||
key: item.id,
|
||||
name: productName,
|
||||
priceLabel: formatPriceAmount({
|
||||
unitAmount: item.price?.unit_amount,
|
||||
billingScheme: item.price?.billing_scheme,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const itemsFromSchedulePhase = ({
|
||||
phase,
|
||||
phaseIndex,
|
||||
}: {
|
||||
phase: Stripe.SubscriptionSchedule.Phase;
|
||||
phaseIndex: number;
|
||||
}): DisplayItem[] =>
|
||||
phase.items.map((item, itemIndex) => {
|
||||
const price = item.price as
|
||||
| string
|
||||
| (Stripe.Price & { product?: string | Stripe.Product })
|
||||
| undefined;
|
||||
const expanded = typeof price === "object" ? price : null;
|
||||
const priceId = typeof price === "string" ? price : (expanded?.id ?? "");
|
||||
const product = expanded?.product;
|
||||
const productName =
|
||||
typeof product === "object" && product && "name" in product
|
||||
? product.name
|
||||
: priceId || "Unknown";
|
||||
return {
|
||||
key: `${phaseIndex}:${itemIndex}`,
|
||||
name: productName,
|
||||
priceLabel: formatPriceAmount({
|
||||
unitAmount: expanded?.unit_amount,
|
||||
billingScheme: expanded?.billing_scheme,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const formatPhaseStart = (startsAt: SyncPhase["starts_at"]): string => {
|
||||
if (startsAt === "now") return "Starts now";
|
||||
return `Starts ${new Date(startsAt).toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})}`;
|
||||
};
|
||||
|
||||
const findScheduleStartDateMs = ({
|
||||
phase,
|
||||
}: {
|
||||
phase: Stripe.SubscriptionSchedule.Phase;
|
||||
}) => phase.start_date * 1000;
|
||||
|
||||
const buildPhaseSections = ({
|
||||
proposal,
|
||||
}: {
|
||||
proposal: SyncProposalV2;
|
||||
}): PhaseSection[] => {
|
||||
const sub = proposal.stripe_subscription;
|
||||
const schedule = proposal.stripe_schedule;
|
||||
|
||||
return proposal.phases.map((phase): PhaseSection => {
|
||||
// Map proposal phase → schedule phase by start_date when a schedule
|
||||
// exists, since the backend filters out phases with zero plans and
|
||||
// indices may not align. Fall back to subscription items for the
|
||||
// current phase when no schedule is attached.
|
||||
const matchingSchedulePhase = schedule
|
||||
? schedule.phases.find((schedulePhase) => {
|
||||
if (phase.starts_at === "now") {
|
||||
return findScheduleStartDateMs({ phase: schedulePhase }) <=
|
||||
Date.now();
|
||||
}
|
||||
return (
|
||||
findScheduleStartDateMs({ phase: schedulePhase }) ===
|
||||
phase.starts_at
|
||||
);
|
||||
})
|
||||
: undefined;
|
||||
|
||||
if (matchingSchedulePhase && schedule) {
|
||||
const phaseIndex = schedule.phases.indexOf(matchingSchedulePhase);
|
||||
return {
|
||||
phase,
|
||||
displayItems: itemsFromSchedulePhase({
|
||||
phase: matchingSchedulePhase,
|
||||
phaseIndex,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
if (phase.starts_at === "now" && sub) {
|
||||
return { phase, displayItems: itemsFromStripeSubscription({ sub }) };
|
||||
}
|
||||
|
||||
return { phase, displayItems: [] };
|
||||
});
|
||||
};
|
||||
|
||||
const seedDraftPlansByPhase = ({
|
||||
proposal,
|
||||
}: {
|
||||
proposal: SyncProposalV2;
|
||||
}): DraftPlan[][] =>
|
||||
proposal.phases.map((phase) =>
|
||||
phase.plans.map((plan) => ({ ...plan, _key: generateKey() })),
|
||||
);
|
||||
|
||||
export function SubscriptionEditorView({
|
||||
proposal,
|
||||
customerId,
|
||||
onBack,
|
||||
onSubmit,
|
||||
isSubmitting,
|
||||
}: {
|
||||
proposal: SyncProposalV2;
|
||||
customerId: string;
|
||||
onBack: () => void;
|
||||
onSubmit: (params: SyncParamsV1) => void;
|
||||
isSubmitting: boolean;
|
||||
}) {
|
||||
const { products } = useProductsQuery();
|
||||
const { features } = useFeaturesQuery();
|
||||
const { customer } = useCusQuery();
|
||||
const entities = customer?.entities ?? [];
|
||||
|
||||
const env = useEnv();
|
||||
const { stripeAccount } = useOrgStripeQuery();
|
||||
const { isAdmin } = useAdmin();
|
||||
const { masterStripeAccount } = useMasterStripeAccount();
|
||||
const { setIsInlineEditorOpen } = useCustomerContext();
|
||||
|
||||
const handleOpenStripe = () => {
|
||||
const subId = proposal.stripe_subscription_id;
|
||||
if (!subId) return;
|
||||
const stripeAccountId = stripeAccount?.id;
|
||||
const masterStripeAccountId = masterStripeAccount?.id;
|
||||
const url =
|
||||
isAdmin && masterStripeAccountId && stripeAccountId
|
||||
? getStripeConnectViewAsLink({
|
||||
masterAccountId: masterStripeAccountId,
|
||||
connectedAccountId: stripeAccountId,
|
||||
env,
|
||||
path: `subscriptions/${subId}`,
|
||||
})
|
||||
: getStripeSubLink({
|
||||
subscriptionId: subId,
|
||||
env,
|
||||
accountId: stripeAccountId,
|
||||
});
|
||||
window.open(url, "_blank");
|
||||
};
|
||||
|
||||
const phaseSections = useMemo(
|
||||
() => buildPhaseSections({ proposal }),
|
||||
[proposal],
|
||||
);
|
||||
const isMultiPhase =
|
||||
(proposal.stripe_schedule?.phases.length ?? 0) > 1 &&
|
||||
phaseSections.length > 1;
|
||||
|
||||
const [draftPlansByPhase, setDraftPlansByPhase] = useState<DraftPlan[][]>(
|
||||
() => seedDraftPlansByPhase({ proposal }),
|
||||
);
|
||||
const [expirePrevious, setExpirePrevious] = useState<boolean>(true);
|
||||
const [editing, setEditing] = useState<{
|
||||
phaseIndex: number;
|
||||
planIndex: number;
|
||||
} | null>(null);
|
||||
|
||||
const handlePlanChange = (
|
||||
phaseIndex: number,
|
||||
planIndex: number,
|
||||
next: DraftPlan,
|
||||
) => {
|
||||
setDraftPlansByPhase((prev) =>
|
||||
prev.map((plans, idx) => {
|
||||
if (idx !== phaseIndex) return plans;
|
||||
const updated = [...plans];
|
||||
updated[planIndex] = next;
|
||||
return updated;
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const handleAddPlan = (phaseIndex: number) => {
|
||||
setDraftPlansByPhase((prev) =>
|
||||
prev.map((plans, idx) => {
|
||||
if (idx !== phaseIndex) return plans;
|
||||
return [
|
||||
...plans,
|
||||
{
|
||||
_key: generateKey(),
|
||||
plan_id: "",
|
||||
quantity: 1,
|
||||
expire_previous: expirePrevious,
|
||||
},
|
||||
];
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const handleRemove = (phaseIndex: number, planIndex: number) => {
|
||||
setDraftPlansByPhase((prev) =>
|
||||
prev.map((plans, idx) => {
|
||||
if (idx !== phaseIndex) return plans;
|
||||
return plans.filter((_, i) => i !== planIndex);
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const handleStartCustomize = (phaseIndex: number, planIndex: number) => {
|
||||
setEditing({ phaseIndex, planIndex });
|
||||
setIsInlineEditorOpen(true);
|
||||
};
|
||||
|
||||
const handleCancelCustomize = () => {
|
||||
setEditing(null);
|
||||
setIsInlineEditorOpen(false);
|
||||
};
|
||||
|
||||
const editingProduct: FrontendProduct | null = useMemo(() => {
|
||||
if (editing === null || !products) return null;
|
||||
const plan = draftPlansByPhase[editing.phaseIndex]?.[editing.planIndex];
|
||||
if (!plan?.plan_id) return null;
|
||||
const productV2 = products.find((p) => p.id === plan.plan_id);
|
||||
if (!productV2) return null;
|
||||
|
||||
const baseProduct = productV2ToFrontendProduct({ product: productV2 });
|
||||
const customize = plan.customize;
|
||||
if (!customize) return baseProduct;
|
||||
|
||||
const customizedV2 = applyCustomizeToProduct({
|
||||
product: productV2,
|
||||
customize,
|
||||
});
|
||||
|
||||
return getProductWithSupportedPlanFormValues({
|
||||
baseProduct,
|
||||
formValues: {
|
||||
items: customizedV2.items,
|
||||
version: undefined,
|
||||
trialLength: null,
|
||||
trialDuration: FreeTrialDuration.Day,
|
||||
trialEnabled: false,
|
||||
trialCardRequired: false,
|
||||
},
|
||||
});
|
||||
}, [editing, draftPlansByPhase, products]);
|
||||
|
||||
const handleCustomizeSave = (draftProduct: FrontendProduct) => {
|
||||
if (editing === null || !products) {
|
||||
handleCancelCustomize();
|
||||
return;
|
||||
}
|
||||
const plan = draftPlansByPhase[editing.phaseIndex]?.[editing.planIndex];
|
||||
if (!plan?.plan_id) {
|
||||
handleCancelCustomize();
|
||||
return;
|
||||
}
|
||||
const productV2 = products.find((p) => p.id === plan.plan_id);
|
||||
if (!productV2) {
|
||||
handleCancelCustomize();
|
||||
return;
|
||||
}
|
||||
|
||||
const baseProduct = productV2ToFrontendProduct({ product: productV2 });
|
||||
const patch = getSupportedPlanFormPatchFromDraftProduct({
|
||||
baseProduct,
|
||||
draftProduct,
|
||||
});
|
||||
|
||||
const nextCustomize = patch.items
|
||||
? buildCustomize({ items: patch.items, features: features ?? [] })
|
||||
: undefined;
|
||||
|
||||
const { phaseIndex, planIndex } = editing;
|
||||
setDraftPlansByPhase((prev) =>
|
||||
prev.map((plans, idx) => {
|
||||
if (idx !== phaseIndex) return plans;
|
||||
const updated = [...plans];
|
||||
updated[planIndex] = { ...plan, customize: nextCustomize };
|
||||
return updated;
|
||||
}),
|
||||
);
|
||||
handleCancelCustomize();
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
const phases: SyncPhase[] = phaseSections
|
||||
.map((section, phaseIndex) => {
|
||||
const validPlans = (draftPlansByPhase[phaseIndex] ?? []).filter((p) =>
|
||||
Boolean(p.plan_id),
|
||||
);
|
||||
const planInstances: SyncPlanInstance[] = validPlans.map(
|
||||
({ _key: _ignore, ...rest }) => ({
|
||||
...rest,
|
||||
expire_previous: expirePrevious,
|
||||
}),
|
||||
);
|
||||
return { starts_at: section.phase.starts_at, plans: planInstances };
|
||||
})
|
||||
.filter((phase) => phase.plans.length > 0);
|
||||
|
||||
if (phases.length === 0) return;
|
||||
|
||||
const params: SyncParamsV1 = {
|
||||
customer_id: customerId,
|
||||
stripe_subscription_id: proposal.stripe_subscription_id,
|
||||
stripe_schedule_id: proposal.stripe_schedule_id,
|
||||
phases,
|
||||
};
|
||||
onSubmit(params);
|
||||
};
|
||||
|
||||
const totalPlanInstances = draftPlansByPhase
|
||||
.flat()
|
||||
.filter((p) => p.plan_id)
|
||||
.reduce((acc, p) => acc + (p.quantity ?? 1), 0);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 overflow-hidden">
|
||||
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="flex items-center gap-1 text-xs text-t3 hover:text-t1"
|
||||
>
|
||||
<ArrowLeftIcon size={14} /> Back to subscriptions
|
||||
</button>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-t3">Stripe subscription</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<code className="text-xs font-mono text-t1">
|
||||
{proposal.stripe_subscription_id}
|
||||
</code>
|
||||
{proposal.stripe_subscription_id && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenStripe}
|
||||
className="text-t4 hover:text-t2 transition-colors"
|
||||
aria-label="Open in Stripe"
|
||||
>
|
||||
<ArrowSquareOutIcon size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{phaseSections.map((section, phaseIndex) => {
|
||||
const phasePlans = draftPlansByPhase[phaseIndex] ?? [];
|
||||
const usedPlanIds = new Set(
|
||||
phasePlans.map((p) => p.plan_id).filter(Boolean) as string[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`phase-${phaseIndex}-${section.phase.starts_at}`}
|
||||
className="space-y-3 pt-3 border-t border-border/40 first:pt-0 first:border-t-0"
|
||||
>
|
||||
{isMultiPhase && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs font-medium text-t1">
|
||||
Phase {phaseIndex + 1}
|
||||
</div>
|
||||
<div className="text-xs text-t3">
|
||||
{formatPhaseStart(section.phase.starts_at)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{section.displayItems.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-t3">Subscription items</div>
|
||||
<div className="space-y-1">
|
||||
{section.displayItems.map((item) => (
|
||||
<div
|
||||
key={item.key}
|
||||
className="flex items-center justify-between text-xs"
|
||||
>
|
||||
<span className="text-t1">{item.name}</span>
|
||||
<span className="text-t3">{item.priceLabel}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs text-t3">Autumn plans</div>
|
||||
{phasePlans.map((plan, planIndex) => (
|
||||
<SyncPlanRow
|
||||
key={plan._key}
|
||||
plan={plan}
|
||||
products={products ?? []}
|
||||
usedPlanIds={
|
||||
new Set(
|
||||
Array.from(usedPlanIds).filter(
|
||||
(id) => id !== plan.plan_id,
|
||||
),
|
||||
)
|
||||
}
|
||||
entities={entities}
|
||||
onChange={(next) =>
|
||||
handlePlanChange(phaseIndex, planIndex, next)
|
||||
}
|
||||
onRemove={() => handleRemove(phaseIndex, planIndex)}
|
||||
onCustomize={() =>
|
||||
handleStartCustomize(phaseIndex, planIndex)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleAddPlan(phaseIndex)}
|
||||
className="flex items-center gap-1 text-xs text-t4 hover:text-t2 transition-colors py-1"
|
||||
>
|
||||
<PlusIcon size={11} />
|
||||
Add plan
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<ConfigRow
|
||||
title="Expire current plans"
|
||||
description="End any active customer products in the same group when the sync runs."
|
||||
action={
|
||||
<Switch
|
||||
checked={expirePrevious}
|
||||
onCheckedChange={(checked) => setExpirePrevious(!!checked)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 px-4 py-3 border-t border-border/40">
|
||||
<Button variant="secondary" onClick={onBack} className="flex-1">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={totalPlanInstances === 0 || isSubmitting}
|
||||
isLoading={isSubmitting}
|
||||
className="flex-1"
|
||||
>
|
||||
Sync {totalPlanInstances}{" "}
|
||||
{totalPlanInstances === 1 ? "plan" : "plans"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{editingProduct && (
|
||||
<InlinePlanEditor
|
||||
product={editingProduct}
|
||||
onSave={handleCustomizeSave}
|
||||
onCancel={handleCancelCustomize}
|
||||
isOpen={editing !== null}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import type { SyncProposalV2 } from "@autumn/shared";
|
||||
import { LinkIcon } from "@phosphor-icons/react";
|
||||
import type Stripe from "stripe";
|
||||
import SmallSpinner from "@/components/general/SmallSpinner";
|
||||
import { useProductsQuery } from "@/hooks/queries/useProductsQuery";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const formatStripeCurrency = ({
|
||||
amount,
|
||||
currency,
|
||||
}: {
|
||||
amount: number;
|
||||
currency: string;
|
||||
}): string => {
|
||||
const dollars = amount / 100;
|
||||
try {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: currency.toUpperCase(),
|
||||
minimumFractionDigits: dollars % 1 === 0 ? 0 : 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(dollars);
|
||||
} catch {
|
||||
return `${dollars.toFixed(2)} ${currency.toUpperCase()}`;
|
||||
}
|
||||
};
|
||||
|
||||
const formatItemPrice = ({
|
||||
price,
|
||||
}: {
|
||||
price: Stripe.Price | null | undefined;
|
||||
}): string => {
|
||||
if (!price) return "—";
|
||||
const currency = price.currency ?? "usd";
|
||||
|
||||
if (price.billing_scheme === "tiered") {
|
||||
return price.tiers_mode === "volume" ? "Volume" : "Tiered";
|
||||
}
|
||||
|
||||
const usageType = price.recurring?.usage_type;
|
||||
if (usageType === "metered") {
|
||||
if (price.unit_amount != null) {
|
||||
return `${formatStripeCurrency({ amount: price.unit_amount, currency })}/unit (metered)`;
|
||||
}
|
||||
return "Metered usage";
|
||||
}
|
||||
|
||||
if (price.unit_amount != null) {
|
||||
return formatStripeCurrency({ amount: price.unit_amount, currency });
|
||||
}
|
||||
|
||||
return "—";
|
||||
};
|
||||
|
||||
const getStripeProductName = ({
|
||||
product,
|
||||
}: {
|
||||
product: string | Stripe.Product | Stripe.DeletedProduct | undefined;
|
||||
}): string | null => {
|
||||
if (typeof product === "object" && product && "name" in product) {
|
||||
return (product as { name: string }).name;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const StripeItemRow = ({ item }: { item: Stripe.SubscriptionItem }) => {
|
||||
const price = item.price;
|
||||
const productName =
|
||||
getStripeProductName({ product: price?.product }) ?? "Item";
|
||||
const priceLabel = formatItemPrice({ price });
|
||||
const quantity = item.quantity ?? 1;
|
||||
const showQuantity = quantity > 1 && price?.billing_scheme !== "tiered";
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between text-xs gap-3">
|
||||
<span className="text-t2 truncate min-w-0">{productName}</span>
|
||||
<div className="shrink-0 ml-2 text-t3 text-right">
|
||||
{priceLabel}
|
||||
{showQuantity && <span className="text-t4"> × {quantity}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ProposalCard = ({
|
||||
proposal,
|
||||
onSelect,
|
||||
productNamesById,
|
||||
}: {
|
||||
proposal: SyncProposalV2;
|
||||
onSelect: () => void;
|
||||
productNamesById: Record<string, string>;
|
||||
}) => {
|
||||
const sub = proposal.stripe_subscription;
|
||||
const isLinked = proposal.already_linked_product_id !== null;
|
||||
const matchedPlans = proposal.phases[0]?.plans ?? [];
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
className={cn(
|
||||
"w-full text-left rounded-lg border border-border p-4 space-y-3",
|
||||
"hover:border-primary/40 transition-colors bg-card",
|
||||
)}
|
||||
>
|
||||
{isLinked && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-amber-600 bg-amber-500/10 px-2 py-1.5 rounded-md">
|
||||
<LinkIcon className="size-3.5 shrink-0" weight="bold" />
|
||||
Already linked
|
||||
</div>
|
||||
)}
|
||||
|
||||
<span className="block text-xs font-mono text-t3 truncate">
|
||||
{proposal.stripe_subscription_id}
|
||||
</span>
|
||||
|
||||
{sub && sub.items.data.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-xs text-t3 font-medium">
|
||||
Subscription items
|
||||
</span>
|
||||
<div className="space-y-1">
|
||||
{sub.items.data.map((item) => (
|
||||
<StripeItemRow key={item.id} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{matchedPlans.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-xs text-t3 font-medium">Matched Plans</span>
|
||||
<div className="space-y-1">
|
||||
{matchedPlans.map((plan, index) => {
|
||||
const name = productNamesById[plan.plan_id] ?? plan.plan_id;
|
||||
const quantity = plan.quantity ?? 1;
|
||||
return (
|
||||
<div
|
||||
key={`${plan.plan_id}-${index}`}
|
||||
className="flex items-center justify-between text-xs gap-3"
|
||||
>
|
||||
<span className="text-t2 truncate">{name}</span>
|
||||
{quantity > 1 && (
|
||||
<span className="text-t4 shrink-0 ml-2">× {quantity}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export function SubscriptionListView({
|
||||
proposals,
|
||||
isLoading,
|
||||
error,
|
||||
onSelect,
|
||||
}: {
|
||||
proposals: SyncProposalV2[];
|
||||
isLoading: boolean;
|
||||
error: unknown;
|
||||
onSelect: (stripeSubscriptionId: string) => void;
|
||||
}) {
|
||||
const { products } = useProductsQuery();
|
||||
const productNamesById = Object.fromEntries(
|
||||
(products ?? []).map((p) => [p.id, p.name]),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-3">
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<SmallSpinner size={20} className="text-t3" />
|
||||
</div>
|
||||
)}
|
||||
{Boolean(error) && (
|
||||
<div className="text-sm text-red-500 py-4">
|
||||
Failed to load Stripe subscriptions.
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && !error && proposals.length === 0 && (
|
||||
<div className="text-sm text-t3 py-8 text-center">
|
||||
No Stripe subscriptions found for this customer.
|
||||
</div>
|
||||
)}
|
||||
{!isLoading &&
|
||||
proposals.map((proposal) => (
|
||||
<ProposalCard
|
||||
key={proposal.stripe_subscription_id}
|
||||
proposal={proposal}
|
||||
onSelect={() =>
|
||||
proposal.stripe_subscription_id &&
|
||||
onSelect(proposal.stripe_subscription_id)
|
||||
}
|
||||
productNamesById={productNamesById}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
import type { Entity, ProductV2, SyncPlanInstance } from "@autumn/shared";
|
||||
import {
|
||||
BuildingsIcon,
|
||||
PackageIcon,
|
||||
PencilSimpleIcon,
|
||||
PuzzlePieceIcon,
|
||||
XIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Badge } from "@/components/v2/badges/Badge";
|
||||
import { Button } from "@/components/v2/buttons/Button";
|
||||
import { Input } from "@/components/v2/inputs/Input";
|
||||
import { SearchableSelect } from "@/components/v2/selects/SearchableSelect";
|
||||
import { useOrg } from "@/hooks/common/useOrg";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
applyCustomizeToProduct,
|
||||
getBasePriceLabel,
|
||||
} from "./syncPlanRowUtils";
|
||||
|
||||
export type DraftPlan = SyncPlanInstance & { _key: string };
|
||||
|
||||
const CUSTOMER_LEVEL_VALUE = "";
|
||||
|
||||
const PriceLabel = ({
|
||||
label,
|
||||
isCustom,
|
||||
}: {
|
||||
label: string;
|
||||
isCustom: boolean;
|
||||
}) => (
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs tabular-nums",
|
||||
isCustom ? "text-emerald-500 font-medium" : "text-t3",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
|
||||
type EntityOption = Entity | null;
|
||||
|
||||
const EntityScopeSubRow = ({
|
||||
entities,
|
||||
scopeEntityId,
|
||||
onChange,
|
||||
}: {
|
||||
entities: Entity[];
|
||||
scopeEntityId: string | undefined;
|
||||
onChange: (entityId: string | undefined) => void;
|
||||
}) => {
|
||||
const entityOptions: EntityOption[] = [null, ...entities];
|
||||
return (
|
||||
<div className="ml-4 pl-3 border-l border-border/40">
|
||||
<SearchableSelect<EntityOption>
|
||||
value={scopeEntityId ?? CUSTOMER_LEVEL_VALUE}
|
||||
onValueChange={(value) =>
|
||||
onChange(value === CUSTOMER_LEVEL_VALUE ? undefined : value)
|
||||
}
|
||||
options={entityOptions}
|
||||
getOptionValue={(option) =>
|
||||
option === null
|
||||
? CUSTOMER_LEVEL_VALUE
|
||||
: option.id || option.internal_id
|
||||
}
|
||||
getOptionLabel={(option) =>
|
||||
option === null ? "Customer-level" : option.name || option.id || "PENDING"
|
||||
}
|
||||
triggerClassName="w-full h-input"
|
||||
placeholder="Select entity"
|
||||
searchable
|
||||
searchPlaceholder="Search entities..."
|
||||
emptyText="No entities found"
|
||||
renderValue={(option) =>
|
||||
option === null || option === undefined ? (
|
||||
<span className="text-t2 text-xs">Customer-level</span>
|
||||
) : (
|
||||
<span className="text-t2 text-xs truncate">
|
||||
{option.name || option.id || "PENDING"}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
renderOption={(option, isSelected) => {
|
||||
if (option === null) {
|
||||
return (
|
||||
<>
|
||||
<span className="text-sm">Customer-level</span>
|
||||
{isSelected && <CheckIcon className="size-4 shrink-0" />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div className="flex gap-2 items-center min-w-0 flex-1">
|
||||
{option.name && (
|
||||
<span className="text-sm shrink-0">{option.name}</span>
|
||||
)}
|
||||
<span className="truncate text-t3 font-mono text-xs min-w-0">
|
||||
{option.id || "PENDING"}
|
||||
</span>
|
||||
</div>
|
||||
{isSelected && <CheckIcon className="size-4 shrink-0" />}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export function SyncPlanRow({
|
||||
plan,
|
||||
products,
|
||||
usedPlanIds,
|
||||
entities,
|
||||
onChange,
|
||||
onRemove,
|
||||
onCustomize,
|
||||
}: {
|
||||
plan: DraftPlan;
|
||||
products: ProductV2[];
|
||||
usedPlanIds: Set<string>;
|
||||
entities: Entity[];
|
||||
onChange: (plan: DraftPlan) => void;
|
||||
onRemove: () => void;
|
||||
onCustomize: () => void;
|
||||
}) {
|
||||
const { org } = useOrg();
|
||||
const currency = org?.default_currency ?? "USD";
|
||||
|
||||
const availableProducts = products.filter((p) => !p.archived);
|
||||
const selectedProduct = products.find((p) => p.id === plan.plan_id);
|
||||
const hasCustomize = Boolean(plan.customize);
|
||||
const hasEntityScope = Boolean(plan.internal_entity_id);
|
||||
|
||||
const [scopeOpen, setScopeOpen] = useState<boolean>(hasEntityScope);
|
||||
|
||||
if (!plan.plan_id) {
|
||||
return (
|
||||
<SearchableSelect
|
||||
value={null}
|
||||
onValueChange={(value) => onChange({ ...plan, plan_id: value })}
|
||||
options={availableProducts}
|
||||
getOptionValue={(product) => product.id}
|
||||
getOptionLabel={(product) => product.name}
|
||||
getOptionDisabled={(product) => usedPlanIds.has(product.id)}
|
||||
renderOption={(product) => (
|
||||
<>
|
||||
<span className="flex-1 truncate min-w-0">{product.name}</span>
|
||||
{usedPlanIds.has(product.id) && (
|
||||
<span className="text-xs text-t4 shrink-0">Already added</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
placeholder="Select plan…"
|
||||
searchable
|
||||
searchPlaceholder="Search plans..."
|
||||
emptyText="No plans found"
|
||||
defaultOpen
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const isAddOn = selectedProduct?.is_add_on === true;
|
||||
const customizedProduct = selectedProduct
|
||||
? applyCustomizeToProduct({
|
||||
product: selectedProduct,
|
||||
customize: plan.customize,
|
||||
})
|
||||
: null;
|
||||
|
||||
const originalPriceLabel = selectedProduct
|
||||
? getBasePriceLabel({ product: selectedProduct, currency })
|
||||
: null;
|
||||
const currentPriceLabel = customizedProduct
|
||||
? getBasePriceLabel({ product: customizedProduct, currency })
|
||||
: null;
|
||||
const isPriceCustom =
|
||||
hasCustomize &&
|
||||
originalPriceLabel !== null &&
|
||||
currentPriceLabel !== null &&
|
||||
originalPriceLabel !== currentPriceLabel;
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div
|
||||
className={cn(
|
||||
"group flex h-input min-w-0 w-full items-center gap-2 rounded-lg",
|
||||
"input-base input-shadow-default px-3 text-sm text-t1",
|
||||
)}
|
||||
>
|
||||
{isAddOn ? (
|
||||
<PuzzlePieceIcon className="size-3.5 shrink-0 text-t3" />
|
||||
) : (
|
||||
<PackageIcon className="size-3.5 shrink-0 text-t3" />
|
||||
)}
|
||||
<span className="flex-1 truncate min-w-0">
|
||||
{selectedProduct?.name ?? plan.plan_id}
|
||||
</span>
|
||||
|
||||
{isAddOn && (
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={plan.quantity ?? 1}
|
||||
onChange={(e) => {
|
||||
const next = Number.parseInt(e.target.value, 10);
|
||||
onChange({
|
||||
...plan,
|
||||
quantity: Number.isFinite(next) && next >= 1 ? next : 1,
|
||||
});
|
||||
}}
|
||||
className="w-14 h-7 text-center text-xs"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="relative flex shrink-0 items-center gap-1.5 min-w-[60px] justify-end">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 transition-opacity duration-150",
|
||||
"group-hover:opacity-0",
|
||||
)}
|
||||
>
|
||||
{hasCustomize && (
|
||||
<Badge variant="green" size="sm">
|
||||
Custom
|
||||
</Badge>
|
||||
)}
|
||||
{currentPriceLabel && (
|
||||
<PriceLabel
|
||||
label={currentPriceLabel}
|
||||
isCustom={isPriceCustom}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute right-0 flex items-center gap-1 transition-opacity duration-150",
|
||||
"opacity-0 group-hover:opacity-100",
|
||||
)}
|
||||
>
|
||||
{entities.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-6 w-6",
|
||||
scopeOpen || hasEntityScope
|
||||
? "text-primary"
|
||||
: "text-t3 hover:text-t1",
|
||||
)}
|
||||
onClick={() => setScopeOpen((v) => !v)}
|
||||
aria-label="Set entity scope"
|
||||
>
|
||||
<BuildingsIcon size={13} />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-t3 hover:text-t1"
|
||||
onClick={onCustomize}
|
||||
>
|
||||
<PencilSimpleIcon size={13} />
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 text-t4 hover:text-destructive transition-colors"
|
||||
onClick={onRemove}
|
||||
>
|
||||
<XIcon size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{entities.length > 0 && scopeOpen && (
|
||||
<EntityScopeSubRow
|
||||
entities={entities}
|
||||
scopeEntityId={plan.internal_entity_id}
|
||||
onChange={(entityId) =>
|
||||
onChange({ ...plan, internal_entity_id: entityId })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useState } from "react";
|
||||
import { SheetHeader } from "@/components/v2/sheets/SharedSheetComponents";
|
||||
import { useSheetStore } from "@/hooks/stores/useSheetStore";
|
||||
import { useCusQuery } from "@/views/customers/customer/hooks/useCusQuery";
|
||||
import { useSyncProposalsV2 } from "./hooks/useSyncProposalsV2";
|
||||
import { SubscriptionEditorView } from "./SubscriptionEditorView";
|
||||
import { SubscriptionListView } from "./SubscriptionListView";
|
||||
|
||||
export function SyncStripeSheetV2() {
|
||||
const { customer, refetch: refetchCustomer } = useCusQuery();
|
||||
const closeSheet = useSheetStore((s) => s.closeSheet);
|
||||
|
||||
const customerId = customer?.id ?? "";
|
||||
const { proposals, isLoading, error, syncMutation } = useSyncProposalsV2({
|
||||
customerId,
|
||||
});
|
||||
|
||||
const [selectedSubscriptionId, setSelectedSubscriptionId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
|
||||
const selectedProposal = selectedSubscriptionId
|
||||
? (proposals.find(
|
||||
(p) => p.stripe_subscription_id === selectedSubscriptionId,
|
||||
) ?? null)
|
||||
: null;
|
||||
|
||||
const headerTitle = selectedProposal
|
||||
? "Configure sync"
|
||||
: "Sync from Stripe";
|
||||
const headerDescription = selectedProposal
|
||||
? "Pick the Autumn plans to attach for this subscription"
|
||||
: "Pick a Stripe subscription to import";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<SheetHeader title={headerTitle} description={headerDescription} />
|
||||
|
||||
{!selectedProposal && (
|
||||
<SubscriptionListView
|
||||
proposals={proposals}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
onSelect={setSelectedSubscriptionId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedProposal && (
|
||||
<SubscriptionEditorView
|
||||
proposal={selectedProposal}
|
||||
customerId={customerId}
|
||||
onBack={() => setSelectedSubscriptionId(null)}
|
||||
onSubmit={(params) =>
|
||||
syncMutation.mutate(params, {
|
||||
onSuccess: () => {
|
||||
refetchCustomer();
|
||||
closeSheet();
|
||||
},
|
||||
})
|
||||
}
|
||||
isSubmitting={syncMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { SyncParamsV1, SyncProposalsV2Response } from "@autumn/shared";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { useQueryKeyFactory } from "@/hooks/common/useQueryKeyFactory";
|
||||
import { useAxiosInstance } from "@/services/useAxiosInstance";
|
||||
import { getBackendErr } from "@/utils/genUtils";
|
||||
|
||||
export const useSyncProposalsV2 = ({
|
||||
customerId,
|
||||
}: {
|
||||
customerId: string;
|
||||
}) => {
|
||||
const axiosInstance = useAxiosInstance();
|
||||
const queryKeyFactory = useQueryKeyFactory();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const proposalsQuery = useQuery({
|
||||
queryKey: queryKeyFactory(["sync-proposals-v2", customerId]),
|
||||
queryFn: async (): Promise<SyncProposalsV2Response> => {
|
||||
const { data } = await axiosInstance.post(
|
||||
"/v1/billing.sync_proposals_v2",
|
||||
{ customer_id: customerId },
|
||||
);
|
||||
return data;
|
||||
},
|
||||
enabled: Boolean(customerId),
|
||||
});
|
||||
|
||||
const syncMutation = useMutation({
|
||||
mutationFn: async (params: SyncParamsV1) => {
|
||||
const { data } = await axiosInstance.post(
|
||||
"/v1/billing.sync_v2",
|
||||
params,
|
||||
);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Stripe sync completed");
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeyFactory(["customer"]),
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(getBackendErr(error, "Failed to sync from Stripe"));
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
proposals: proposalsQuery.data?.proposals ?? [],
|
||||
isLoading: proposalsQuery.isLoading,
|
||||
error: proposalsQuery.error,
|
||||
refetch: proposalsQuery.refetch,
|
||||
syncMutation,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
type CustomizePlanV1,
|
||||
formatAmount,
|
||||
formatInterval,
|
||||
isPriceItem,
|
||||
type ProductItem,
|
||||
type ProductV2,
|
||||
} from "@autumn/shared";
|
||||
|
||||
/**
|
||||
* Apply a `customize` block to a `ProductV2`, returning the effective
|
||||
* product. Items override the full set when present; a base price override
|
||||
* (`customize.price`) replaces the existing fixed price item or is
|
||||
* appended.
|
||||
*/
|
||||
export const applyCustomizeToProduct = ({
|
||||
product,
|
||||
customize,
|
||||
}: {
|
||||
product: ProductV2;
|
||||
customize: CustomizePlanV1 | undefined;
|
||||
}): ProductV2 => {
|
||||
if (!customize) return product;
|
||||
|
||||
let items: ProductItem[] = customize.items ?? product.items ?? [];
|
||||
|
||||
if (customize.price !== undefined) {
|
||||
if (customize.price === null) {
|
||||
items = items.filter((item) => !isPriceItem(item));
|
||||
} else {
|
||||
const newPriceItem: ProductItem = {
|
||||
price: customize.price.amount,
|
||||
interval: customize.price.interval,
|
||||
interval_count: customize.price.interval_count ?? 1,
|
||||
} as ProductItem;
|
||||
const existingIndex = items.findIndex((item) => isPriceItem(item));
|
||||
if (existingIndex >= 0) {
|
||||
items = items.map((item, i) => (i === existingIndex ? newPriceItem : item));
|
||||
} else {
|
||||
items = [newPriceItem, ...items];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { ...product, items };
|
||||
};
|
||||
|
||||
/**
|
||||
* Format the base (fixed) price of a ProductV2 as a single-line label
|
||||
* like "$20 per month" or "Free".
|
||||
*/
|
||||
export const getBasePriceLabel = ({
|
||||
product,
|
||||
currency,
|
||||
}: {
|
||||
product: ProductV2;
|
||||
currency: string;
|
||||
}): string => {
|
||||
const priceItem = product.items?.find((item) => isPriceItem(item));
|
||||
if (!priceItem || priceItem.price === 0 || priceItem.price === undefined) {
|
||||
return "Free";
|
||||
}
|
||||
|
||||
const formattedPrice = formatAmount({
|
||||
currency,
|
||||
amount: priceItem.price ?? 0,
|
||||
amountFormatOptions: {
|
||||
style: "currency",
|
||||
currencyDisplay: "narrowSymbol",
|
||||
},
|
||||
});
|
||||
const intervalText = priceItem.interval
|
||||
? formatInterval({
|
||||
interval: priceItem.interval,
|
||||
intervalCount: priceItem.interval_count ?? 1,
|
||||
})
|
||||
: "one-off";
|
||||
|
||||
return `${formattedPrice} ${intervalText}`;
|
||||
};
|
||||
@@ -32,7 +32,7 @@ import { cn } from "@/lib/utils";
|
||||
import { CusService } from "@/services/customers/CusService";
|
||||
import { useAxiosInstance } from "@/services/useAxiosInstance";
|
||||
import { useEnv } from "@/utils/envUtils";
|
||||
import { getBackendErr, notNullish } from "@/utils/genUtils";
|
||||
import { getBackendErr } from "@/utils/genUtils";
|
||||
import {
|
||||
getRevenueCatCusLink,
|
||||
getStripeConnectViewAsLink,
|
||||
@@ -69,7 +69,7 @@ export function CustomerActions() {
|
||||
const stripeCustomerId = customer?.processor?.id;
|
||||
const stripeConnectViewAsCustomerLink =
|
||||
isAdmin &&
|
||||
notNullish(sessionData?.session?.impersonatedBy) &&
|
||||
// notNullish(sessionData?.session?.impersonatedBy) &&
|
||||
masterStripeAccount?.id &&
|
||||
stripeAccount?.id &&
|
||||
stripeCustomerId
|
||||
@@ -174,6 +174,7 @@ export function CustomerActions() {
|
||||
<BracketsSquareIcon />
|
||||
Show customer object
|
||||
</DropdownMenuItem>
|
||||
{/* Old sync sheet — superseded by sync-stripe-v2 below. Kept for reference.
|
||||
{stripeCustomerId &&
|
||||
customer?.processor?.type === ProcessorType.Stripe && (
|
||||
<DropdownMenuItem
|
||||
@@ -187,6 +188,20 @@ export function CustomerActions() {
|
||||
Sync from Stripe
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
*/}
|
||||
{stripeCustomerId &&
|
||||
customer?.processor?.type === ProcessorType.Stripe && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setSheet({ type: "sync-stripe-v2" });
|
||||
setActionsOpen(false);
|
||||
}}
|
||||
className="flex gap-2"
|
||||
>
|
||||
<ArrowsClockwiseIcon />
|
||||
Sync from Stripe
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={handleOpenBillingPortal}
|
||||
className="flex gap-2"
|
||||
|
||||
@@ -26,6 +26,7 @@ import { InvoiceDetailSheet } from "../components/sheets/InvoiceDetailSheet";
|
||||
import { RecordUsageSheet } from "../components/sheets/RecordUsageSheet";
|
||||
import { SubscriptionDetailSheet } from "../components/sheets/SubscriptionDetailSheet";
|
||||
import { SyncStripeSheet } from "../components/sync-stripe/SyncStripeSheet";
|
||||
import { SyncStripeSheetV2 } from "../components/sync-stripe-v2/SyncStripeSheetV2";
|
||||
import { SHEET_ANIMATION } from "./customerAnimations";
|
||||
|
||||
export function CustomerSheets() {
|
||||
@@ -73,6 +74,8 @@ export function CustomerSheets() {
|
||||
}
|
||||
case "sync-stripe":
|
||||
return <SyncStripeSheet />;
|
||||
case "sync-stripe-v2":
|
||||
return <SyncStripeSheetV2 />;
|
||||
case "billing-auto-topup-add":
|
||||
case "billing-auto-topup-edit":
|
||||
return <BillingAutoTopupSheet />;
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { FrontendProduct } from "@autumn/shared";
|
||||
|
||||
import {
|
||||
AdminPlanIdsTooltip,
|
||||
type AdminPlanIds,
|
||||
} from "@/components/forms/shared/admin/AdminPlanIdsTooltip";
|
||||
import { Button } from "@/components/v2/buttons/Button";
|
||||
import {
|
||||
useCurrentItem,
|
||||
@@ -14,10 +18,12 @@ export const BasePriceDisplay = ({
|
||||
isOnboarding,
|
||||
product,
|
||||
readOnly = false,
|
||||
adminIds,
|
||||
}: {
|
||||
isOnboarding?: boolean;
|
||||
product: FrontendProduct;
|
||||
readOnly?: boolean;
|
||||
adminIds?: AdminPlanIds;
|
||||
}) => {
|
||||
const { sheetType, setSheet } = useSheet();
|
||||
const { org } = useOrg();
|
||||
@@ -67,7 +73,27 @@ export const BasePriceDisplay = ({
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
// readOnly: render a plain span so hover events reach the admin tooltip
|
||||
// (the editable Button uses `pointer-events-none` on readOnly which would
|
||||
// suppress hover detection).
|
||||
if (readOnly) {
|
||||
const content = (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 cursor-default",
|
||||
isOnboarding && "mt-1",
|
||||
)}
|
||||
>
|
||||
{renderPriceContent()}
|
||||
</span>
|
||||
);
|
||||
if (!adminIds) return content;
|
||||
return (
|
||||
<AdminPlanIdsTooltip ids={adminIds}>{content}</AdminPlanIdsTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
const button = (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="default"
|
||||
@@ -76,11 +102,8 @@ export const BasePriceDisplay = ({
|
||||
isEditingPlanPrice && !isOnboarding && "btn-secondary-active z-95",
|
||||
isOnboarding &&
|
||||
"bg-transparent! border-none! outline-0! border-transparent! pointer-events-none shadow-none! p-0! h-fit! mt-1",
|
||||
readOnly &&
|
||||
"pointer-events-none bg-transparent! border-none! shadow-none! p-0!",
|
||||
)}
|
||||
onClick={() => {
|
||||
if (readOnly) return;
|
||||
if (item && !checkItemIsValid(item)) return;
|
||||
handleClick();
|
||||
}}
|
||||
@@ -88,4 +111,7 @@ export const BasePriceDisplay = ({
|
||||
{renderPriceContent()}
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (!adminIds) return button;
|
||||
return <AdminPlanIdsTooltip ids={adminIds}>{button}</AdminPlanIdsTooltip>;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user