Files
cfw-autumn/shared/api/billing/sync/syncParamsV1.ts
2026-05-05 18:06:39 +08:00

111 lines
3.7 KiB
TypeScript

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