@openlup/core 0.0.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +225 -0
- package/CODE_OF_CONDUCT.md +39 -0
- package/CONTRIBUTING.md +34 -0
- package/LICENSE +204 -0
- package/MAINTAINERS.md +33 -0
- package/README.md +85 -3
- package/SECURITY.md +29 -0
- package/dist/bundle/contracts.d.ts +9 -0
- package/dist/bundle/contracts.js +1 -0
- package/dist/bundle/ports.d.ts +41 -0
- package/dist/bundle/ports.js +1 -0
- package/dist/catalog/identifiers.d.ts +31 -0
- package/dist/catalog/identifiers.js +46 -0
- package/dist/catalog/index.d.ts +2 -0
- package/dist/catalog/index.js +2 -0
- package/dist/checkout/idempotency.d.ts +29 -0
- package/dist/checkout/idempotency.js +37 -0
- package/dist/checkout/index.d.ts +2 -0
- package/dist/checkout/index.js +2 -0
- package/dist/company-identity/contracts.d.ts +220 -0
- package/dist/company-identity/contracts.js +167 -0
- package/dist/company-identity/index.d.ts +6 -0
- package/dist/company-identity/index.js +2 -0
- package/dist/company-identity/ports.d.ts +13 -0
- package/dist/company-identity/ports.js +1 -0
- package/dist/fulfillment/facts.d.ts +103 -0
- package/dist/fulfillment/facts.js +195 -0
- package/dist/fulfillment/index.d.ts +4 -0
- package/dist/fulfillment/index.js +2 -0
- package/dist/inventory/atp.d.ts +172 -0
- package/dist/inventory/atp.js +211 -0
- package/dist/inventory/index.d.ts +2 -0
- package/dist/inventory/index.js +2 -0
- package/dist/marketing/research/contracts.d.ts +75 -0
- package/dist/marketing/research/contracts.js +60 -0
- package/dist/marketing/research/index.d.ts +4 -0
- package/dist/marketing/research/index.js +4 -0
- package/dist/marketing/research/ports.d.ts +15 -0
- package/dist/marketing/research/ports.js +1 -0
- package/dist/partners/index.d.ts +4 -0
- package/dist/partners/index.js +4 -0
- package/dist/partners/ports.d.ts +12 -0
- package/dist/partners/ports.js +1 -0
- package/dist/partners/status.d.ts +48 -0
- package/dist/partners/status.js +35 -0
- package/dist/payment/executionBaseContracts.d.ts +81 -0
- package/dist/payment/executionBaseContracts.js +8 -0
- package/dist/payment/index.d.ts +36 -0
- package/dist/payment/index.js +18 -0
- package/dist/payment/methodLifecycle.d.ts +91 -0
- package/dist/payment/methodLifecycle.js +76 -0
- package/dist/payment/paymentControlTypes.d.ts +135 -0
- package/dist/payment/paymentControlTypes.js +110 -0
- package/dist/payment/paymentFailureTaxonomy.d.ts +80 -0
- package/dist/payment/paymentFailureTaxonomy.js +155 -0
- package/dist/payment/paymentFailureTaxonomyContracts.d.ts +106 -0
- package/dist/payment/paymentFailureTaxonomyContracts.js +177 -0
- package/dist/payment/paymentRecovery.d.ts +26 -0
- package/dist/payment/paymentRecovery.js +73 -0
- package/dist/payment/paymentRecoveryContracts.d.ts +53 -0
- package/dist/payment/paymentRecoveryContracts.js +10 -0
- package/dist/payment/paymentStateMachine.d.ts +28 -0
- package/dist/payment/paymentStateMachine.js +184 -0
- package/dist/payment/providerAttemptIdempotency.d.ts +23 -0
- package/dist/payment/providerAttemptIdempotency.js +31 -0
- package/dist/payment/providerCapability.d.ts +148 -0
- package/dist/payment/providerCapability.js +13 -0
- package/dist/platform-runtime/contracts.d.ts +9 -0
- package/dist/platform-runtime/contracts.js +13 -0
- package/dist/platform-runtime/index.d.ts +10 -0
- package/dist/platform-runtime/index.js +4 -0
- package/dist/platform-runtime/platformKernel.d.ts +44 -0
- package/dist/platform-runtime/platformKernel.js +39 -0
- package/dist/platform-runtime/ports.d.ts +100 -0
- package/dist/platform-runtime/ports.js +1 -0
- package/dist/pricing/index.d.ts +8 -0
- package/dist/pricing/index.js +8 -0
- package/dist/pricing/ports.d.ts +22 -0
- package/dist/pricing/ports.js +7 -0
- package/dist/pricing/pricingBreakdown.d.ts +86 -0
- package/dist/pricing/pricingBreakdown.js +155 -0
- package/dist/pricing/targetPriceAllocation.d.ts +97 -0
- package/dist/pricing/targetPriceAllocation.js +202 -0
- package/dist/pricing/types.d.ts +32 -0
- package/dist/pricing/types.js +4 -0
- package/dist/promo/adjustmentEngine.d.ts +4 -0
- package/dist/promo/adjustmentEngine.js +194 -0
- package/dist/promo/adjustmentTypes.d.ts +103 -0
- package/dist/promo/adjustmentTypes.js +13 -0
- package/dist/promo/index.d.ts +8 -0
- package/dist/promo/index.js +8 -0
- package/dist/promo/promoEvaluator.d.ts +25 -0
- package/dist/promo/promoEvaluator.js +135 -0
- package/dist/promo/types.d.ts +86 -0
- package/dist/promo/types.js +12 -0
- package/dist/risk/core/evaluator.d.ts +3 -0
- package/dist/risk/core/evaluator.js +32 -0
- package/dist/risk/core/rules.d.ts +3 -0
- package/dist/risk/core/rules.js +118 -0
- package/dist/risk/core/types.d.ts +65 -0
- package/dist/risk/core/types.js +1 -0
- package/dist/risk/index.d.ts +8 -0
- package/dist/risk/index.js +8 -0
- package/dist/risk/types.d.ts +32 -0
- package/dist/risk/types.js +43 -0
- package/dist/shipping/index.d.ts +4 -0
- package/dist/shipping/index.js +4 -0
- package/dist/shipping/shippingResolver.d.ts +26 -0
- package/dist/shipping/shippingResolver.js +88 -0
- package/dist/shipping/types.d.ts +45 -0
- package/dist/shipping/types.js +4 -0
- package/dist/subscription/cycleHardening.d.ts +95 -0
- package/dist/subscription/cycleHardening.js +118 -0
- package/dist/subscription/index.d.ts +14 -0
- package/dist/subscription/index.js +14 -0
- package/dist/subscription/subscriptionEngineCore.d.ts +13 -0
- package/dist/subscription/subscriptionEngineCore.js +145 -0
- package/dist/subscription/subscriptionEngineLifecycle.d.ts +150 -0
- package/dist/subscription/subscriptionEngineLifecycle.js +433 -0
- package/dist/subscription/subscriptionEnginePayment.d.ts +25 -0
- package/dist/subscription/subscriptionEnginePayment.js +127 -0
- package/dist/subscription/subscriptionEngineTypes.d.ts +94 -0
- package/dist/subscription/subscriptionEngineTypes.js +4 -0
- package/dist/subscription/subscriptionEngineUtils.d.ts +32 -0
- package/dist/subscription/subscriptionEngineUtils.js +74 -0
- package/dist/subscription/types.d.ts +58 -0
- package/dist/subscription/types.js +47 -0
- package/dist/testing.d.ts +48 -0
- package/dist/testing.js +105 -0
- package/package.json +215 -4
- package/release-gates.json +232 -0
- package/src/bundle/contracts.ts +15 -0
- package/src/bundle/ports.ts +32 -0
- package/src/catalog/identifiers.ts +50 -0
- package/src/catalog/index.ts +2 -0
- package/src/checkout/idempotency.ts +49 -0
- package/src/checkout/index.ts +2 -0
- package/src/company-identity/contracts.ts +208 -0
- package/src/company-identity/index.ts +28 -0
- package/src/company-identity/ports.ts +19 -0
- package/src/fulfillment/facts.ts +300 -0
- package/src/fulfillment/index.ts +34 -0
- package/src/inventory/atp.ts +249 -0
- package/src/inventory/index.ts +2 -0
- package/src/marketing/research/contracts.ts +91 -0
- package/src/marketing/research/index.ts +4 -0
- package/src/marketing/research/ports.ts +26 -0
- package/src/partners/index.ts +4 -0
- package/src/partners/ports.ts +18 -0
- package/src/partners/status.ts +49 -0
- package/src/payment/executionBaseContracts.ts +93 -0
- package/src/payment/index.ts +128 -0
- package/src/payment/methodLifecycle.ts +130 -0
- package/src/payment/paymentControlTypes.ts +197 -0
- package/src/payment/paymentFailureTaxonomy.ts +201 -0
- package/src/payment/paymentFailureTaxonomyContracts.ts +228 -0
- package/src/payment/paymentRecovery.ts +95 -0
- package/src/payment/paymentRecoveryContracts.ts +61 -0
- package/src/payment/paymentStateMachine.ts +263 -0
- package/src/payment/providerAttemptIdempotency.ts +57 -0
- package/src/payment/providerCapability.ts +172 -0
- package/src/platform-runtime/contracts.ts +33 -0
- package/src/platform-runtime/index.ts +36 -0
- package/src/platform-runtime/platformKernel.ts +86 -0
- package/src/platform-runtime/ports.ts +88 -0
- package/src/pricing/index.ts +8 -0
- package/src/pricing/ports.ts +27 -0
- package/src/pricing/pricingBreakdown.ts +243 -0
- package/src/pricing/targetPriceAllocation.ts +293 -0
- package/src/pricing/types.ts +36 -0
- package/src/promo/adjustmentEngine.ts +253 -0
- package/src/promo/adjustmentTypes.ts +94 -0
- package/src/promo/index.ts +8 -0
- package/src/promo/promoEvaluator.ts +137 -0
- package/src/promo/types.ts +97 -0
- package/src/risk/core/evaluator.ts +41 -0
- package/src/risk/core/rules.ts +128 -0
- package/src/risk/core/types.ts +73 -0
- package/src/risk/index.ts +8 -0
- package/src/risk/types.ts +60 -0
- package/src/shipping/index.ts +4 -0
- package/src/shipping/shippingResolver.ts +101 -0
- package/src/shipping/types.ts +49 -0
- package/src/subscription/cycleHardening.ts +174 -0
- package/src/subscription/index.ts +14 -0
- package/src/subscription/subscriptionEngineCore.ts +181 -0
- package/src/subscription/subscriptionEngineLifecycle.ts +574 -0
- package/src/subscription/subscriptionEnginePayment.ts +179 -0
- package/src/subscription/subscriptionEngineTypes.ts +114 -0
- package/src/subscription/subscriptionEngineUtils.ts +110 -0
- package/src/subscription/types.ts +104 -0
- package/src/testing.ts +169 -0
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
import { isEditWindowOpen } from "./subscriptionEngineCore.js";
|
|
2
|
+
import { DEFAULT_CYCLE_RETRY_CADENCE, isDunningLadderExhausted, } from "./cycleHardening.js";
|
|
3
|
+
import { addCadenceDays, cloneTemplate, failure, makeEvent, parseIso, statusPatch, success, touchSubscription, validateTemplate, } from "./subscriptionEngineUtils.js";
|
|
4
|
+
/** @beta */
|
|
5
|
+
export function swapTemplateLine(input) {
|
|
6
|
+
if (!isEditWindowOpen(input.subscription, input.now)) {
|
|
7
|
+
return failure("edit_window_closed", "template swaps are locked after the edit cutoff");
|
|
8
|
+
}
|
|
9
|
+
if (input.subscription.template.lines.some((line) => line.variant_id === input.toVariantId)) {
|
|
10
|
+
return failure("duplicate_line", "target variant is already present in the template");
|
|
11
|
+
}
|
|
12
|
+
let replaced = false;
|
|
13
|
+
const lines = input.subscription.template.lines.map((line) => {
|
|
14
|
+
if (line.variant_id !== input.fromVariantId)
|
|
15
|
+
return { ...line };
|
|
16
|
+
replaced = true;
|
|
17
|
+
return { ...line, variant_id: input.toVariantId };
|
|
18
|
+
});
|
|
19
|
+
if (!replaced) {
|
|
20
|
+
return failure("line_not_found", "source variant is not present in the template");
|
|
21
|
+
}
|
|
22
|
+
return updateTemplate({
|
|
23
|
+
subscription: input.subscription,
|
|
24
|
+
template: { ...cloneTemplate(input.subscription.template), lines },
|
|
25
|
+
now: input.now,
|
|
26
|
+
eventType: "subscription.template_updated",
|
|
27
|
+
payload: { operation: "swap", fromVariantId: input.fromVariantId, toVariantId: input.toVariantId },
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
/** @beta */
|
|
31
|
+
export function addPermanentAddon(input) {
|
|
32
|
+
if (!isEditWindowOpen(input.subscription, input.now)) {
|
|
33
|
+
return failure("edit_window_closed", "template add-ons are locked after the edit cutoff");
|
|
34
|
+
}
|
|
35
|
+
if (input.subscription.template.lines.some((line) => line.variant_id === input.line.variant_id)) {
|
|
36
|
+
return failure("duplicate_line", "add-on variant is already present in the template");
|
|
37
|
+
}
|
|
38
|
+
const nextSortOrder = input.line.sort_order ??
|
|
39
|
+
Math.max(0, ...input.subscription.template.lines.map((line) => line.sort_order)) + 1;
|
|
40
|
+
const template = cloneTemplate(input.subscription.template);
|
|
41
|
+
template.lines = [
|
|
42
|
+
...template.lines,
|
|
43
|
+
{
|
|
44
|
+
variant_id: input.line.variant_id,
|
|
45
|
+
qty: input.line.qty,
|
|
46
|
+
sort_order: nextSortOrder,
|
|
47
|
+
is_addon: true,
|
|
48
|
+
},
|
|
49
|
+
];
|
|
50
|
+
return updateTemplate({
|
|
51
|
+
subscription: input.subscription,
|
|
52
|
+
template,
|
|
53
|
+
now: input.now,
|
|
54
|
+
eventType: "subscription.template_updated",
|
|
55
|
+
payload: { operation: "add_permanent_addon", variantId: input.line.variant_id },
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
/** @beta */
|
|
59
|
+
export function skipNextCycle(input) {
|
|
60
|
+
const now = parseIso(input.now);
|
|
61
|
+
if (!now)
|
|
62
|
+
return failure("invalid_timestamp", "now must be a valid ISO timestamp");
|
|
63
|
+
if (input.subscription.status !== "active") {
|
|
64
|
+
return failure("inactive_subscription", "only active subscriptions can skip cycles");
|
|
65
|
+
}
|
|
66
|
+
const nextCycleAt = addCadenceDays(input.subscription.nextCycleAt, input.subscription.template.cadence_days);
|
|
67
|
+
if (!nextCycleAt)
|
|
68
|
+
return failure("invalid_timestamp", "subscription.nextCycleAt must be a valid ISO timestamp");
|
|
69
|
+
const subscription = touchSubscription(input.subscription, input.now, { nextCycleAt });
|
|
70
|
+
return success({
|
|
71
|
+
subscription,
|
|
72
|
+
events: [
|
|
73
|
+
makeEvent({
|
|
74
|
+
eventType: "subscription.cycle_skipped",
|
|
75
|
+
subscription: input.subscription,
|
|
76
|
+
idempotencyKey: `${input.subscription.id}:${input.subscription.nextCycleAt}:cycle_skipped`,
|
|
77
|
+
occurredAt: now.toISOString(),
|
|
78
|
+
payload: { skippedScheduledAt: input.subscription.nextCycleAt, reason: input.reason ?? null },
|
|
79
|
+
}),
|
|
80
|
+
makeEvent({
|
|
81
|
+
eventType: "subscription.next_cycle_at_updated",
|
|
82
|
+
subscription,
|
|
83
|
+
idempotencyKey: `${subscription.id}:${subscription.nextCycleAt}:next_cycle_at_updated`,
|
|
84
|
+
occurredAt: now.toISOString(),
|
|
85
|
+
payload: { nextCycleAt },
|
|
86
|
+
}),
|
|
87
|
+
],
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
/** @beta */
|
|
91
|
+
export function slideNextCycle(input) {
|
|
92
|
+
const now = parseIso(input.now);
|
|
93
|
+
const newNextCycleAt = parseIso(input.newNextCycleAt);
|
|
94
|
+
if (!now || !newNextCycleAt) {
|
|
95
|
+
return failure("invalid_timestamp", "now and newNextCycleAt must be valid ISO timestamps");
|
|
96
|
+
}
|
|
97
|
+
if (input.subscription.status !== "active") {
|
|
98
|
+
return failure("inactive_subscription", "only active subscriptions can slide cycles");
|
|
99
|
+
}
|
|
100
|
+
if (newNextCycleAt.getTime() <= now.getTime()) {
|
|
101
|
+
return failure("invalid_slide_target", "newNextCycleAt must be in the future");
|
|
102
|
+
}
|
|
103
|
+
const subscription = touchSubscription(input.subscription, now.toISOString(), {
|
|
104
|
+
nextCycleAt: newNextCycleAt.toISOString(),
|
|
105
|
+
});
|
|
106
|
+
return success({
|
|
107
|
+
subscription,
|
|
108
|
+
events: [
|
|
109
|
+
makeEvent({
|
|
110
|
+
eventType: "subscription.next_cycle_at_updated",
|
|
111
|
+
subscription,
|
|
112
|
+
idempotencyKey: `${subscription.id}:${subscription.nextCycleAt}:slide`,
|
|
113
|
+
occurredAt: now.toISOString(),
|
|
114
|
+
payload: {
|
|
115
|
+
previousNextCycleAt: input.subscription.nextCycleAt,
|
|
116
|
+
nextCycleAt: subscription.nextCycleAt,
|
|
117
|
+
reason: input.reason ?? null,
|
|
118
|
+
},
|
|
119
|
+
}),
|
|
120
|
+
],
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
/** @beta */
|
|
124
|
+
export function pauseSubscription(input) {
|
|
125
|
+
return changeSubscriptionStatus(input.subscription, "paused", input.now, "subscription.paused", {
|
|
126
|
+
reason: input.reason ?? null,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
/** @beta */
|
|
130
|
+
export function resumeSubscription(input) {
|
|
131
|
+
// Resume leaves `paused` only. Leaving `cancelled` is a win-back and goes
|
|
132
|
+
// through reactivateSubscription, which re-arms the schedule. A subscription
|
|
133
|
+
// the exhausted ladder paused must resume through
|
|
134
|
+
// resumeSubscriptionFromExpiredDunning, which skips the uncollected cycle.
|
|
135
|
+
return changeSubscriptionStatus(input.subscription, "active", input.now, "subscription.resumed", {}, ["paused"]);
|
|
136
|
+
}
|
|
137
|
+
/** @beta */
|
|
138
|
+
export function cancelSubscription(input) {
|
|
139
|
+
return changeSubscriptionStatus(input.subscription, "cancelled", input.now, "subscription.cancelled", {
|
|
140
|
+
reason: input.reason ?? null,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
/** @beta */
|
|
144
|
+
export function completeSubscription(input) {
|
|
145
|
+
return changeSubscriptionStatus(input.subscription, "completed", input.now, "subscription.completed", {});
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Win-back: the owner reactivates their own cancelled subscription.
|
|
149
|
+
*
|
|
150
|
+
* Mirrors the `cancelled -> active` edge of the managed SQL guard
|
|
151
|
+
* `public.subscription_guard_status_transition` and the customer `reactivate`
|
|
152
|
+
* action behind it. A reactivation charges again, so it needs a stored payment
|
|
153
|
+
* method, and it re-arms the next cycle {@link RESTART_LEAD_DAYS} out rather
|
|
154
|
+
* than leaving a stale instant that would make the renewal due immediately.
|
|
155
|
+
* The SQL action also clears the end instant and cancellation reason, requires
|
|
156
|
+
* the customer's confirmation of the charge timing, refuses while a cycle is
|
|
157
|
+
* locked and bumps the template version; those durable duties stay with the
|
|
158
|
+
* host.
|
|
159
|
+
*
|
|
160
|
+
* @beta
|
|
161
|
+
*/
|
|
162
|
+
export function reactivateSubscription(input) {
|
|
163
|
+
const now = parseIso(input.now);
|
|
164
|
+
if (!now)
|
|
165
|
+
return failure("invalid_timestamp", "now must be a valid ISO timestamp");
|
|
166
|
+
if (input.subscription.status !== "cancelled") {
|
|
167
|
+
return failure("invalid_transition", `Cannot reactivate a subscription that is ${input.subscription.status}; only cancelled subscriptions can be won back`);
|
|
168
|
+
}
|
|
169
|
+
if (!input.subscription.paymentMethodRef) {
|
|
170
|
+
return failure("missing_payment_method", "reactivation requires a stored payment method");
|
|
171
|
+
}
|
|
172
|
+
const nextCycleAt = restartNextCycleAt(input.subscription, now);
|
|
173
|
+
if (!nextCycleAt)
|
|
174
|
+
return failure("invalid_timestamp", "restart exceeds the supported timestamp range");
|
|
175
|
+
return restart({
|
|
176
|
+
subscription: input.subscription,
|
|
177
|
+
now,
|
|
178
|
+
nextCycleAt,
|
|
179
|
+
idempotencyPrefix: `${input.subscription.id}:reactivated:${now.toISOString()}`,
|
|
180
|
+
payload: { reactivatedFrom: "cancelled", reason: input.reason ?? null },
|
|
181
|
+
leadingEvents: [],
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* The sanctioned non-payment rule: a cycle that exhausted the retry ladder
|
|
186
|
+
* pauses an ACTIVE subscription.
|
|
187
|
+
*
|
|
188
|
+
* This is the only path in the engine that suspends a live subscription for a
|
|
189
|
+
* reason the owner did not choose, and it fires only when
|
|
190
|
+
* {@link isDunningLadderExhausted} holds for the refused cycle. A ladder cut
|
|
191
|
+
* short by a terminating refusal class, or a failure on any rung that still has
|
|
192
|
+
* a retry after it, is refused as `dunning_not_exhausted` and leaves the
|
|
193
|
+
* subscription untouched. A subscription that is not active (already paused,
|
|
194
|
+
* cancelled or completed) is returned unchanged with no event, which is what
|
|
195
|
+
* the managed SQL dunning boundary does and what makes a replay harmless.
|
|
196
|
+
*
|
|
197
|
+
* The schedule is not touched: pausing never moves `nextCycleAt`.
|
|
198
|
+
*
|
|
199
|
+
* @beta
|
|
200
|
+
*/
|
|
201
|
+
export function pauseSubscriptionForExpiredDunning(input) {
|
|
202
|
+
const now = parseIso(input.now);
|
|
203
|
+
if (!now)
|
|
204
|
+
return failure("invalid_timestamp", "now must be a valid ISO timestamp");
|
|
205
|
+
const mismatch = cycleOwnershipError(input.subscription, input.cycle);
|
|
206
|
+
if (mismatch)
|
|
207
|
+
return mismatch;
|
|
208
|
+
if (!isDunningLadderExhausted(input.cycle, input.cadence ?? DEFAULT_CYCLE_RETRY_CADENCE)) {
|
|
209
|
+
return failure("dunning_not_exhausted", "only a cycle that exhausted the retry ladder may pause a subscription for non-payment");
|
|
210
|
+
}
|
|
211
|
+
if (input.subscription.status !== "active") {
|
|
212
|
+
return success({ subscription: touchSubscription(input.subscription, input.subscription.updatedAt, {}), events: [] });
|
|
213
|
+
}
|
|
214
|
+
const subscription = touchSubscription(input.subscription, now.toISOString(), statusPatch("paused"));
|
|
215
|
+
return success({
|
|
216
|
+
subscription,
|
|
217
|
+
events: [
|
|
218
|
+
makeEvent({
|
|
219
|
+
eventType: "subscription.paused",
|
|
220
|
+
subscription,
|
|
221
|
+
cycleNumber: input.cycle.cycleNumber,
|
|
222
|
+
idempotencyKey: `${input.cycle.engineIdempotencyKey}:subscription_paused`,
|
|
223
|
+
occurredAt: now.toISOString(),
|
|
224
|
+
payload: {
|
|
225
|
+
reason: "payment_failed_expired",
|
|
226
|
+
retryAttempt: input.cycle.retryAttempt,
|
|
227
|
+
failureReason: input.cycle.failureReason,
|
|
228
|
+
},
|
|
229
|
+
}),
|
|
230
|
+
],
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Customer-initiated recovery out of the non-payment pause.
|
|
235
|
+
*
|
|
236
|
+
* Mirrors the managed SQL `subscription_resume_after_expired_dunning`: the
|
|
237
|
+
* subscription must be paused and the cycle it passes must be the refused cycle
|
|
238
|
+
* that exhausted the ladder (the caller passes the LATEST such cycle, as the SQL
|
|
239
|
+
* boundary reads the latest dunning case). A new payment method, when given,
|
|
240
|
+
* replaces the stored one; without any method the resume is refused. Whether
|
|
241
|
+
* the method is chargeable unattended is the host's check, as it is in SQL.
|
|
242
|
+
* The given cycle becomes `skipped` rather than being charged late; the SQL
|
|
243
|
+
* boundary skips every uncollected cycle, so a host that holds more than one
|
|
244
|
+
* must skip the rest itself. The next cycle is re-armed
|
|
245
|
+
* {@link RESTART_LEAD_DAYS} out.
|
|
246
|
+
*
|
|
247
|
+
* @beta
|
|
248
|
+
*/
|
|
249
|
+
export function resumeSubscriptionFromExpiredDunning(input) {
|
|
250
|
+
const now = parseIso(input.now);
|
|
251
|
+
if (!now)
|
|
252
|
+
return failure("invalid_timestamp", "now must be a valid ISO timestamp");
|
|
253
|
+
if (input.subscription.status !== "paused") {
|
|
254
|
+
return failure("invalid_transition", `Cannot resume a ${input.subscription.status} subscription from expired dunning; it must be paused`);
|
|
255
|
+
}
|
|
256
|
+
const mismatch = cycleOwnershipError(input.subscription, input.cycle);
|
|
257
|
+
if (mismatch)
|
|
258
|
+
return mismatch;
|
|
259
|
+
if (!isDunningLadderExhausted(input.cycle, input.cadence ?? DEFAULT_CYCLE_RETRY_CADENCE)) {
|
|
260
|
+
return failure("dunning_not_exhausted", "the cycle did not exhaust the retry ladder");
|
|
261
|
+
}
|
|
262
|
+
const replacementRef = input.paymentMethodRef?.trim() ? input.paymentMethodRef : null;
|
|
263
|
+
const method = replacementRef
|
|
264
|
+
? {
|
|
265
|
+
paymentMethodRef: replacementRef,
|
|
266
|
+
paymentMethodKind: input.paymentMethodKind?.trim() || input.subscription.paymentMethodKind,
|
|
267
|
+
}
|
|
268
|
+
: {};
|
|
269
|
+
const withMethod = { ...input.subscription, ...method };
|
|
270
|
+
if (!withMethod.paymentMethodRef) {
|
|
271
|
+
return failure("missing_payment_method", "resuming requires a chargeable payment method");
|
|
272
|
+
}
|
|
273
|
+
const nextCycleAt = restartNextCycleAt(withMethod, now);
|
|
274
|
+
if (!nextCycleAt)
|
|
275
|
+
return failure("invalid_timestamp", "restart exceeds the supported timestamp range");
|
|
276
|
+
const cycle = { ...input.cycle, status: "skipped", nextRetryAt: null };
|
|
277
|
+
const restarted = restart({
|
|
278
|
+
subscription: withMethod,
|
|
279
|
+
now,
|
|
280
|
+
nextCycleAt,
|
|
281
|
+
idempotencyPrefix: `${input.cycle.engineIdempotencyKey}:resumed_after_expired_dunning`,
|
|
282
|
+
payload: {
|
|
283
|
+
resumedAfterExpiredDunning: true,
|
|
284
|
+
skippedCycleNumber: cycle.cycleNumber,
|
|
285
|
+
},
|
|
286
|
+
leadingEvents: [
|
|
287
|
+
makeEvent({
|
|
288
|
+
eventType: "subscription.cycle_skipped",
|
|
289
|
+
subscription: withMethod,
|
|
290
|
+
cycleNumber: cycle.cycleNumber,
|
|
291
|
+
idempotencyKey: `${input.cycle.engineIdempotencyKey}:cycle_skipped`,
|
|
292
|
+
occurredAt: now.toISOString(),
|
|
293
|
+
payload: {
|
|
294
|
+
skippedScheduledAt: cycle.scheduledAt,
|
|
295
|
+
reason: "subscription_resumed_after_expired_dunning",
|
|
296
|
+
},
|
|
297
|
+
}),
|
|
298
|
+
],
|
|
299
|
+
});
|
|
300
|
+
if (restarted.ok === false)
|
|
301
|
+
return restarted;
|
|
302
|
+
return success({ ...restarted.value, cycle });
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* How far out a restarted subscription's next cycle is re-armed, in days. Both
|
|
306
|
+
* managed SQL restart paths (the customer `reactivate` action and
|
|
307
|
+
* `subscription_resume_after_expired_dunning`) spell it `interval '2 days'`.
|
|
308
|
+
*
|
|
309
|
+
* @beta
|
|
310
|
+
*/
|
|
311
|
+
export const RESTART_LEAD_DAYS = 2;
|
|
312
|
+
function updateTemplate(input) {
|
|
313
|
+
const now = parseIso(input.now);
|
|
314
|
+
if (!now)
|
|
315
|
+
return failure("invalid_timestamp", "now must be a valid ISO timestamp");
|
|
316
|
+
const template = cloneTemplate(input.template);
|
|
317
|
+
const templateError = validateTemplate(template);
|
|
318
|
+
if (templateError)
|
|
319
|
+
return templateError;
|
|
320
|
+
const subscription = touchSubscription(input.subscription, now.toISOString(), {
|
|
321
|
+
template,
|
|
322
|
+
templateVersion: input.subscription.templateVersion + 1,
|
|
323
|
+
});
|
|
324
|
+
return success({
|
|
325
|
+
subscription,
|
|
326
|
+
events: [
|
|
327
|
+
makeEvent({
|
|
328
|
+
eventType: input.eventType,
|
|
329
|
+
subscription,
|
|
330
|
+
idempotencyKey: `${subscription.id}:template:${subscription.templateVersion}`,
|
|
331
|
+
occurredAt: now.toISOString(),
|
|
332
|
+
payload: { ...input.payload, templateVersion: subscription.templateVersion },
|
|
333
|
+
}),
|
|
334
|
+
],
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
function changeSubscriptionStatus(subscription, status, nowIso, eventType, payload, allowedFrom) {
|
|
338
|
+
const now = parseIso(nowIso);
|
|
339
|
+
if (!now)
|
|
340
|
+
return failure("invalid_timestamp", "now must be a valid ISO timestamp");
|
|
341
|
+
if ((allowedFrom !== undefined && !allowedFrom.includes(subscription.status)) ||
|
|
342
|
+
!canTransitionSubscriptionStatus(subscription.status, status)) {
|
|
343
|
+
return failure("invalid_transition", `Cannot move subscription from ${subscription.status} to ${status}`);
|
|
344
|
+
}
|
|
345
|
+
const next = touchSubscription(subscription, now.toISOString(), statusPatch(status));
|
|
346
|
+
return success({
|
|
347
|
+
subscription: next,
|
|
348
|
+
events: [
|
|
349
|
+
makeEvent({
|
|
350
|
+
eventType,
|
|
351
|
+
subscription: next,
|
|
352
|
+
idempotencyKey: `${next.id}:${eventType}:${now.toISOString()}`,
|
|
353
|
+
occurredAt: now.toISOString(),
|
|
354
|
+
payload,
|
|
355
|
+
}),
|
|
356
|
+
],
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
function restart(input) {
|
|
360
|
+
if (!canTransitionSubscriptionStatus(input.subscription.status, "active")) {
|
|
361
|
+
return failure("invalid_transition", `Cannot move subscription from ${input.subscription.status} to active`);
|
|
362
|
+
}
|
|
363
|
+
const occurredAt = input.now.toISOString();
|
|
364
|
+
const subscription = touchSubscription(input.subscription, occurredAt, {
|
|
365
|
+
...statusPatch("active"),
|
|
366
|
+
nextCycleAt: input.nextCycleAt,
|
|
367
|
+
});
|
|
368
|
+
const events = [
|
|
369
|
+
...input.leadingEvents,
|
|
370
|
+
makeEvent({
|
|
371
|
+
eventType: "subscription.resumed",
|
|
372
|
+
subscription,
|
|
373
|
+
idempotencyKey: `${input.idempotencyPrefix}:resumed`,
|
|
374
|
+
occurredAt,
|
|
375
|
+
payload: { ...input.payload, nextCycleAt: input.nextCycleAt },
|
|
376
|
+
}),
|
|
377
|
+
];
|
|
378
|
+
if (input.nextCycleAt !== input.subscription.nextCycleAt) {
|
|
379
|
+
events.push(makeEvent({
|
|
380
|
+
eventType: "subscription.next_cycle_at_updated",
|
|
381
|
+
subscription,
|
|
382
|
+
idempotencyKey: `${input.idempotencyPrefix}:next_cycle_at_updated`,
|
|
383
|
+
occurredAt,
|
|
384
|
+
payload: { previousNextCycleAt: input.subscription.nextCycleAt, nextCycleAt: input.nextCycleAt },
|
|
385
|
+
}));
|
|
386
|
+
}
|
|
387
|
+
return success({ subscription, events });
|
|
388
|
+
}
|
|
389
|
+
// A restart re-arms the schedule RESTART_LEAD_DAYS out, but never earlier than
|
|
390
|
+
// the instant already stored: the same monotonic clamp recordPaymentSuccess
|
|
391
|
+
// applies, so a restart cannot pull a charge into a window already paid for.
|
|
392
|
+
// The managed SQL restart paths assign `now + 2 days` without the clamp, so
|
|
393
|
+
// the two differ exactly when the stored instant is later than that: a win-back
|
|
394
|
+
// of a subscription paid through a later date, or a schedule skipped or slid
|
|
395
|
+
// while the ladder was still running. There SQL charges earlier; this does not.
|
|
396
|
+
function restartNextCycleAt(subscription, now) {
|
|
397
|
+
return new Date(Math.max(Date.parse(subscription.nextCycleAt) || 0, now.getTime() + RESTART_LEAD_DAYS * 24 * 60 * 60 * 1000)).toJSON();
|
|
398
|
+
}
|
|
399
|
+
function cycleOwnershipError(subscription, cycle) {
|
|
400
|
+
if (cycle.subscriptionId === subscription.id)
|
|
401
|
+
return null;
|
|
402
|
+
return failure("invalid_transition", "the cycle belongs to a different subscription");
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* The engine's subscription status matrix: every edge a status may take, and
|
|
406
|
+
* nothing else.
|
|
407
|
+
*
|
|
408
|
+
* It mirrors the managed SQL guard `public.subscription_guard_status_transition`
|
|
409
|
+
* over the four statuses the engine manages. The guard's remaining edges belong
|
|
410
|
+
* to the activation flow the engine never drives (`pending_activation` ->
|
|
411
|
+
* `active` / `activation_failed` / `cancelled`, and the audited expired-checkout
|
|
412
|
+
* reopen `cancelled` -> `pending_activation`). `completed` is terminal on both
|
|
413
|
+
* sides. `cancelled` -> `active` is the owner's win-back.
|
|
414
|
+
*
|
|
415
|
+
* Which OPERATION may take an edge is narrower than the matrix: `active` ->
|
|
416
|
+
* `paused` is the owner's pause or {@link pauseSubscriptionForExpiredDunning},
|
|
417
|
+
* and `cancelled` -> `active` is only {@link reactivateSubscription}.
|
|
418
|
+
*
|
|
419
|
+
* @beta
|
|
420
|
+
*/
|
|
421
|
+
export const SUBSCRIPTION_STATUS_TRANSITIONS = Object.freeze({
|
|
422
|
+
active: Object.freeze(["paused", "cancelled", "completed"]),
|
|
423
|
+
paused: Object.freeze(["active", "cancelled"]),
|
|
424
|
+
cancelled: Object.freeze(["active"]),
|
|
425
|
+
completed: Object.freeze([]),
|
|
426
|
+
});
|
|
427
|
+
/** @beta */
|
|
428
|
+
export function canTransitionSubscriptionStatus(from, to) {
|
|
429
|
+
// Own keys only: a status read from storage is an untyped string, and
|
|
430
|
+
// "constructor" or "__proto__" must answer false rather than throw.
|
|
431
|
+
return Object.prototype.hasOwnProperty.call(SUBSCRIPTION_STATUS_TRANSITIONS, from) &&
|
|
432
|
+
SUBSCRIPTION_STATUS_TRANSITIONS[from].includes(to);
|
|
433
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type CycleRetryCadence } from "./cycleHardening.js";
|
|
2
|
+
import type { CycleMutationResult, EngineCycle, EngineEvent, EngineSubscription, SubscriptionEngineResult } from "./subscriptionEngineTypes.js";
|
|
3
|
+
/** @beta */
|
|
4
|
+
export declare function recordPaymentSuccess(input: {
|
|
5
|
+
subscription: EngineSubscription;
|
|
6
|
+
cycle: EngineCycle;
|
|
7
|
+
paidAt: string;
|
|
8
|
+
recordedAt: string;
|
|
9
|
+
}): SubscriptionEngineResult<{
|
|
10
|
+
subscription: EngineSubscription;
|
|
11
|
+
cycle: EngineCycle;
|
|
12
|
+
events: EngineEvent[];
|
|
13
|
+
}>;
|
|
14
|
+
/** @beta */
|
|
15
|
+
export declare function recordPaymentFailure(input: {
|
|
16
|
+
cycle: EngineCycle;
|
|
17
|
+
failedAt: string;
|
|
18
|
+
reason: string;
|
|
19
|
+
failureClass?: string | null;
|
|
20
|
+
/**
|
|
21
|
+
* The deployment's ladder. Pass the same cadence to
|
|
22
|
+
* `pauseSubscriptionForExpiredDunning` so both agree on exhaustion.
|
|
23
|
+
*/
|
|
24
|
+
cadence?: CycleRetryCadence;
|
|
25
|
+
}): SubscriptionEngineResult<CycleMutationResult>;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { nextRetryAttemptAt } from "./cycleHardening.js";
|
|
2
|
+
import { addCadenceDays, failure, isTerminalCycle, makeEvent, parseIso, success, touchSubscription, } from "./subscriptionEngineUtils.js";
|
|
3
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
4
|
+
/** @beta */
|
|
5
|
+
export function recordPaymentSuccess(input) {
|
|
6
|
+
const paidAt = parseIso(input.paidAt);
|
|
7
|
+
if (!paidAt)
|
|
8
|
+
return failure("invalid_timestamp", "paidAt must be a valid ISO timestamp");
|
|
9
|
+
const recordedAt = parseIso(input.recordedAt);
|
|
10
|
+
if (!recordedAt)
|
|
11
|
+
return failure("invalid_timestamp", "recordedAt must be a valid ISO timestamp");
|
|
12
|
+
if (isTerminalCycle(input.cycle)) {
|
|
13
|
+
return failure("terminal_cycle", "terminal cycles cannot be paid");
|
|
14
|
+
}
|
|
15
|
+
// The next cycle is one cadence past the CYCLE'S OWN scheduled instant, not past
|
|
16
|
+
// the moment the money arrived: a late payment must not silently shorten the
|
|
17
|
+
// interval the customer bought. The whole days the payment was late are then
|
|
18
|
+
// ADDED, because a delivery that arrives late shifts the whole subscription
|
|
19
|
+
// later rather than letting the next one stack up behind it.
|
|
20
|
+
//
|
|
21
|
+
// Lateness is measured to the EARLIER of the payment instant and the instant we
|
|
22
|
+
// recorded it, so a provider timestamp from the future cannot inflate the shift
|
|
23
|
+
// and push the next charge out past what the customer actually owes.
|
|
24
|
+
//
|
|
25
|
+
// ⛔ The outer Math.max is the load-bearing part and is NOT an optimisation.
|
|
26
|
+
// It is a monotonic clamp: `nextCycleAt` may only ever move LATER, never
|
|
27
|
+
// earlier. Without it, a replayed webhook, an out-of-order settlement, or a
|
|
28
|
+
// manual correction applied against an older cycle could compute an EARLIER
|
|
29
|
+
// instant and pull a charge forward into a window the subscriber has already
|
|
30
|
+
// been billed for — the one failure mode this rail must never have. The same
|
|
31
|
+
// clamp is spelled `GREATEST` in the SQL delivery-alignment rail for exactly
|
|
32
|
+
// this reason; the two are one rule with two spellings.
|
|
33
|
+
//
|
|
34
|
+
// `Date.parse(...) || 0` is deliberate rather than defensive noise: a
|
|
35
|
+
// subscription whose stored instant is unreadable clamps against the epoch,
|
|
36
|
+
// which yields the computed instant, instead of poisoning the whole expression
|
|
37
|
+
// with NaN and returning an invalid date. Cadence keeps its own call so its own
|
|
38
|
+
// validity guard still fires and still reports the right error, and `toJSON()`
|
|
39
|
+
// answers null past the representable range rather than throwing, which is why
|
|
40
|
+
// the shift is range-checked below instead of trusted.
|
|
41
|
+
const onTimeNextCycleAt = addCadenceDays(input.cycle.scheduledAt, input.cycle.templateSnapshot.cadence_days);
|
|
42
|
+
if (!onTimeNextCycleAt)
|
|
43
|
+
return failure("invalid_timestamp", "cycle.scheduledAt must be a valid ISO timestamp");
|
|
44
|
+
const shiftedByDays = Math.max(0, Math.floor((Math.min(paidAt.getTime(), recordedAt.getTime()) - Date.parse(input.cycle.scheduledAt)) / DAY_MS));
|
|
45
|
+
const nextCycleAt = new Date(Math.max(Date.parse(input.subscription.nextCycleAt) || 0, Date.parse(onTimeNextCycleAt) + shiftedByDays * DAY_MS)).toJSON();
|
|
46
|
+
if (!nextCycleAt) {
|
|
47
|
+
return failure("invalid_timestamp", "payment shift exceeds the supported timestamp range");
|
|
48
|
+
}
|
|
49
|
+
const paidAtIso = paidAt.toISOString();
|
|
50
|
+
const cycle = {
|
|
51
|
+
...input.cycle,
|
|
52
|
+
status: "paid",
|
|
53
|
+
paidAt: paidAtIso,
|
|
54
|
+
retryAttempt: input.cycle.retryAttempt,
|
|
55
|
+
nextRetryAt: null,
|
|
56
|
+
failureReason: null,
|
|
57
|
+
};
|
|
58
|
+
const subscription = touchSubscription(input.subscription, recordedAt.toISOString(), { nextCycleAt });
|
|
59
|
+
return success({
|
|
60
|
+
subscription,
|
|
61
|
+
cycle,
|
|
62
|
+
events: [
|
|
63
|
+
makeEvent({
|
|
64
|
+
eventType: "subscription.cycle_paid",
|
|
65
|
+
subscription,
|
|
66
|
+
cycleNumber: cycle.cycleNumber,
|
|
67
|
+
idempotencyKey: `${cycle.engineIdempotencyKey}:cycle_paid`,
|
|
68
|
+
occurredAt: paidAtIso,
|
|
69
|
+
payload: { paidAt: cycle.paidAt, nextCycleAt },
|
|
70
|
+
}),
|
|
71
|
+
makeEvent({
|
|
72
|
+
eventType: "subscription.next_cycle_at_updated",
|
|
73
|
+
subscription,
|
|
74
|
+
cycleNumber: cycle.cycleNumber,
|
|
75
|
+
idempotencyKey: `${cycle.engineIdempotencyKey}:next_cycle_at_updated`,
|
|
76
|
+
occurredAt: paidAtIso,
|
|
77
|
+
payload: {
|
|
78
|
+
nextCycleAt,
|
|
79
|
+
basedOnScheduledAt: cycle.scheduledAt,
|
|
80
|
+
basedOnPaidAt: cycle.paidAt,
|
|
81
|
+
shiftedByDays,
|
|
82
|
+
},
|
|
83
|
+
}),
|
|
84
|
+
],
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
/** @beta */
|
|
88
|
+
export function recordPaymentFailure(input) {
|
|
89
|
+
const failedAt = parseIso(input.failedAt);
|
|
90
|
+
if (!failedAt)
|
|
91
|
+
return failure("invalid_timestamp", "failedAt must be a valid ISO timestamp");
|
|
92
|
+
if (isTerminalCycle(input.cycle)) {
|
|
93
|
+
return failure("terminal_cycle", "terminal cycles cannot be retried");
|
|
94
|
+
}
|
|
95
|
+
const retryAttempt = input.cycle.retryAttempt + 1;
|
|
96
|
+
// The ladder owns its terminal decision.
|
|
97
|
+
const nextRetryAt = nextRetryAttemptAt(failedAt.toISOString(), retryAttempt, input.cadence, input.failureClass);
|
|
98
|
+
const status = nextRetryAt ? "retry_scheduled" : "payment_failed";
|
|
99
|
+
const cycle = {
|
|
100
|
+
...input.cycle,
|
|
101
|
+
status,
|
|
102
|
+
retryAttempt,
|
|
103
|
+
nextRetryAt,
|
|
104
|
+
failureReason: input.reason,
|
|
105
|
+
};
|
|
106
|
+
const events = [
|
|
107
|
+
{
|
|
108
|
+
eventType: "subscription.payment_failed",
|
|
109
|
+
subscriptionId: cycle.subscriptionId,
|
|
110
|
+
cycleNumber: cycle.cycleNumber,
|
|
111
|
+
idempotencyKey: `${cycle.engineIdempotencyKey}:payment_failed:${retryAttempt}`,
|
|
112
|
+
occurredAt: failedAt.toISOString(),
|
|
113
|
+
payload: { reason: input.reason, retryAttempt },
|
|
114
|
+
},
|
|
115
|
+
];
|
|
116
|
+
if (nextRetryAt) {
|
|
117
|
+
events.push({
|
|
118
|
+
eventType: "subscription.retry_scheduled",
|
|
119
|
+
subscriptionId: cycle.subscriptionId,
|
|
120
|
+
cycleNumber: cycle.cycleNumber,
|
|
121
|
+
idempotencyKey: `${cycle.engineIdempotencyKey}:retry_scheduled:${retryAttempt}`,
|
|
122
|
+
occurredAt: failedAt.toISOString(),
|
|
123
|
+
payload: { nextRetryAt, retryAttempt },
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
return success({ cycle, events });
|
|
127
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { SubscriptionCycleStatus, SubscriptionEngineEventType, SubscriptionStatus, SubscriptionTemplateSnapshot } from "./types.js";
|
|
2
|
+
/** @beta */
|
|
3
|
+
export declare const DEFAULT_EDIT_WINDOW_HOURS = 72;
|
|
4
|
+
/** @beta */
|
|
5
|
+
export declare const DEFAULT_TIMEZONE = "UTC";
|
|
6
|
+
/** @beta */
|
|
7
|
+
export type SubscriptionEngineErrorCode = "invalid_timestamp" | "invalid_template" | "inactive_subscription" | "edit_window_closed" | "line_not_found" | "duplicate_line" | "invalid_slide_target" | "invalid_transition" | "terminal_cycle" | "missing_payment_method" | "dunning_not_exhausted";
|
|
8
|
+
/** @beta */
|
|
9
|
+
export type SubscriptionEngineResult<T> = {
|
|
10
|
+
ok: true;
|
|
11
|
+
value: T;
|
|
12
|
+
} | {
|
|
13
|
+
ok: false;
|
|
14
|
+
error: {
|
|
15
|
+
code: SubscriptionEngineErrorCode;
|
|
16
|
+
message: string;
|
|
17
|
+
};
|
|
18
|
+
};
|
|
19
|
+
/** @beta */
|
|
20
|
+
export interface EngineSubscription {
|
|
21
|
+
id: string;
|
|
22
|
+
clientRef: string;
|
|
23
|
+
status: SubscriptionStatus;
|
|
24
|
+
template: SubscriptionTemplateSnapshot;
|
|
25
|
+
templateVersion: number;
|
|
26
|
+
nextCycleAt: string;
|
|
27
|
+
paymentMethodRef: string | null;
|
|
28
|
+
paymentMethodKind: string | null;
|
|
29
|
+
timezone: string;
|
|
30
|
+
createdAt: string;
|
|
31
|
+
updatedAt: string;
|
|
32
|
+
}
|
|
33
|
+
/** @beta */
|
|
34
|
+
export interface EngineCycle {
|
|
35
|
+
subscriptionId: string;
|
|
36
|
+
cycleNumber: number;
|
|
37
|
+
status: SubscriptionCycleStatus;
|
|
38
|
+
scheduledAt: string;
|
|
39
|
+
paidAt: string | null;
|
|
40
|
+
templateVersion: number;
|
|
41
|
+
templateSnapshot: SubscriptionTemplateSnapshot;
|
|
42
|
+
pricingSnapshot: Record<string, unknown>;
|
|
43
|
+
paymentMethodRef: string | null;
|
|
44
|
+
retryAttempt: number;
|
|
45
|
+
nextRetryAt: string | null;
|
|
46
|
+
failureReason: string | null;
|
|
47
|
+
engineIdempotencyKey: string;
|
|
48
|
+
}
|
|
49
|
+
/** @beta */
|
|
50
|
+
export interface EngineEvent {
|
|
51
|
+
eventType: SubscriptionEngineEventType;
|
|
52
|
+
subscriptionId: string;
|
|
53
|
+
cycleNumber?: number;
|
|
54
|
+
idempotencyKey: string;
|
|
55
|
+
occurredAt: string;
|
|
56
|
+
payload: Record<string, unknown>;
|
|
57
|
+
}
|
|
58
|
+
/** @beta */
|
|
59
|
+
export interface CreateInitialSubscriptionCheckoutInput {
|
|
60
|
+
subscriptionId: string;
|
|
61
|
+
clientRef: string;
|
|
62
|
+
template: SubscriptionTemplateSnapshot;
|
|
63
|
+
paymentMethodRef?: string | null;
|
|
64
|
+
paymentMethodKind?: string | null;
|
|
65
|
+
firstCycleAt: string;
|
|
66
|
+
now: string;
|
|
67
|
+
pricingSnapshot: Record<string, unknown>;
|
|
68
|
+
idempotencyKey: string;
|
|
69
|
+
timezone?: string | null;
|
|
70
|
+
}
|
|
71
|
+
/** @beta */
|
|
72
|
+
export interface PlanCycleInput {
|
|
73
|
+
subscription: EngineSubscription;
|
|
74
|
+
cycleNumber: number;
|
|
75
|
+
now: string;
|
|
76
|
+
pricingSnapshot: Record<string, unknown>;
|
|
77
|
+
idempotencyKey: string;
|
|
78
|
+
}
|
|
79
|
+
/** @beta */
|
|
80
|
+
export interface SubscriptionMutationResult {
|
|
81
|
+
subscription: EngineSubscription;
|
|
82
|
+
events: EngineEvent[];
|
|
83
|
+
}
|
|
84
|
+
/** @beta */
|
|
85
|
+
export interface CycleMutationResult {
|
|
86
|
+
cycle: EngineCycle;
|
|
87
|
+
events: EngineEvent[];
|
|
88
|
+
}
|
|
89
|
+
/** @beta */
|
|
90
|
+
export interface InitialSubscriptionCheckoutModel {
|
|
91
|
+
subscription: EngineSubscription;
|
|
92
|
+
cycle: EngineCycle;
|
|
93
|
+
events: EngineEvent[];
|
|
94
|
+
}
|