@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,179 @@
|
|
|
1
|
+
import { nextRetryAttemptAt, type CycleRetryCadence } from "./cycleHardening.js";
|
|
2
|
+
import type { SubscriptionCycleStatus } from "./types.js";
|
|
3
|
+
import type {
|
|
4
|
+
CycleMutationResult,
|
|
5
|
+
EngineCycle,
|
|
6
|
+
EngineEvent,
|
|
7
|
+
EngineSubscription,
|
|
8
|
+
SubscriptionEngineResult,
|
|
9
|
+
} from "./subscriptionEngineTypes.js";
|
|
10
|
+
import {
|
|
11
|
+
addCadenceDays,
|
|
12
|
+
failure,
|
|
13
|
+
isTerminalCycle,
|
|
14
|
+
makeEvent,
|
|
15
|
+
parseIso,
|
|
16
|
+
success,
|
|
17
|
+
touchSubscription,
|
|
18
|
+
} from "./subscriptionEngineUtils.js";
|
|
19
|
+
|
|
20
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
21
|
+
|
|
22
|
+
/** @beta */
|
|
23
|
+
export function recordPaymentSuccess(input: {
|
|
24
|
+
subscription: EngineSubscription;
|
|
25
|
+
cycle: EngineCycle;
|
|
26
|
+
paidAt: string;
|
|
27
|
+
recordedAt: string;
|
|
28
|
+
}): SubscriptionEngineResult<{
|
|
29
|
+
subscription: EngineSubscription;
|
|
30
|
+
cycle: EngineCycle;
|
|
31
|
+
events: EngineEvent[];
|
|
32
|
+
}> {
|
|
33
|
+
const paidAt = parseIso(input.paidAt);
|
|
34
|
+
if (!paidAt) return failure("invalid_timestamp", "paidAt must be a valid ISO timestamp");
|
|
35
|
+
const recordedAt = parseIso(input.recordedAt);
|
|
36
|
+
if (!recordedAt) return failure("invalid_timestamp", "recordedAt must be a valid ISO timestamp");
|
|
37
|
+
if (isTerminalCycle(input.cycle)) {
|
|
38
|
+
return failure("terminal_cycle", "terminal cycles cannot be paid");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// The next cycle is one cadence past the CYCLE'S OWN scheduled instant, not past
|
|
42
|
+
// the moment the money arrived: a late payment must not silently shorten the
|
|
43
|
+
// interval the customer bought. The whole days the payment was late are then
|
|
44
|
+
// ADDED, because a delivery that arrives late shifts the whole subscription
|
|
45
|
+
// later rather than letting the next one stack up behind it.
|
|
46
|
+
//
|
|
47
|
+
// Lateness is measured to the EARLIER of the payment instant and the instant we
|
|
48
|
+
// recorded it, so a provider timestamp from the future cannot inflate the shift
|
|
49
|
+
// and push the next charge out past what the customer actually owes.
|
|
50
|
+
//
|
|
51
|
+
// ⛔ The outer Math.max is the load-bearing part and is NOT an optimisation.
|
|
52
|
+
// It is a monotonic clamp: `nextCycleAt` may only ever move LATER, never
|
|
53
|
+
// earlier. Without it, a replayed webhook, an out-of-order settlement, or a
|
|
54
|
+
// manual correction applied against an older cycle could compute an EARLIER
|
|
55
|
+
// instant and pull a charge forward into a window the subscriber has already
|
|
56
|
+
// been billed for — the one failure mode this rail must never have. The same
|
|
57
|
+
// clamp is spelled `GREATEST` in the SQL delivery-alignment rail for exactly
|
|
58
|
+
// this reason; the two are one rule with two spellings.
|
|
59
|
+
//
|
|
60
|
+
// `Date.parse(...) || 0` is deliberate rather than defensive noise: a
|
|
61
|
+
// subscription whose stored instant is unreadable clamps against the epoch,
|
|
62
|
+
// which yields the computed instant, instead of poisoning the whole expression
|
|
63
|
+
// with NaN and returning an invalid date. Cadence keeps its own call so its own
|
|
64
|
+
// validity guard still fires and still reports the right error, and `toJSON()`
|
|
65
|
+
// answers null past the representable range rather than throwing, which is why
|
|
66
|
+
// the shift is range-checked below instead of trusted.
|
|
67
|
+
const onTimeNextCycleAt = addCadenceDays(input.cycle.scheduledAt, input.cycle.templateSnapshot.cadence_days);
|
|
68
|
+
if (!onTimeNextCycleAt) return failure("invalid_timestamp", "cycle.scheduledAt must be a valid ISO timestamp");
|
|
69
|
+
|
|
70
|
+
const shiftedByDays = Math.max(0, Math.floor(
|
|
71
|
+
(Math.min(paidAt.getTime(), recordedAt.getTime()) - Date.parse(input.cycle.scheduledAt)) / DAY_MS,
|
|
72
|
+
));
|
|
73
|
+
const nextCycleAt = new Date(Math.max(
|
|
74
|
+
Date.parse(input.subscription.nextCycleAt) || 0,
|
|
75
|
+
Date.parse(onTimeNextCycleAt) + shiftedByDays * DAY_MS,
|
|
76
|
+
)).toJSON();
|
|
77
|
+
if (!nextCycleAt) {
|
|
78
|
+
return failure("invalid_timestamp", "payment shift exceeds the supported timestamp range");
|
|
79
|
+
}
|
|
80
|
+
const paidAtIso = paidAt.toISOString();
|
|
81
|
+
|
|
82
|
+
const cycle: EngineCycle = {
|
|
83
|
+
...input.cycle,
|
|
84
|
+
status: "paid",
|
|
85
|
+
paidAt: paidAtIso,
|
|
86
|
+
retryAttempt: input.cycle.retryAttempt,
|
|
87
|
+
nextRetryAt: null,
|
|
88
|
+
failureReason: null,
|
|
89
|
+
};
|
|
90
|
+
const subscription = touchSubscription(input.subscription, recordedAt.toISOString(), { nextCycleAt });
|
|
91
|
+
|
|
92
|
+
return success({
|
|
93
|
+
subscription,
|
|
94
|
+
cycle,
|
|
95
|
+
events: [
|
|
96
|
+
makeEvent({
|
|
97
|
+
eventType: "subscription.cycle_paid",
|
|
98
|
+
subscription,
|
|
99
|
+
cycleNumber: cycle.cycleNumber,
|
|
100
|
+
idempotencyKey: `${cycle.engineIdempotencyKey}:cycle_paid`,
|
|
101
|
+
occurredAt: paidAtIso,
|
|
102
|
+
payload: { paidAt: cycle.paidAt, nextCycleAt },
|
|
103
|
+
}),
|
|
104
|
+
makeEvent({
|
|
105
|
+
eventType: "subscription.next_cycle_at_updated",
|
|
106
|
+
subscription,
|
|
107
|
+
cycleNumber: cycle.cycleNumber,
|
|
108
|
+
idempotencyKey: `${cycle.engineIdempotencyKey}:next_cycle_at_updated`,
|
|
109
|
+
occurredAt: paidAtIso,
|
|
110
|
+
payload: {
|
|
111
|
+
nextCycleAt,
|
|
112
|
+
basedOnScheduledAt: cycle.scheduledAt,
|
|
113
|
+
basedOnPaidAt: cycle.paidAt,
|
|
114
|
+
shiftedByDays,
|
|
115
|
+
},
|
|
116
|
+
}),
|
|
117
|
+
],
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** @beta */
|
|
122
|
+
export function recordPaymentFailure(input: {
|
|
123
|
+
cycle: EngineCycle;
|
|
124
|
+
failedAt: string;
|
|
125
|
+
reason: string;
|
|
126
|
+
failureClass?: string | null;
|
|
127
|
+
/**
|
|
128
|
+
* The deployment's ladder. Pass the same cadence to
|
|
129
|
+
* `pauseSubscriptionForExpiredDunning` so both agree on exhaustion.
|
|
130
|
+
*/
|
|
131
|
+
cadence?: CycleRetryCadence;
|
|
132
|
+
}): SubscriptionEngineResult<CycleMutationResult> {
|
|
133
|
+
const failedAt = parseIso(input.failedAt);
|
|
134
|
+
if (!failedAt) return failure("invalid_timestamp", "failedAt must be a valid ISO timestamp");
|
|
135
|
+
if (isTerminalCycle(input.cycle)) {
|
|
136
|
+
return failure("terminal_cycle", "terminal cycles cannot be retried");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const retryAttempt = input.cycle.retryAttempt + 1;
|
|
140
|
+
// The ladder owns its terminal decision.
|
|
141
|
+
const nextRetryAt = nextRetryAttemptAt(
|
|
142
|
+
failedAt.toISOString(),
|
|
143
|
+
retryAttempt,
|
|
144
|
+
input.cadence,
|
|
145
|
+
input.failureClass,
|
|
146
|
+
);
|
|
147
|
+
const status: SubscriptionCycleStatus = nextRetryAt ? "retry_scheduled" : "payment_failed";
|
|
148
|
+
const cycle: EngineCycle = {
|
|
149
|
+
...input.cycle,
|
|
150
|
+
status,
|
|
151
|
+
retryAttempt,
|
|
152
|
+
nextRetryAt,
|
|
153
|
+
failureReason: input.reason,
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const events: EngineEvent[] = [
|
|
157
|
+
{
|
|
158
|
+
eventType: "subscription.payment_failed",
|
|
159
|
+
subscriptionId: cycle.subscriptionId,
|
|
160
|
+
cycleNumber: cycle.cycleNumber,
|
|
161
|
+
idempotencyKey: `${cycle.engineIdempotencyKey}:payment_failed:${retryAttempt}`,
|
|
162
|
+
occurredAt: failedAt.toISOString(),
|
|
163
|
+
payload: { reason: input.reason, retryAttempt },
|
|
164
|
+
},
|
|
165
|
+
];
|
|
166
|
+
|
|
167
|
+
if (nextRetryAt) {
|
|
168
|
+
events.push({
|
|
169
|
+
eventType: "subscription.retry_scheduled",
|
|
170
|
+
subscriptionId: cycle.subscriptionId,
|
|
171
|
+
cycleNumber: cycle.cycleNumber,
|
|
172
|
+
idempotencyKey: `${cycle.engineIdempotencyKey}:retry_scheduled:${retryAttempt}`,
|
|
173
|
+
occurredAt: failedAt.toISOString(),
|
|
174
|
+
payload: { nextRetryAt, retryAttempt },
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return success({ cycle, events });
|
|
179
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
SubscriptionCycleStatus,
|
|
3
|
+
SubscriptionEngineEventType,
|
|
4
|
+
SubscriptionStatus,
|
|
5
|
+
SubscriptionTemplateSnapshot,
|
|
6
|
+
} from "./types.js";
|
|
7
|
+
|
|
8
|
+
/** @beta */
|
|
9
|
+
export const DEFAULT_EDIT_WINDOW_HOURS = 72;
|
|
10
|
+
/** @beta */
|
|
11
|
+
export const DEFAULT_TIMEZONE = "UTC";
|
|
12
|
+
|
|
13
|
+
/** @beta */
|
|
14
|
+
export type SubscriptionEngineErrorCode =
|
|
15
|
+
| "invalid_timestamp"
|
|
16
|
+
| "invalid_template"
|
|
17
|
+
| "inactive_subscription"
|
|
18
|
+
| "edit_window_closed"
|
|
19
|
+
| "line_not_found"
|
|
20
|
+
| "duplicate_line"
|
|
21
|
+
| "invalid_slide_target"
|
|
22
|
+
| "invalid_transition"
|
|
23
|
+
| "terminal_cycle"
|
|
24
|
+
| "missing_payment_method"
|
|
25
|
+
| "dunning_not_exhausted";
|
|
26
|
+
|
|
27
|
+
/** @beta */
|
|
28
|
+
export type SubscriptionEngineResult<T> =
|
|
29
|
+
| { ok: true; value: T }
|
|
30
|
+
| { ok: false; error: { code: SubscriptionEngineErrorCode; message: string } };
|
|
31
|
+
|
|
32
|
+
/** @beta */
|
|
33
|
+
export interface EngineSubscription {
|
|
34
|
+
id: string;
|
|
35
|
+
clientRef: string;
|
|
36
|
+
status: SubscriptionStatus;
|
|
37
|
+
template: SubscriptionTemplateSnapshot;
|
|
38
|
+
templateVersion: number;
|
|
39
|
+
nextCycleAt: string;
|
|
40
|
+
paymentMethodRef: string | null;
|
|
41
|
+
paymentMethodKind: string | null;
|
|
42
|
+
timezone: string;
|
|
43
|
+
createdAt: string;
|
|
44
|
+
updatedAt: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** @beta */
|
|
48
|
+
export interface EngineCycle {
|
|
49
|
+
subscriptionId: string;
|
|
50
|
+
cycleNumber: number;
|
|
51
|
+
status: SubscriptionCycleStatus;
|
|
52
|
+
scheduledAt: string;
|
|
53
|
+
paidAt: string | null;
|
|
54
|
+
templateVersion: number;
|
|
55
|
+
templateSnapshot: SubscriptionTemplateSnapshot;
|
|
56
|
+
pricingSnapshot: Record<string, unknown>;
|
|
57
|
+
paymentMethodRef: string | null;
|
|
58
|
+
retryAttempt: number;
|
|
59
|
+
nextRetryAt: string | null;
|
|
60
|
+
failureReason: string | null;
|
|
61
|
+
engineIdempotencyKey: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** @beta */
|
|
65
|
+
export interface EngineEvent {
|
|
66
|
+
eventType: SubscriptionEngineEventType;
|
|
67
|
+
subscriptionId: string;
|
|
68
|
+
cycleNumber?: number;
|
|
69
|
+
idempotencyKey: string;
|
|
70
|
+
occurredAt: string;
|
|
71
|
+
payload: Record<string, unknown>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** @beta */
|
|
75
|
+
export interface CreateInitialSubscriptionCheckoutInput {
|
|
76
|
+
subscriptionId: string;
|
|
77
|
+
clientRef: string;
|
|
78
|
+
template: SubscriptionTemplateSnapshot;
|
|
79
|
+
paymentMethodRef?: string | null;
|
|
80
|
+
paymentMethodKind?: string | null;
|
|
81
|
+
firstCycleAt: string;
|
|
82
|
+
now: string;
|
|
83
|
+
pricingSnapshot: Record<string, unknown>;
|
|
84
|
+
idempotencyKey: string;
|
|
85
|
+
timezone?: string | null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** @beta */
|
|
89
|
+
export interface PlanCycleInput {
|
|
90
|
+
subscription: EngineSubscription;
|
|
91
|
+
cycleNumber: number;
|
|
92
|
+
now: string;
|
|
93
|
+
pricingSnapshot: Record<string, unknown>;
|
|
94
|
+
idempotencyKey: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** @beta */
|
|
98
|
+
export interface SubscriptionMutationResult {
|
|
99
|
+
subscription: EngineSubscription;
|
|
100
|
+
events: EngineEvent[];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** @beta */
|
|
104
|
+
export interface CycleMutationResult {
|
|
105
|
+
cycle: EngineCycle;
|
|
106
|
+
events: EngineEvent[];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** @beta */
|
|
110
|
+
export interface InitialSubscriptionCheckoutModel {
|
|
111
|
+
subscription: EngineSubscription;
|
|
112
|
+
cycle: EngineCycle;
|
|
113
|
+
events: EngineEvent[];
|
|
114
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type { SubscriptionEngineEventType, SubscriptionStatus } from "./types.js";
|
|
2
|
+
import type {
|
|
3
|
+
EngineCycle,
|
|
4
|
+
EngineEvent,
|
|
5
|
+
EngineSubscription,
|
|
6
|
+
SubscriptionEngineErrorCode,
|
|
7
|
+
SubscriptionEngineResult,
|
|
8
|
+
} from "./subscriptionEngineTypes.js";
|
|
9
|
+
import type { SubscriptionTemplateSnapshot } from "./types.js";
|
|
10
|
+
|
|
11
|
+
/** @beta */
|
|
12
|
+
export function touchSubscription(
|
|
13
|
+
subscription: EngineSubscription,
|
|
14
|
+
updatedAt: string,
|
|
15
|
+
patch: Partial<EngineSubscription>,
|
|
16
|
+
): EngineSubscription {
|
|
17
|
+
return {
|
|
18
|
+
...subscription,
|
|
19
|
+
...patch,
|
|
20
|
+
template: patch.template ? cloneTemplate(patch.template) : cloneTemplate(subscription.template),
|
|
21
|
+
updatedAt,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** @beta */
|
|
26
|
+
export function makeEvent(input: {
|
|
27
|
+
eventType: SubscriptionEngineEventType;
|
|
28
|
+
subscription: EngineSubscription;
|
|
29
|
+
cycleNumber?: number;
|
|
30
|
+
idempotencyKey: string;
|
|
31
|
+
occurredAt: string;
|
|
32
|
+
payload: Record<string, unknown>;
|
|
33
|
+
}): EngineEvent {
|
|
34
|
+
return {
|
|
35
|
+
eventType: input.eventType,
|
|
36
|
+
subscriptionId: input.subscription.id,
|
|
37
|
+
cycleNumber: input.cycleNumber,
|
|
38
|
+
idempotencyKey: input.idempotencyKey,
|
|
39
|
+
occurredAt: input.occurredAt,
|
|
40
|
+
payload: input.payload,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** @beta */
|
|
45
|
+
export function addCadenceDays(iso: string, cadenceDays: number): string | null {
|
|
46
|
+
const date = parseIso(iso);
|
|
47
|
+
if (!date || cadenceDays < 1) return null;
|
|
48
|
+
return new Date(date.getTime() + cadenceDays * 24 * 60 * 60 * 1000).toJSON();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** @beta */
|
|
52
|
+
export function parseIso(iso: string): Date | null {
|
|
53
|
+
const date = new Date(iso);
|
|
54
|
+
if (Number.isNaN(date.getTime())) return null;
|
|
55
|
+
return date;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** @beta */
|
|
59
|
+
export function validateTemplate(
|
|
60
|
+
template: SubscriptionTemplateSnapshot,
|
|
61
|
+
): SubscriptionEngineResult<never> | null {
|
|
62
|
+
if (template.cadence_days < 1 || template.lines.length === 0) {
|
|
63
|
+
return failure("invalid_template", "template must have a positive cadence and at least one line");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
for (const line of template.lines) {
|
|
67
|
+
if (line.qty < 1) {
|
|
68
|
+
return failure("invalid_template", "template lines must have positive quantities");
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** @beta */
|
|
76
|
+
export function isTerminalCycle(cycle: EngineCycle): boolean {
|
|
77
|
+
return cycle.status === "paid" || cycle.status === "skipped" || cycle.status === "cancelled";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** @beta */
|
|
81
|
+
export function cloneTemplate(template: SubscriptionTemplateSnapshot): SubscriptionTemplateSnapshot {
|
|
82
|
+
return {
|
|
83
|
+
...template,
|
|
84
|
+
size_constraint: template.size_constraint ? cloneRecord(template.size_constraint) : null,
|
|
85
|
+
lines: template.lines.map((line) => ({ ...line })),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** @beta */
|
|
90
|
+
export function cloneRecord<T extends Record<string, unknown>>(record: T): T {
|
|
91
|
+
return JSON.parse(JSON.stringify(record)) as T;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** @beta */
|
|
95
|
+
export function success<T>(value: T): SubscriptionEngineResult<T> {
|
|
96
|
+
return { ok: true, value };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** @beta */
|
|
100
|
+
export function failure(
|
|
101
|
+
code: SubscriptionEngineErrorCode,
|
|
102
|
+
message: string,
|
|
103
|
+
): SubscriptionEngineResult<never> {
|
|
104
|
+
return { ok: false, error: { code, message } };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** @beta */
|
|
108
|
+
export function statusPatch(status: SubscriptionStatus): Partial<EngineSubscription> {
|
|
109
|
+
return { status };
|
|
110
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/** @beta */
|
|
2
|
+
export const SUBSCRIPTION_STATUSES = ["active", "paused", "cancelled", "completed"] as const;
|
|
3
|
+
/** @beta */
|
|
4
|
+
export type SubscriptionStatus = (typeof SUBSCRIPTION_STATUSES)[number];
|
|
5
|
+
|
|
6
|
+
// Full set of `subscriptions.status` values persisted in the DB (see the
|
|
7
|
+
// `subscriptions.status` CHECK constraint). This is a SUPERSET of the
|
|
8
|
+
// engine-managed lifecycle states above: it also includes the activation-flow
|
|
9
|
+
// states (`pending_activation`, `activation_failed`) that the recurring engine
|
|
10
|
+
// never transitions between. Read/display contracts that echo a stored row MUST
|
|
11
|
+
// accept all of these; the engine transition logic stays scoped to
|
|
12
|
+
// SUBSCRIPTION_STATUSES.
|
|
13
|
+
/** @beta */
|
|
14
|
+
export const SUBSCRIPTION_RECORD_STATUSES = [
|
|
15
|
+
"pending_activation",
|
|
16
|
+
"active",
|
|
17
|
+
"paused",
|
|
18
|
+
"cancelled",
|
|
19
|
+
"completed",
|
|
20
|
+
"activation_failed",
|
|
21
|
+
] as const;
|
|
22
|
+
/** @beta */
|
|
23
|
+
export type SubscriptionRecordStatus = (typeof SUBSCRIPTION_RECORD_STATUSES)[number];
|
|
24
|
+
|
|
25
|
+
/** @beta */
|
|
26
|
+
export const SUBSCRIPTION_CYCLE_STATUSES = [
|
|
27
|
+
"planned",
|
|
28
|
+
"payment_pending",
|
|
29
|
+
"paid",
|
|
30
|
+
"payment_failed",
|
|
31
|
+
"retry_scheduled",
|
|
32
|
+
"skipped",
|
|
33
|
+
"cancelled",
|
|
34
|
+
] as const;
|
|
35
|
+
/** @beta */
|
|
36
|
+
export type SubscriptionCycleStatus = (typeof SUBSCRIPTION_CYCLE_STATUSES)[number];
|
|
37
|
+
|
|
38
|
+
/** @beta */
|
|
39
|
+
export const SUBSCRIPTION_ENGINE_EVENT_TYPES = [
|
|
40
|
+
"subscription.created",
|
|
41
|
+
"subscription.template_updated",
|
|
42
|
+
"subscription.edit_window_opened",
|
|
43
|
+
"subscription.next_cycle_at_updated",
|
|
44
|
+
"subscription.cycle_planned",
|
|
45
|
+
"subscription.payment_requested",
|
|
46
|
+
"subscription.payment_failed",
|
|
47
|
+
"subscription.retry_scheduled",
|
|
48
|
+
"subscription.payment_recovered",
|
|
49
|
+
"subscription.cycle_paid",
|
|
50
|
+
"subscription.cycle_skipped",
|
|
51
|
+
"subscription.cycle_cancelled",
|
|
52
|
+
"subscription.paused",
|
|
53
|
+
"subscription.resumed",
|
|
54
|
+
"subscription.cancelled",
|
|
55
|
+
"subscription.completed",
|
|
56
|
+
] as const;
|
|
57
|
+
/** @beta */
|
|
58
|
+
export type SubscriptionEngineEventType = (typeof SUBSCRIPTION_ENGINE_EVENT_TYPES)[number];
|
|
59
|
+
|
|
60
|
+
/** @beta */
|
|
61
|
+
export interface SubscriptionLineSnapshot {
|
|
62
|
+
variant_id: string;
|
|
63
|
+
qty: number;
|
|
64
|
+
sort_order: number;
|
|
65
|
+
is_addon: boolean;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** @beta */
|
|
69
|
+
export interface SubscriptionTemplateSnapshot {
|
|
70
|
+
cadence_days: number;
|
|
71
|
+
size_constraint: Record<string, unknown> | null;
|
|
72
|
+
currency: string;
|
|
73
|
+
region_code: string;
|
|
74
|
+
edit_window_hours?: number | null;
|
|
75
|
+
lines: SubscriptionLineSnapshot[];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** @beta */
|
|
79
|
+
export interface SubscriptionEngineEvent {
|
|
80
|
+
event_id: string;
|
|
81
|
+
event_type: SubscriptionEngineEventType;
|
|
82
|
+
subscription_id: string;
|
|
83
|
+
cycle_number?: number;
|
|
84
|
+
template?: SubscriptionTemplateSnapshot;
|
|
85
|
+
next_cycle_at?: string;
|
|
86
|
+
payment_payload?: {
|
|
87
|
+
payment_id?: string;
|
|
88
|
+
amount_minor: number;
|
|
89
|
+
currency: string;
|
|
90
|
+
order_lines: Array<{
|
|
91
|
+
variant_id: string;
|
|
92
|
+
qty: number;
|
|
93
|
+
unit_price_minor: number;
|
|
94
|
+
}>;
|
|
95
|
+
};
|
|
96
|
+
raw_payload: Record<string, unknown>;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** @beta */
|
|
100
|
+
export interface CreateSubscriptionInput {
|
|
101
|
+
template: SubscriptionTemplateSnapshot;
|
|
102
|
+
client_ref: string;
|
|
103
|
+
payment_method_ref: string;
|
|
104
|
+
}
|
package/src/testing.ts
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import {
|
|
2
|
+
PAYMENT_EXECUTION_ATTEMPT_STATUSES,
|
|
3
|
+
type PaymentExecutionBaseInput,
|
|
4
|
+
type PaymentExecutionBaseResult,
|
|
5
|
+
} from "./payment/index.js";
|
|
6
|
+
|
|
7
|
+
/** @internal */
|
|
8
|
+
export type { PaymentExecutionBaseInput } from "./payment/index.js";
|
|
9
|
+
|
|
10
|
+
/** @internal */
|
|
11
|
+
export const neutralBrandFixture = {
|
|
12
|
+
displayName: "Example Store",
|
|
13
|
+
publicOrigin: "https://example.test",
|
|
14
|
+
} as const;
|
|
15
|
+
|
|
16
|
+
/** @internal */
|
|
17
|
+
export const paymentExecutionBaseContractInput = {
|
|
18
|
+
idempotencyKey: "checkout-contract:payment-execution",
|
|
19
|
+
providerIdempotencyKey: "contract:example-pay:payment-intent-contract:checkout-payment-execution",
|
|
20
|
+
providerRequestFingerprint: "example-pay|payment-intent-contract|4200|USD|one_time|order_contract",
|
|
21
|
+
paymentIntentId: "11111111-1111-4111-8111-111111111111",
|
|
22
|
+
amountMinor: 4200,
|
|
23
|
+
currency: "USD",
|
|
24
|
+
mode: "one_time",
|
|
25
|
+
} satisfies PaymentExecutionBaseInput;
|
|
26
|
+
|
|
27
|
+
/** @internal */
|
|
28
|
+
export type PaymentExecutionContractResult = PaymentExecutionBaseResult & {
|
|
29
|
+
rawProviderPayload?: Record<string, unknown> | null;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** @internal */
|
|
33
|
+
export type PaymentExecutionContractValidationIssueCode =
|
|
34
|
+
| "provider_mismatch"
|
|
35
|
+
| "invalid_provider_attempt_id"
|
|
36
|
+
| "invalid_provider_session_id"
|
|
37
|
+
| "invalid_attempt_status"
|
|
38
|
+
| "invalid_next_action_kind"
|
|
39
|
+
| "invalid_request_payload"
|
|
40
|
+
| "invalid_response_payload"
|
|
41
|
+
| "missing_provider_idempotency_key"
|
|
42
|
+
| "missing_provider_request_fingerprint"
|
|
43
|
+
| "sensitive_value_leaked";
|
|
44
|
+
|
|
45
|
+
/** @internal */
|
|
46
|
+
export interface PaymentExecutionContractValidationIssue {
|
|
47
|
+
code: PaymentExecutionContractValidationIssueCode;
|
|
48
|
+
message: string;
|
|
49
|
+
path: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** @internal */
|
|
53
|
+
export type PaymentExecutionContractValidationResult =
|
|
54
|
+
| { ok: true; issues: [] }
|
|
55
|
+
| { ok: false; issues: PaymentExecutionContractValidationIssue[] };
|
|
56
|
+
|
|
57
|
+
/** @internal */
|
|
58
|
+
export interface PaymentExecutionContractValidationInput<
|
|
59
|
+
TInput extends PaymentExecutionBaseInput = PaymentExecutionBaseInput,
|
|
60
|
+
TResult extends PaymentExecutionContractResult = PaymentExecutionContractResult,
|
|
61
|
+
> {
|
|
62
|
+
input: TInput;
|
|
63
|
+
result: TResult;
|
|
64
|
+
expectedProvider: string;
|
|
65
|
+
allowedNextActionKinds?: readonly string[];
|
|
66
|
+
sensitiveValues?: readonly string[];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** @internal */
|
|
70
|
+
export function validatePaymentExecutionContractResult<
|
|
71
|
+
TInput extends PaymentExecutionBaseInput = PaymentExecutionBaseInput,
|
|
72
|
+
TResult extends PaymentExecutionContractResult = PaymentExecutionContractResult,
|
|
73
|
+
>(
|
|
74
|
+
context: PaymentExecutionContractValidationInput<TInput, TResult>,
|
|
75
|
+
): PaymentExecutionContractValidationResult {
|
|
76
|
+
const issues: PaymentExecutionContractValidationIssue[] = [];
|
|
77
|
+
const { input, result } = context;
|
|
78
|
+
|
|
79
|
+
if (result.provider !== context.expectedProvider) {
|
|
80
|
+
issues.push({
|
|
81
|
+
code: "provider_mismatch",
|
|
82
|
+
path: "provider",
|
|
83
|
+
message: `Expected provider ${context.expectedProvider}.`,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
if (result.providerAttemptId !== null && typeof result.providerAttemptId !== "string") {
|
|
87
|
+
issues.push({
|
|
88
|
+
code: "invalid_provider_attempt_id",
|
|
89
|
+
path: "providerAttemptId",
|
|
90
|
+
message: "Expected providerAttemptId to be null or a string.",
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
if (result.providerSessionId !== null && typeof result.providerSessionId !== "string") {
|
|
94
|
+
issues.push({
|
|
95
|
+
code: "invalid_provider_session_id",
|
|
96
|
+
path: "providerSessionId",
|
|
97
|
+
message: "Expected providerSessionId to be null or a string.",
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
if (!PAYMENT_EXECUTION_ATTEMPT_STATUSES.includes(result.attemptStatus)) {
|
|
101
|
+
issues.push({
|
|
102
|
+
code: "invalid_attempt_status",
|
|
103
|
+
path: "attemptStatus",
|
|
104
|
+
message: "Expected attemptStatus to be a provider-execution attempt status.",
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
if (
|
|
108
|
+
result.nextActionKind !== null
|
|
109
|
+
&& !(context.allowedNextActionKinds ?? []).includes(result.nextActionKind)
|
|
110
|
+
) {
|
|
111
|
+
issues.push({
|
|
112
|
+
code: "invalid_next_action_kind",
|
|
113
|
+
path: "nextActionKind",
|
|
114
|
+
message: "Expected nextActionKind to be null or listed in allowedNextActionKinds.",
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (!isRecordPayload(result.requestPayload)) {
|
|
119
|
+
issues.push({
|
|
120
|
+
code: "invalid_request_payload",
|
|
121
|
+
path: "requestPayload",
|
|
122
|
+
message: "Expected requestPayload to be a non-array object.",
|
|
123
|
+
});
|
|
124
|
+
} else {
|
|
125
|
+
if (result.requestPayload.providerIdempotencyKey !== input.providerIdempotencyKey) {
|
|
126
|
+
issues.push({
|
|
127
|
+
code: "missing_provider_idempotency_key",
|
|
128
|
+
path: "requestPayload.providerIdempotencyKey",
|
|
129
|
+
message: "Expected requestPayload to persist providerIdempotencyKey.",
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
if (result.requestPayload.providerRequestFingerprint !== input.providerRequestFingerprint) {
|
|
133
|
+
issues.push({
|
|
134
|
+
code: "missing_provider_request_fingerprint",
|
|
135
|
+
path: "requestPayload.providerRequestFingerprint",
|
|
136
|
+
message: "Expected requestPayload to persist providerRequestFingerprint.",
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (!isRecordPayload(result.responsePayload)) {
|
|
142
|
+
issues.push({
|
|
143
|
+
code: "invalid_response_payload",
|
|
144
|
+
path: "responsePayload",
|
|
145
|
+
message: "Expected responsePayload to be a non-array object.",
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const persistedPayloadJson = JSON.stringify({
|
|
150
|
+
requestPayload: result.requestPayload,
|
|
151
|
+
responsePayload: result.responsePayload,
|
|
152
|
+
rawProviderPayload: result.rawProviderPayload ?? null,
|
|
153
|
+
});
|
|
154
|
+
for (const sensitiveValue of context.sensitiveValues ?? []) {
|
|
155
|
+
if (persistedPayloadJson.includes(sensitiveValue)) {
|
|
156
|
+
issues.push({
|
|
157
|
+
code: "sensitive_value_leaked",
|
|
158
|
+
path: "requestPayload|responsePayload|rawProviderPayload",
|
|
159
|
+
message: "Expected persisted provider payloads not to contain sensitive values.",
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return issues.length === 0 ? { ok: true, issues: [] } : { ok: false, issues };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function isRecordPayload(payload: unknown): payload is Record<string, unknown> {
|
|
168
|
+
return Boolean(payload) && typeof payload === "object" && !Array.isArray(payload);
|
|
169
|
+
}
|