@openlup/platform 0.24.0 → 0.26.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 +127 -432
- package/README.md +46 -11
- package/dist/communications/contracts/subscriptionPaymentExpired.d.ts +3 -2
- package/dist/communications/contracts/subscriptionPaymentExpired.js +1 -1
- package/dist/communications/contracts/subscriptionPaymentFailed.d.ts +4 -3
- package/dist/communications/contracts/subscriptionPaymentFailed.js +4 -5
- package/dist/composition/index.d.ts +1 -0
- package/dist/composition/index.js +1 -0
- package/dist/composition/subscriptionContribution.d.ts +18 -0
- package/dist/composition/subscriptionContribution.js +19 -0
- package/dist/inventory/index.d.ts +2 -0
- package/dist/inventory/index.js +1 -0
- package/dist/inventory/postgresStockAvailability.d.ts +24 -0
- package/dist/inventory/postgresStockAvailability.js +48 -0
- package/dist/subscriptions/commandExamples.d.ts +6 -0
- package/dist/subscriptions/commandExamples.js +20 -0
- package/dist/subscriptions/contracts/commands.d.ts +8 -1
- package/dist/subscriptions/contracts/index.d.ts +2 -2
- package/dist/subscriptions/contracts/renewal.d.ts +2 -0
- package/dist/subscriptions/contracts/stockWait.d.ts +12 -1
- package/dist/subscriptions/index.d.ts +22 -4
- package/dist/subscriptions/index.js +22 -3
- package/dist/subscriptions/lifecycleMessages.d.ts +9 -0
- package/dist/subscriptions/lifecycleMessages.js +38 -0
- package/dist/subscriptions/paymentOutcomes.js +1 -1
- package/dist/subscriptions/postgresCommandStore.d.ts +44 -0
- package/dist/subscriptions/postgresCommandStore.js +172 -0
- package/dist/subscriptions/postgresRetryStore.d.ts +6 -0
- package/dist/subscriptions/postgresRetryStore.js +11 -0
- package/dist/subscriptions/postgresStockWaitStore.d.ts +62 -0
- package/dist/subscriptions/postgresStockWaitStore.js +174 -0
- package/dist/subscriptions/renewalCycleFailureMechanism.js +3 -2
- package/dist/subscriptions/services.d.ts +31 -0
- package/dist/subscriptions/services.js +15 -0
- package/package.json +2 -2
- package/release-gates.json +2 -2
- package/sql/20261024170000_subscription_command_lifecycle.sql +404 -0
- package/sql/20261024180000_subscription_stock_wait.sql +330 -0
- package/sql/20261024183000_inventory_available_to_promise.sql +70 -0
- package/sql/20261024184000_subscription_renewal_reserve_uses_seam.sql +147 -0
- package/sql/20261024184500_subscription_retry_late_positive_stock.sql +59 -0
- package/sql/20261024184600_subscription_retry_candidate_payment_filter.sql +22 -0
- package/src/communications/contracts/subscriptionPaymentExpired.ts +4 -3
- package/src/communications/contracts/subscriptionPaymentFailed.ts +8 -8
- package/src/composition/index.ts +1 -0
- package/src/composition/subscriptionContribution.ts +25 -0
- package/src/inventory/index.ts +2 -0
- package/src/inventory/postgresStockAvailability.ts +54 -0
- package/src/subscriptions/commandExamples.ts +19 -0
- package/src/subscriptions/contracts/commands.ts +35 -2
- package/src/subscriptions/contracts/index.ts +2 -2
- package/src/subscriptions/contracts/renewal.ts +2 -1
- package/src/subscriptions/contracts/stockWait.ts +14 -2
- package/src/subscriptions/index.ts +30 -3
- package/src/subscriptions/lifecycleMessages.ts +37 -0
- package/src/subscriptions/paymentOutcomes.ts +1 -1
- package/src/subscriptions/postgresCommandStore.ts +183 -0
- package/src/subscriptions/postgresRetryStore.ts +11 -0
- package/src/subscriptions/postgresStockWaitStore.ts +170 -0
- package/src/subscriptions/renewalCycleFailureMechanism.ts +3 -1
- package/src/subscriptions/services.ts +21 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { subscriptionRecord } from "./postgresRenewalStore.js";
|
|
2
|
+
import { NATIVE_SUBSCRIPTION_CAPABILITIES } from "./index.js";
|
|
3
|
+
const refusalCodes = new Set([
|
|
4
|
+
"multi_cycle_prepaid_unsupported",
|
|
5
|
+
"subscription_command_addon_not_found",
|
|
6
|
+
"subscription_command_bundle_total_mismatch",
|
|
7
|
+
"subscription_command_charge_timing_not_confirmed",
|
|
8
|
+
"subscription_command_idempotency_conflict",
|
|
9
|
+
"subscription_command_invalid_composition_constraint",
|
|
10
|
+
"subscription_command_invalid_input",
|
|
11
|
+
"subscription_command_not_active",
|
|
12
|
+
"subscription_command_operation_busy",
|
|
13
|
+
"subscription_command_pause_forbidden",
|
|
14
|
+
"subscription_command_payment_blocked",
|
|
15
|
+
"subscription_command_preview_unsupported",
|
|
16
|
+
"subscription_command_renewal_terms_missing",
|
|
17
|
+
"subscription_command_unsupported_action",
|
|
18
|
+
"subscription_edit_catalog_unavailable",
|
|
19
|
+
"subscription_edit_invalid_lines",
|
|
20
|
+
"subscription_edit_invalid_request",
|
|
21
|
+
"subscription_edit_quote_expired",
|
|
22
|
+
"subscription_edit_quote_required",
|
|
23
|
+
"subscription_edit_stale_template",
|
|
24
|
+
"subscription_edit_window_closed",
|
|
25
|
+
"subscription_lifecycle_invalid_transition",
|
|
26
|
+
"subscription_lifecycle_invalid_skip",
|
|
27
|
+
"subscription_lifecycle_invalid_slide",
|
|
28
|
+
"subscription_lifecycle_payment_blocked",
|
|
29
|
+
]);
|
|
30
|
+
function refusalReason(reason) {
|
|
31
|
+
return typeof reason === "string" && refusalCodes.has(reason)
|
|
32
|
+
? reason : "subscription_command_invalid_input";
|
|
33
|
+
}
|
|
34
|
+
function commandResult(result) {
|
|
35
|
+
return result.status === "refused" || result.outcome === "refused"
|
|
36
|
+
? { ...result, reason: refusalReason(result.reason) } : result;
|
|
37
|
+
}
|
|
38
|
+
/** N2 owns normalization, locking, quote acceptance, receipts and all writes.
|
|
39
|
+
* Preview is a separate persisted transaction; apply never executes payment. */
|
|
40
|
+
export function createPostgresSubscriptionCommandStore(binding) {
|
|
41
|
+
return {
|
|
42
|
+
preview(input) { return invokeCommand(binding, input, true); },
|
|
43
|
+
apply(input) { return invokeCommand(binding, input, false); },
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
async function invokeCommand(binding, input, preview, futureTerms) {
|
|
47
|
+
if (input.action === "multi_cycle_prepaid")
|
|
48
|
+
return {
|
|
49
|
+
status: "refused", reason: NATIVE_SUBSCRIPTION_CAPABILITIES.multiCyclePrepaid.refusalCode,
|
|
50
|
+
};
|
|
51
|
+
return binding.withTransaction(async (executor) => {
|
|
52
|
+
const values = [input.actorId, input.clientId, input.idempotencyKey, input.subscriptionId, input.action, input.payload, input.now];
|
|
53
|
+
// Share N2's actor/key lock so a concurrent preview/apply cannot change
|
|
54
|
+
// the receipt between this read and native dispatch. Replay ends here,
|
|
55
|
+
// before any lifecycle function or other potentially writing call.
|
|
56
|
+
await executor.query("SELECT pg_advisory_xact_lock(hashtextextended($1::uuid::text||':'||$2::text,0))", [input.actorId, input.idempotencyKey]);
|
|
57
|
+
const stored = await executor.query(`WITH stored AS (
|
|
58
|
+
SELECT r.*, CASE WHEN $9::boolean THEN jsonb_set(r.command,'{payload}',
|
|
59
|
+
(r.command->'payload')-ARRAY['coreLines','addonLines','compositionConstraint']) ELSE r.command END AS requested_command
|
|
60
|
+
FROM public.subscription_command_receipts r WHERE r.actor_id=$1::uuid AND r.idempotency_key=$3::text
|
|
61
|
+
AND $7::timestamptz IS NOT NULL AND isfinite($7::timestamptz)
|
|
62
|
+
AND $3::text=btrim($3::text) AND char_length($3::text) BETWEEN 8 AND 180
|
|
63
|
+
AND $5::text IS NOT NULL AND jsonb_typeof($6::jsonb)='object'
|
|
64
|
+
AND (NOT $8::boolean OR NOT ($6::jsonb ? 'acceptedQuoteHash'))
|
|
65
|
+
AND EXISTS(SELECT 1 FROM public.subscriptions WHERE id=$4::uuid AND client_id=$2::uuid)
|
|
66
|
+
), receipt AS (
|
|
67
|
+
SELECT *, CASE WHEN $8::boolean OR status='previewed' THEN
|
|
68
|
+
jsonb_set(requested_command,'{payload}',(requested_command->'payload')-'acceptedQuoteHash')=
|
|
69
|
+
jsonb_build_object('clientId',$2::uuid,'subscriptionId',$4::uuid,'action',$5::text,'payload',$6::jsonb-'acceptedQuoteHash')
|
|
70
|
+
ELSE requested_command=jsonb_build_object('clientId',$2::uuid,'subscriptionId',$4::uuid,'action',$5::text,'payload',$6::jsonb)
|
|
71
|
+
END AND ($8::boolean OR quote_hash IS NOT DISTINCT FROM ($6::jsonb->>'acceptedQuoteHash')) AS matches
|
|
72
|
+
FROM stored
|
|
73
|
+
) SELECT command, CASE WHEN NOT matches THEN jsonb_build_object('status','refused','reason','subscription_command_idempotency_conflict')
|
|
74
|
+
WHEN $8::boolean OR status='refused' THEN result
|
|
75
|
+
WHEN status<>'previewed' THEN result||jsonb_build_object('status','replayed','originalStatus',status,'commandFingerprint',command_fingerprint)
|
|
76
|
+
END AS response FROM receipt`, [...values, preview, !!futureTerms]);
|
|
77
|
+
if (stored.rows[0]?.response)
|
|
78
|
+
return commandResult(subscriptionRecord(stored.rows[0].response));
|
|
79
|
+
if (futureTerms) {
|
|
80
|
+
if (stored.rows[0]) {
|
|
81
|
+
// A matching preview already bound the host translation. Apply that
|
|
82
|
+
// exact quote even if the host's mutable policy has since changed.
|
|
83
|
+
values[5] = { ...subscriptionRecord(subscriptionRecord(stored.rows[0].command).payload),
|
|
84
|
+
acceptedQuoteHash: input.payload.acceptedQuoteHash };
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
const terms = await futureTerms();
|
|
88
|
+
if ("outcome" in terms)
|
|
89
|
+
return commandResult({ ...terms });
|
|
90
|
+
values[5] = { ...input.payload, ...terms };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
let result;
|
|
94
|
+
if (!preview && !Object.hasOwn(input.payload, "acceptedQuoteHash")) {
|
|
95
|
+
// N2 owns every command's no-op decision. Keep only its terminal noop;
|
|
96
|
+
// other direct applies must retain their quote requirement without
|
|
97
|
+
// persisting a speculative quote or refusal from this preview probe.
|
|
98
|
+
await executor.query("SAVEPOINT subscription_command_noop");
|
|
99
|
+
const { rows } = await executor.query("SELECT public.subscription_command_preview($1::uuid,$2::uuid,$3::text,$4::uuid,$5::text,$6::jsonb,$7::timestamptz) AS response", values);
|
|
100
|
+
const candidate = commandResult(subscriptionRecord(rows[0]?.response));
|
|
101
|
+
if (candidate.status === "noop")
|
|
102
|
+
result = candidate;
|
|
103
|
+
else
|
|
104
|
+
await executor.query("ROLLBACK TO SAVEPOINT subscription_command_noop");
|
|
105
|
+
await executor.query("RELEASE SAVEPOINT subscription_command_noop");
|
|
106
|
+
}
|
|
107
|
+
if (!result) {
|
|
108
|
+
const name = preview ? "subscription_command_preview" : "subscription_command_apply";
|
|
109
|
+
const { rows } = await executor.query(`SELECT public.${name}($1::uuid,$2::uuid,$3::text,$4::uuid,$5::text,$6::jsonb,$7::timestamptz) AS response`, values);
|
|
110
|
+
result = commandResult(subscriptionRecord(rows[0]?.response));
|
|
111
|
+
}
|
|
112
|
+
if (preview || result.status === "refused")
|
|
113
|
+
return result;
|
|
114
|
+
const receipt = await executor.query(`SELECT command_fingerprint FROM public.subscription_command_receipts
|
|
115
|
+
WHERE actor_id=$1::uuid AND idempotency_key=$2::text`, [input.actorId, input.idempotencyKey]);
|
|
116
|
+
if (typeof receipt.rows[0]?.command_fingerprint !== "string")
|
|
117
|
+
throw new Error("subscription_command_receipt_missing");
|
|
118
|
+
return { ...result, commandFingerprint: receipt.rows[0].command_fingerprint };
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
/** Trusted customer/operator authorization precedes this adapter. The native
|
|
122
|
+
* result's subscriptionStatus is the state recorded by the original execution,
|
|
123
|
+
* including on replay; it is not a fresh read of mutable subscription state. */
|
|
124
|
+
export function createPostgresSubscriptionCommands(binding) {
|
|
125
|
+
function encode(command) {
|
|
126
|
+
// This serving adapter never pauses an active subscription. Existing native
|
|
127
|
+
// callers retain their separate, unchanged lifecycle contract.
|
|
128
|
+
if (command.action === "pause")
|
|
129
|
+
return { outcome: "refused", reason: "subscription_command_pause_forbidden" };
|
|
130
|
+
const { actor, subscriptionId, idempotencyKey, now, action, ...fields } = command;
|
|
131
|
+
let payload = { ...fields, actorKind: actor.kind };
|
|
132
|
+
let nativeAction = action === "skip" ? "skip_next_cycle" : action === "slide" ? "slide_next_cycle" : action;
|
|
133
|
+
if (command.action === "edit_future_terms") {
|
|
134
|
+
// Preserve the host carriers as well as their native translation in the
|
|
135
|
+
// actual-command fingerprint. The accepted hash is stripped for preview.
|
|
136
|
+
payload = { ...payload, requestedCoreLines: command.coreLines, requestedAddonLines: command.addonLines,
|
|
137
|
+
compositionConstraintRef: command.compositionConstraint };
|
|
138
|
+
delete payload.coreLines;
|
|
139
|
+
delete payload.addonLines;
|
|
140
|
+
delete payload.compositionConstraint;
|
|
141
|
+
// An empty typed hash means no quote; preserve native no-op replay/probing.
|
|
142
|
+
if (command.acceptedQuoteHash === "")
|
|
143
|
+
delete payload.acceptedQuoteHash;
|
|
144
|
+
nativeAction = "update_package_template";
|
|
145
|
+
}
|
|
146
|
+
return { actorId: actor.actorId, clientId: actor.clientId, subscriptionId, idempotencyKey, now, action: nativeAction, payload };
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
capabilities: NATIVE_SUBSCRIPTION_CAPABILITIES,
|
|
150
|
+
async preview(command) {
|
|
151
|
+
const input = encode(command);
|
|
152
|
+
if ("outcome" in input)
|
|
153
|
+
return input;
|
|
154
|
+
delete input.payload.acceptedQuoteHash;
|
|
155
|
+
return invokeCommand(binding, input, true, () => binding.futureTerms(command));
|
|
156
|
+
},
|
|
157
|
+
async apply(command) {
|
|
158
|
+
const input = encode(command);
|
|
159
|
+
if ("outcome" in input)
|
|
160
|
+
return input;
|
|
161
|
+
const result = await invokeCommand(binding, input, false, command.action === "edit_future_terms" ? () => binding.futureTerms(command) : undefined);
|
|
162
|
+
if (result.status === "refused" || result.outcome === "refused")
|
|
163
|
+
return { outcome: "refused", reason: refusalReason(result.reason) };
|
|
164
|
+
if (result.status !== "applied" && result.status !== "replayed" && result.status !== "noop")
|
|
165
|
+
throw new Error("subscription_command_response_invalid");
|
|
166
|
+
return { actorId: command.actor.actorId, idempotencyKey: command.idempotencyKey,
|
|
167
|
+
commandFingerprint: String(result.commandFingerprint), outcome: result.status, subscriptionId: String(result.subscriptionId),
|
|
168
|
+
originalState: String(result.subscriptionStatus), nextCycleAt: result.nextCycleAt == null ? null : String(result.nextCycleAt),
|
|
169
|
+
revision: String(result.templateVersion), eventId: result.eventId == null ? null : String(result.eventId) };
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
}
|
|
@@ -53,6 +53,12 @@ export declare function createPostgresRetryStore(executor: SubscriptionQueryExec
|
|
|
53
53
|
read(operationId: string): Promise<NativeRetryRow | null>;
|
|
54
54
|
candidates(limit: number, now: string): Promise<NativeRetryRow[]>;
|
|
55
55
|
applyFailure(operationId: string, key: string, input: NativeRetryFailure): Promise<NativeRetryRow>;
|
|
56
|
+
/** Read the immutable rung receipt against the current case. An interrupted
|
|
57
|
+
* apply still needs propagation; an already advanced case must not be rewritten. */
|
|
58
|
+
failureProgress(operationId: string, key: string): Promise<{
|
|
59
|
+
replayed: boolean;
|
|
60
|
+
caseAdvanced: boolean;
|
|
61
|
+
}>;
|
|
56
62
|
openCase(operationId: string, template: string, recoveryPath: string | null): Promise<Record<string, unknown>>;
|
|
57
63
|
authorizeRecovery(operationId: string, tokenId: string, methodRef: string, methodEvidenceRef: string, now: string): Promise<Record<string, unknown>>;
|
|
58
64
|
scheduleRecovery(operationId: string, key: string, authorizationId: string, now: string): Promise<Record<string, unknown>>;
|
|
@@ -34,6 +34,17 @@ export function createPostgresRetryStore(executor) {
|
|
|
34
34
|
async applyFailure(operationId, key, input) {
|
|
35
35
|
return row(await invoke("subscription_retry_apply_failure", [operationId, key, input]));
|
|
36
36
|
},
|
|
37
|
+
/** Read the immutable rung receipt against the current case. An interrupted
|
|
38
|
+
* apply still needs propagation; an already advanced case must not be rewritten. */
|
|
39
|
+
async failureProgress(operationId, key) {
|
|
40
|
+
const result = await executor.query(`SELECT true AS replayed,
|
|
41
|
+
(c.retry_attempt >= (r.result->>'retry_attempt')::integer OR o.state='recovered') AS case_advanced
|
|
42
|
+
FROM public.subscription_retry_obligations o
|
|
43
|
+
JOIN public.subscription_retry_receipts r ON r.obligation_id=o.id AND r.kind='failure' AND r.receipt_key=$2::text
|
|
44
|
+
LEFT JOIN public.subscription_dunning_cases c ON c.id=o.case_id
|
|
45
|
+
WHERE o.renewal_operation_id=$1::uuid`, [operationId, key]);
|
|
46
|
+
return { replayed: result.rows.length === 1, caseAdvanced: result.rows[0]?.case_advanced === true };
|
|
47
|
+
},
|
|
37
48
|
async openCase(operationId, template, recoveryPath) {
|
|
38
49
|
return subscriptionRecord(await invoke("subscription_retry_open_case", [operationId, template, recoveryPath]));
|
|
39
50
|
},
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { StockWaitCase, StockWaitDeficit, StockWaitFrozenCase, StockWaitRecoveryBinding } from "./contracts/stockWait.js";
|
|
2
|
+
import type { RenewalSchedule } from "./contracts/renewal.js";
|
|
3
|
+
import { type NativeRenewalCodec, type SubscriptionQueryExecutor } from "./postgresRenewalStore.js";
|
|
4
|
+
export interface NativeStockWaitBinding {
|
|
5
|
+
executor: SubscriptionQueryExecutor;
|
|
6
|
+
withTransaction<T>(work: (executor: SubscriptionQueryExecutor) => Promise<T>): Promise<T>;
|
|
7
|
+
codec: NativeRenewalCodec;
|
|
8
|
+
workerId: string;
|
|
9
|
+
}
|
|
10
|
+
export interface NativeStockWaitAdmission {
|
|
11
|
+
waiting: boolean;
|
|
12
|
+
caseId: string | null;
|
|
13
|
+
response: Record<string, unknown>;
|
|
14
|
+
}
|
|
15
|
+
/** Each multi-statement mutation uses the host transaction. Payment execution is
|
|
16
|
+
* deliberately outside this store. SQL owns stock locks and subtransactions. */
|
|
17
|
+
export declare function createPostgresStockWaitStore(options: NativeStockWaitBinding): {
|
|
18
|
+
readFrozen(input: RenewalSchedule): Promise<StockWaitFrozenCase | null>;
|
|
19
|
+
listNormalDue(input: {
|
|
20
|
+
limit: number;
|
|
21
|
+
asOf: string;
|
|
22
|
+
}): Promise<import("./contracts/renewal.js").RenewalDue[]>;
|
|
23
|
+
deficits(input: {
|
|
24
|
+
snapshot: import("./contracts/renewal.js").RenewalFrozenSnapshot;
|
|
25
|
+
operationId: string;
|
|
26
|
+
now: string;
|
|
27
|
+
}): Promise<StockWaitDeficit[]>;
|
|
28
|
+
admit(input: {
|
|
29
|
+
operationId: string;
|
|
30
|
+
claimToken: string;
|
|
31
|
+
snapshot: import("./contracts/renewal.js").RenewalFrozenSnapshot;
|
|
32
|
+
recovery?: StockWaitRecoveryBinding;
|
|
33
|
+
now: string;
|
|
34
|
+
}): Promise<NativeStockWaitAdmission>;
|
|
35
|
+
enqueue(caseId: string, kind: "started" | "resumed"): Promise<void>;
|
|
36
|
+
markResumed(orderId: string): Promise<StockWaitCase | null>;
|
|
37
|
+
listCandidates(input: {
|
|
38
|
+
limit: number;
|
|
39
|
+
asOf: string;
|
|
40
|
+
}): Promise<StockWaitFrozenCase[]>;
|
|
41
|
+
readFrozenCase(input: StockWaitRecoveryBinding): Promise<StockWaitCase | null>;
|
|
42
|
+
reserveAndTransition(input: {
|
|
43
|
+
caseId: string;
|
|
44
|
+
frozenFingerprint: string;
|
|
45
|
+
now: string;
|
|
46
|
+
}): Promise<StockWaitCase>;
|
|
47
|
+
recoverRefusedSchedule(input: {
|
|
48
|
+
operationId: string;
|
|
49
|
+
caseId: string;
|
|
50
|
+
claimGeneration: number;
|
|
51
|
+
frozenFingerprint: string;
|
|
52
|
+
scheduledAt: string;
|
|
53
|
+
now: string;
|
|
54
|
+
}): Promise<{
|
|
55
|
+
outcome: "recovered";
|
|
56
|
+
operation: import("./contracts/renewal.js").RenewalReadback;
|
|
57
|
+
replayed: boolean;
|
|
58
|
+
} | {
|
|
59
|
+
outcome: "refused";
|
|
60
|
+
reason: "not_stock_only" | "external_ownership" | "stale_snapshot" | "lease_active" | "inventory_unavailable";
|
|
61
|
+
}>;
|
|
62
|
+
};
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { subscriptionRecord } from "./postgresRenewalStore.js";
|
|
2
|
+
const nullable = (value) => value == null ? null : String(value);
|
|
3
|
+
function binding(value) {
|
|
4
|
+
const r = subscriptionRecord(value);
|
|
5
|
+
for (const key of ["caseId", "cycleId", "orderId", "paymentId", "engineIdempotencyKey"]) {
|
|
6
|
+
if (r[key] !== null && typeof r[key] !== "string")
|
|
7
|
+
throw new Error("stock_wait_binding_invalid");
|
|
8
|
+
}
|
|
9
|
+
return r;
|
|
10
|
+
}
|
|
11
|
+
function array(value) {
|
|
12
|
+
if (!Array.isArray(value))
|
|
13
|
+
throw new Error("stock_wait_array_invalid");
|
|
14
|
+
return value;
|
|
15
|
+
}
|
|
16
|
+
/** Each multi-statement mutation uses the host transaction. Payment execution is
|
|
17
|
+
* deliberately outside this store. SQL owns stock locks and subtransactions. */
|
|
18
|
+
export function createPostgresStockWaitStore(options) {
|
|
19
|
+
const { executor, codec } = options;
|
|
20
|
+
async function invoke(db, name, values) {
|
|
21
|
+
return (await db.query(`SELECT public.${name}(${values.map((_, i) => `$${i + 1}`).join(",")}) AS response`, values)).rows[0]?.response;
|
|
22
|
+
}
|
|
23
|
+
async function deficits(db, value) {
|
|
24
|
+
const rows = array(value).map(subscriptionRecord);
|
|
25
|
+
if (rows.length === 0)
|
|
26
|
+
return [];
|
|
27
|
+
const catalog = (await db.query("SELECT id,sku,title FROM public.catalog_skus WHERE sku=ANY($1::text[])", [rows.map(r => String(r.sku))])).rows;
|
|
28
|
+
return rows.map(value => {
|
|
29
|
+
const r = subscriptionRecord(value);
|
|
30
|
+
// Catalogue labels enrich the projection; quantities still come only from N3.
|
|
31
|
+
const item = catalog.find(item => item.sku === r.sku);
|
|
32
|
+
if (!item)
|
|
33
|
+
throw new Error("stock_wait_catalog_sku_missing");
|
|
34
|
+
return { skuId: String(item.id), sku: String(r.sku), displayName: String(item.title), size: null,
|
|
35
|
+
required: Number(r.requiredUnits), reserved: Number(r.reservedUnits),
|
|
36
|
+
available: Number(r.availableUnits), missing: Number(r.missingUnits) };
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
async function readCase(db, caseId) {
|
|
40
|
+
const rows = (await db.query(`SELECT public.subscription_stock_wait_read(c.id) AS projection,
|
|
41
|
+
c.cycle_snapshot,op.identity_key,cycle.id AS bound_cycle_id,ord.id AS bound_order_id,intent.id AS payment_id
|
|
42
|
+
FROM public.subscription_stock_wait_cases c JOIN public.subscription_renewal_operations op ON op.id=c.renewal_operation_id
|
|
43
|
+
LEFT JOIN public.subscription_cycles cycle ON cycle.renewal_operation_id=op.id
|
|
44
|
+
LEFT JOIN public.commerce_orders ord ON ord.renewal_operation_id=op.id
|
|
45
|
+
LEFT JOIN public.commerce_settlement_intents intent ON intent.renewal_operation_id=op.id WHERE c.id=$1::uuid`, [caseId])).rows;
|
|
46
|
+
if (!rows[0])
|
|
47
|
+
return null;
|
|
48
|
+
const r = subscriptionRecord(rows[0].projection), snapshot = codec.decode(subscriptionRecord(rows[0].cycle_snapshot));
|
|
49
|
+
if (!["waiting", "stock_secured", "resumed", "closed"].includes(String(r.state)))
|
|
50
|
+
throw new Error("stock_wait_state_invalid");
|
|
51
|
+
return { subscriptionId: String(r.subscription_id), scheduledAt: snapshot.scheduledAt, scheduledInstant: snapshot.scheduledInstant,
|
|
52
|
+
caseId: String(r.id), clientId: String(r.client_id), state: r.state,
|
|
53
|
+
frozenSnapshot: snapshot, frozenFingerprint: String(r.fingerprint),
|
|
54
|
+
binding: { caseId, cycleId: nullable(rows[0].bound_cycle_id), orderId: nullable(rows[0].bound_order_id),
|
|
55
|
+
paymentId: nullable(rows[0].payment_id), engineIdempotencyKey: String(rows[0].identity_key) },
|
|
56
|
+
deficits: await deficits(db, r.deficits), fulfillmentRef: nullable(r.fulfillment_id), blockReason: nullable(r.block_reason),
|
|
57
|
+
openedAt: String(r.opened_at), checkedAt: nullable(r.last_checked_at), selectedAt: nullable(r.last_selected_at),
|
|
58
|
+
securedAt: nullable(r.stock_secured_at), resumedAt: nullable(r.resumed_at), closedAt: nullable(r.closed_at),
|
|
59
|
+
startedNoticeKey: `subscription_stock_wait:${caseId}:started`, resumedNoticeKey: `subscription_stock_wait:${caseId}:resumed`,
|
|
60
|
+
noticeEligible: r.waitingNoticeRelevant === true };
|
|
61
|
+
}
|
|
62
|
+
async function frozen(db, value) {
|
|
63
|
+
const r = subscriptionRecord(value), b = binding(r.binding), snapshot = codec.decode(subscriptionRecord(r.snapshot));
|
|
64
|
+
return { subscriptionId: snapshot.subscriptionId, scheduledAt: snapshot.scheduledAt, scheduledInstant: snapshot.scheduledInstant, operationId: String(r.operationId),
|
|
65
|
+
case: b.caseId === null ? null : await readCase(db, b.caseId), frozenSnapshot: snapshot,
|
|
66
|
+
frozenFingerprint: String(r.operationFingerprint), binding: b };
|
|
67
|
+
}
|
|
68
|
+
async function recover(db, input) {
|
|
69
|
+
// Match the SQL lock order, then fence the schedule and generation not present
|
|
70
|
+
// in N3's SQL signature. No provider-owned refusal is converted to a wait.
|
|
71
|
+
await db.query(`SELECT id FROM public.subscriptions WHERE id=(SELECT subscription_id
|
|
72
|
+
FROM public.subscription_renewal_operations WHERE id=$1::uuid) FOR UPDATE`, [input.operationId]);
|
|
73
|
+
const row = (await db.query(`SELECT op.*,op.scheduled_at=$3::timestamptz AS schedule_matches,c.id AS case_id,c.state AS case_state,
|
|
74
|
+
EXISTS(SELECT 1 FROM public.fulfillment_inventory_reservations r WHERE r.renewal_operation_id=op.id
|
|
75
|
+
AND r.status='held' AND r.expires_at>$4::timestamptz) AS live_hold,
|
|
76
|
+
EXISTS(SELECT 1 FROM public.subscription_cycles WHERE renewal_operation_id=op.id) OR
|
|
77
|
+
EXISTS(SELECT 1 FROM public.commerce_orders WHERE renewal_operation_id=op.id) OR
|
|
78
|
+
EXISTS(SELECT 1 FROM public.commerce_settlement_intents WHERE renewal_operation_id=op.id) OR
|
|
79
|
+
EXISTS(SELECT 1 FROM public.subscription_renewal_outcomes WHERE renewal_operation_id=op.id AND
|
|
80
|
+
(outcome<>'refused' OR reason IS NULL OR reason NOT IN ('inventory_unavailable','reservation_expired') OR external_ref IS NOT NULL)) AS has_artifacts
|
|
81
|
+
FROM public.subscription_renewal_operations op LEFT JOIN public.subscription_stock_wait_cases c ON c.renewal_operation_id=op.id
|
|
82
|
+
WHERE op.id=$1::uuid AND c.id=$2::uuid FOR UPDATE OF op`, [input.operationId, input.caseId, input.scheduledAt, input.now])).rows[0];
|
|
83
|
+
if (!row || !row.schedule_matches || row.operation_fingerprint !== input.frozenFingerprint) {
|
|
84
|
+
return { outcome: "refused", reason: "stale_snapshot" };
|
|
85
|
+
}
|
|
86
|
+
if (row.has_artifacts || row.attempt_prepared_at != null || row.external_attempt_ref != null || row.external_attempt_acknowledged_at != null) {
|
|
87
|
+
return { outcome: "refused", reason: "external_ownership" };
|
|
88
|
+
}
|
|
89
|
+
if (!(row.state === "refused" && ["inventory_unavailable", "reservation_expired"].includes(String(row.terminal_reason)))
|
|
90
|
+
&& !(row.state === "reserved" && row.case_state === "stock_secured"))
|
|
91
|
+
return { outcome: "refused", reason: "not_stock_only" };
|
|
92
|
+
const securedReplay = row.case_state === "stock_secured" && row.state === "reserved" && row.live_hold === true
|
|
93
|
+
&& new Date(String(row.lease_expires_at)).getTime() > Date.parse(input.now)
|
|
94
|
+
&& [input.claimGeneration, input.claimGeneration + 1].includes(Number(row.claim_generation));
|
|
95
|
+
if (!securedReplay && Number(row.claim_generation) !== input.claimGeneration)
|
|
96
|
+
return { outcome: "refused", reason: "stale_snapshot" };
|
|
97
|
+
if (!securedReplay && new Date(String(row.lease_expires_at)).getTime() > Date.parse(input.now))
|
|
98
|
+
return { outcome: "refused", reason: "lease_active" };
|
|
99
|
+
const result = subscriptionRecord(await invoke(db, "subscription_stock_wait_recover_refusal", [input.operationId, input.frozenFingerprint, options.workerId, input.now]));
|
|
100
|
+
if (result.waiting === true)
|
|
101
|
+
return { outcome: "refused", reason: "inventory_unavailable" };
|
|
102
|
+
return { outcome: "recovered", operation: codec.readback(result), replayed: result.replayed === true };
|
|
103
|
+
}
|
|
104
|
+
const port = {
|
|
105
|
+
async listCandidates(input) {
|
|
106
|
+
return options.withTransaction(async (db) => {
|
|
107
|
+
const values = array(await invoke(db, "subscription_stock_wait_candidates", [input.limit, input.asOf]));
|
|
108
|
+
const result = [];
|
|
109
|
+
for (const value of values)
|
|
110
|
+
result.push(await frozen(db, value));
|
|
111
|
+
return result;
|
|
112
|
+
});
|
|
113
|
+
},
|
|
114
|
+
async readFrozenCase(input) {
|
|
115
|
+
if (input.caseId === null)
|
|
116
|
+
return null;
|
|
117
|
+
const result = await readCase(executor, input.caseId);
|
|
118
|
+
if (result && Object.keys(input).some(key => input[key] !== result.binding[key])) {
|
|
119
|
+
throw new Error("stock_wait_frozen_binding_changed");
|
|
120
|
+
}
|
|
121
|
+
return result;
|
|
122
|
+
},
|
|
123
|
+
async reserveAndTransition(input) {
|
|
124
|
+
return options.withTransaction(async (db) => {
|
|
125
|
+
const row = (await db.query(`SELECT op.id,op.claim_generation,op.scheduled_at::text FROM public.subscription_stock_wait_cases c
|
|
126
|
+
JOIN public.subscription_renewal_operations op ON op.id=c.renewal_operation_id WHERE c.id=$1::uuid`, [input.caseId])).rows[0];
|
|
127
|
+
if (!row)
|
|
128
|
+
throw new Error("stock_wait_case_missing");
|
|
129
|
+
const result = await recover(db, { ...input, operationId: String(row.id), claimGeneration: Number(row.claim_generation), scheduledAt: String(row.scheduled_at) });
|
|
130
|
+
if (result.outcome === "refused" && result.reason !== "inventory_unavailable")
|
|
131
|
+
throw new Error(`stock_wait_${result.reason}`);
|
|
132
|
+
const current = await readCase(db, input.caseId);
|
|
133
|
+
if (!current)
|
|
134
|
+
throw new Error("stock_wait_case_missing");
|
|
135
|
+
return current;
|
|
136
|
+
});
|
|
137
|
+
},
|
|
138
|
+
recoverRefusedSchedule(input) { return options.withTransaction(db => recover(db, input)); },
|
|
139
|
+
};
|
|
140
|
+
return {
|
|
141
|
+
...port,
|
|
142
|
+
async readFrozen(input) {
|
|
143
|
+
return options.withTransaction(async (db) => {
|
|
144
|
+
const value = await invoke(db, "subscription_stock_wait_frozen", [input.subscriptionId, input.scheduledAt]);
|
|
145
|
+
return value == null ? null : frozen(db, value);
|
|
146
|
+
});
|
|
147
|
+
},
|
|
148
|
+
async listNormalDue(input) {
|
|
149
|
+
return array(await invoke(executor, "subscription_stock_wait_normal_due", [input.limit, input.asOf])).map(value => codec.due(subscriptionRecord(value)));
|
|
150
|
+
},
|
|
151
|
+
async deficits(input) {
|
|
152
|
+
return options.withTransaction(async (db) => deficits(db, await invoke(db, "subscription_stock_wait_deficits", [codec.encode(input.snapshot), input.operationId, input.now])));
|
|
153
|
+
},
|
|
154
|
+
async admit(input) {
|
|
155
|
+
const request = { operationId: input.operationId, claimToken: input.claimToken, snapshot: codec.encode(input.snapshot),
|
|
156
|
+
operationFingerprint: codec.fingerprint(input.snapshot), identityKey: codec.identity(input.snapshot), worker: options.workerId };
|
|
157
|
+
const due = input.recovery ? { stockWaitRecovery: input.recovery } : {};
|
|
158
|
+
const response = subscriptionRecord(await invoke(executor, "subscription_stock_wait_admit", [request, due, input.now]));
|
|
159
|
+
if (response.waiting === true && typeof response.caseId !== "string")
|
|
160
|
+
throw new Error("stock_wait_case_identity_missing");
|
|
161
|
+
return { waiting: response.waiting === true, caseId: nullable(response.caseId), response };
|
|
162
|
+
},
|
|
163
|
+
async enqueue(caseId, kind) {
|
|
164
|
+
await executor.query(`SELECT public.subscription_stock_wait_enqueue(c,$2::text)
|
|
165
|
+
FROM public.subscription_stock_wait_cases c WHERE c.id=$1::uuid`, [caseId, kind]);
|
|
166
|
+
},
|
|
167
|
+
markResumed(orderId) {
|
|
168
|
+
return options.withTransaction(async (db) => {
|
|
169
|
+
const value = await invoke(db, "subscription_stock_wait_mark_resumed", [orderId]);
|
|
170
|
+
return value == null ? null : readCase(db, String(subscriptionRecord(value).id));
|
|
171
|
+
});
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
}
|
|
@@ -25,7 +25,7 @@ export async function propagateRenewalFailure(port, ctx, policy) {
|
|
|
25
25
|
});
|
|
26
26
|
const cycleSnapshot = await port.readCycleRetryState(ctx.cycleId);
|
|
27
27
|
return {
|
|
28
|
-
...await openDunningCase(port, ctx, cycleSnapshot, occurredAt, policy),
|
|
28
|
+
...await openDunningCase(port, ctx, cycleSnapshot, occurredAt, policy, applied.paymentAttemptId ?? null),
|
|
29
29
|
applyReplayed: applied.replayed,
|
|
30
30
|
};
|
|
31
31
|
}
|
|
@@ -37,7 +37,7 @@ async function resolveDurableOccurredAt(port, ctx, policy) {
|
|
|
37
37
|
});
|
|
38
38
|
return durable !== null && durable.length > 0 ? durable : ctx.occurredAt;
|
|
39
39
|
}
|
|
40
|
-
async function openDunningCase(port, ctx, cycle, occurredAt, policy) {
|
|
40
|
+
async function openDunningCase(port, ctx, cycle, occurredAt, policy, failedPaymentAttemptId) {
|
|
41
41
|
const retryAttempt = cycle.retryAttempt ?? 1;
|
|
42
42
|
// Resolve again after the retry read: original context evidence remains dynamic.
|
|
43
43
|
const failureClass = policy.resolveClassification(ctx.failureClassification, ctx.failureReason).failureClass;
|
|
@@ -52,6 +52,7 @@ async function openDunningCase(port, ctx, cycle, occurredAt, policy) {
|
|
|
52
52
|
? cycle.nextRetryAt
|
|
53
53
|
: nextRetryAttemptAt(occurredAt, retryAttempt, undefined, failureClass);
|
|
54
54
|
const outcome = await port.openDunningCase({
|
|
55
|
+
failedPaymentAttemptId,
|
|
55
56
|
idempotencyKey: policy.dunningKey(ctx.executionIdempotencyKey, retryAttempt),
|
|
56
57
|
cycleId: ctx.cycleId,
|
|
57
58
|
subscriptionId: ctx.subscriptionId,
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { SubscriptionCapabilities } from "./contracts/commands.js";
|
|
2
|
+
/** One host executor for a selected population. Construction never starts work. */
|
|
3
|
+
export declare function createSubscriptionServices<Result>(input: {
|
|
4
|
+
enabled?: boolean;
|
|
5
|
+
capabilities: SubscriptionCapabilities;
|
|
6
|
+
readiness(): Promise<{
|
|
7
|
+
kind: "ready";
|
|
8
|
+
} | {
|
|
9
|
+
kind: "refused";
|
|
10
|
+
code: string;
|
|
11
|
+
}>;
|
|
12
|
+
run(): Promise<Result>;
|
|
13
|
+
}): {
|
|
14
|
+
capabilities: SubscriptionCapabilities;
|
|
15
|
+
readiness: () => Promise<{
|
|
16
|
+
kind: "ready";
|
|
17
|
+
} | {
|
|
18
|
+
kind: "refused";
|
|
19
|
+
code: string;
|
|
20
|
+
} | {
|
|
21
|
+
readonly kind: "refused";
|
|
22
|
+
readonly code: "disabled";
|
|
23
|
+
}>;
|
|
24
|
+
run(): Promise<Result | {
|
|
25
|
+
kind: "refused";
|
|
26
|
+
code: string;
|
|
27
|
+
} | {
|
|
28
|
+
readonly kind: "refused";
|
|
29
|
+
readonly code: "disabled";
|
|
30
|
+
}>;
|
|
31
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** One host executor for a selected population. Construction never starts work. */
|
|
2
|
+
export function createSubscriptionServices(input) {
|
|
3
|
+
async function readiness() {
|
|
4
|
+
if (input.enabled !== true)
|
|
5
|
+
return { kind: "refused", code: "disabled" };
|
|
6
|
+
return input.readiness();
|
|
7
|
+
}
|
|
8
|
+
return {
|
|
9
|
+
capabilities: input.capabilities, readiness,
|
|
10
|
+
async run() {
|
|
11
|
+
const state = await readiness();
|
|
12
|
+
return state.kind === "ready" ? input.run() : state;
|
|
13
|
+
},
|
|
14
|
+
};
|
|
15
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openlup/platform",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.26.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Runnable server substrate: Node HTTP runtime, BFF dispatch, scheduler, storage, data gateways and bundle composition; development preview.",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"api:update": "npm run build && node --experimental-strip-types ./scripts/api-contract.ts --update"
|
|
32
32
|
},
|
|
33
33
|
"peerDependencies": {
|
|
34
|
-
"@openlup/core": "0.
|
|
34
|
+
"@openlup/core": "0.26.0",
|
|
35
35
|
"pg": "^8.22.0",
|
|
36
36
|
"@aws-sdk/client-s3": "^3.1144.0",
|
|
37
37
|
"@types/pg": "^8.20.0"
|
package/release-gates.json
CHANGED