feat: added restore + sync

This commit is contained in:
John Yeo
2026-05-05 18:06:39 +08:00
parent ba970d5951
commit b2fa5e15c3
85 changed files with 6082 additions and 429 deletions

View File

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

View File

@@ -1,4 +1,5 @@
export * from "./operations/getStripeActiveSubscriptionSchedule";
export * from "./utils/classifyStripeSubscriptionScheduleUtils";
export * from "./utils/convertStripeSubscriptionScheduleUtils";
export * from "./utils/logStripeSchedulePhaseUtils";

View File

@@ -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") {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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[];
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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" }),
],
},
{

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

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

View 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[];
};

View File

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

View File

@@ -3,4 +3,5 @@ export * from "./billingContext";
export * from "./billingContextOverride";
export * from "./createScheduleBillingContext";
export * from "./multiAttachBillingContext";
export * from "./syncBillingContext";
export * from "./updateSubscriptionBillingContext";

View 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"]>;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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