@openlup/platform 0.23.0 → 0.25.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 +327 -269
- package/README.md +123 -12
- 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/fulfillment/contracts/dispatch.d.ts +4 -0
- package/dist/fulfillment/dispatchProjection.d.ts +1 -1
- package/dist/fulfillment/dispatchProjection.js +4 -2
- package/dist/fulfillment/omnipack/client.js +14 -6
- package/dist/fulfillment/omnipack/index.js +5 -2
- package/dist/fulfillment/providerDispatchWorker.d.ts +38 -0
- package/dist/fulfillment/providerDispatchWorker.js +19 -1
- 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/invoicing/accountingCanonicalProviderLines.d.ts +5 -0
- package/dist/invoicing/accountingCanonicalProviderLines.js +104 -0
- package/dist/invoicing/accountingLineAllocation.d.ts +2 -0
- package/dist/invoicing/accountingLineAllocation.js +69 -0
- package/dist/invoicing/adapterConformance.d.ts +42 -0
- package/dist/invoicing/adapterConformance.js +296 -0
- package/dist/invoicing/artifacts.d.ts +7 -0
- package/dist/invoicing/artifacts.js +20 -0
- package/dist/invoicing/bindings.d.ts +46 -0
- package/dist/invoicing/bindings.js +69 -0
- package/dist/invoicing/channelPolicy.d.ts +28 -0
- package/dist/invoicing/channelPolicy.js +27 -0
- package/dist/invoicing/continuity.d.ts +12 -0
- package/dist/invoicing/continuity.js +18 -0
- package/dist/invoicing/contracts/facts.d.ts +11 -1
- package/dist/invoicing/contracts/host.d.ts +3 -4
- package/dist/invoicing/contracts/index.d.ts +1 -1
- package/dist/invoicing/contracts/store.d.ts +15 -5
- package/dist/invoicing/contribution.d.ts +21 -0
- package/dist/invoicing/contribution.js +19 -0
- package/dist/invoicing/correction.d.ts +42 -0
- package/dist/invoicing/correction.js +44 -0
- package/dist/invoicing/delivery.d.ts +85 -0
- package/dist/invoicing/delivery.js +79 -0
- package/dist/invoicing/documentHistory.d.ts +50 -0
- package/dist/invoicing/documentHistory.js +213 -0
- package/dist/invoicing/documentSync.d.ts +24 -0
- package/dist/invoicing/documentSync.js +26 -0
- package/dist/invoicing/dutyCollector.d.ts +15 -0
- package/dist/invoicing/dutyCollector.js +35 -0
- package/dist/invoicing/fulfillmentTrigger.d.ts +56 -0
- package/dist/invoicing/fulfillmentTrigger.js +90 -0
- package/dist/invoicing/handoff.d.ts +37 -0
- package/dist/invoicing/handoff.js +62 -0
- package/dist/invoicing/index.d.ts +38 -0
- package/dist/invoicing/index.js +36 -0
- package/dist/invoicing/issue.d.ts +39 -0
- package/dist/invoicing/issue.js +55 -0
- package/dist/invoicing/migrations.d.ts +48 -0
- package/dist/invoicing/migrations.js +25 -0
- package/dist/invoicing/nativeUnsupported.d.ts +9 -0
- package/dist/invoicing/nativeUnsupported.js +22 -0
- package/dist/invoicing/paymentReadback.d.ts +20 -0
- package/dist/invoicing/paymentReadback.js +47 -0
- package/dist/invoicing/postgresInvoiceStore.d.ts +55 -0
- package/dist/invoicing/postgresInvoiceStore.js +344 -0
- package/dist/invoicing/readModel.d.ts +74 -0
- package/dist/invoicing/readModel.js +90 -0
- package/dist/invoicing/readback.d.ts +4 -0
- package/dist/invoicing/readback.js +30 -0
- package/dist/invoicing/reversal.d.ts +37 -0
- package/dist/invoicing/reversal.js +28 -0
- package/dist/invoicing/sellerRevision.d.ts +19 -0
- package/dist/invoicing/sellerRevision.js +14 -0
- package/dist/invoicing/settlementSync.d.ts +18 -0
- package/dist/invoicing/settlementSync.js +32 -0
- package/dist/invoicing/snapshot.d.ts +52 -0
- package/dist/invoicing/snapshot.js +141 -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/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/services.d.ts +31 -0
- package/dist/subscriptions/services.js +15 -0
- package/package.json +3 -3
- package/release-gates.json +21 -7
- package/sql/20260809210000_accounting_document_rail.sql +889 -0
- package/sql/20260811150000_accounting_payout_rail.sql +333 -0
- package/sql/20260812130000_accounting_document_lifecycle_rail.sql +460 -0
- package/sql/20261022190000_invoicing_durable_duties.sql +494 -0
- package/sql/20261022191000_invoicing_document_effects.sql +249 -0
- package/sql/20261022192000_invoicing_payment_decoupling.sql +144 -0
- package/sql/20261022195000_invoicing_seller_revision.sql +65 -0
- 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/fulfillment/contracts/dispatch.ts +4 -0
- package/src/fulfillment/dispatchProjection.ts +4 -2
- package/src/fulfillment/omnipack/client.ts +13 -6
- package/src/fulfillment/omnipack/index.ts +5 -2
- package/src/fulfillment/providerDispatchWorker.ts +14 -1
- package/src/inventory/index.ts +2 -0
- package/src/inventory/postgresStockAvailability.ts +54 -0
- package/src/invoicing/accountingCanonicalProviderLines.ts +134 -0
- package/src/invoicing/accountingLineAllocation.ts +69 -0
- package/src/invoicing/adapterConformance.ts +249 -0
- package/src/invoicing/artifacts.ts +19 -0
- package/src/invoicing/bindings.ts +80 -0
- package/src/invoicing/channelPolicy.ts +50 -0
- package/src/invoicing/continuity.ts +15 -0
- package/src/invoicing/contracts/facts.ts +12 -1
- package/src/invoicing/contracts/host.ts +3 -4
- package/src/invoicing/contracts/index.ts +1 -1
- package/src/invoicing/contracts/store.ts +11 -7
- package/src/invoicing/contribution.ts +28 -0
- package/src/invoicing/correction.ts +56 -0
- package/src/invoicing/delivery.ts +122 -0
- package/src/invoicing/documentHistory.ts +301 -0
- package/src/invoicing/documentSync.ts +51 -0
- package/src/invoicing/dutyCollector.ts +31 -0
- package/src/invoicing/fulfillmentTrigger.ts +144 -0
- package/src/invoicing/handoff.ts +71 -0
- package/src/invoicing/index.ts +38 -0
- package/src/invoicing/issue.ts +64 -0
- package/src/invoicing/migrations.ts +25 -0
- package/src/invoicing/nativeUnsupported.ts +24 -0
- package/src/invoicing/paymentReadback.ts +70 -0
- package/src/invoicing/postgresInvoiceStore.ts +334 -0
- package/src/invoicing/readModel.ts +80 -0
- package/src/invoicing/readback.ts +26 -0
- package/src/invoicing/reversal.ts +34 -0
- package/src/invoicing/sellerRevision.ts +20 -0
- package/src/invoicing/settlementSync.ts +37 -0
- package/src/invoicing/snapshot.ts +192 -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/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/services.ts +21 -0
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import type { InvoiceBuyer } from "./contracts/facts.js";
|
|
2
|
+
import { mapCanonicalIssueLines } from "./accountingCanonicalProviderLines.js";
|
|
3
|
+
import { allocateChargedGrossTotals } from "./accountingLineAllocation.js";
|
|
4
|
+
|
|
5
|
+
type IssueLines = ReturnType<typeof mapCanonicalIssueLines>;
|
|
6
|
+
interface Options {
|
|
7
|
+
defaultLineLabel: string;
|
|
8
|
+
deliveryLabel: string;
|
|
9
|
+
defaultTaxRate: string;
|
|
10
|
+
}
|
|
11
|
+
/** Compatibility projection of a claimed host snapshot, not a second store contract.
|
|
12
|
+
* Seller/date/kind/payment selection remains with the host; no environment is read.
|
|
13
|
+
*/
|
|
14
|
+
export function mapInvoiceClaimToProviderSnapshot<Seller, Kind, Payment>(input: {
|
|
15
|
+
orderRef: string;
|
|
16
|
+
issueDate: string;
|
|
17
|
+
sellDate: string;
|
|
18
|
+
currency: string;
|
|
19
|
+
seller: Seller;
|
|
20
|
+
buyerSnapshot: Record<string, unknown>;
|
|
21
|
+
documentKind: Kind;
|
|
22
|
+
governmentClearanceRequired: boolean;
|
|
23
|
+
payment: Payment;
|
|
24
|
+
linesSnapshot: unknown[];
|
|
25
|
+
totalGrossMinor: number;
|
|
26
|
+
totalNetMinor: number;
|
|
27
|
+
orderMoney: unknown;
|
|
28
|
+
}, options: Options & { defaultBuyerLabel: string }) {
|
|
29
|
+
return {
|
|
30
|
+
orderRef: input.orderRef,
|
|
31
|
+
issueDate: input.issueDate,
|
|
32
|
+
sellDate: input.sellDate,
|
|
33
|
+
currency: input.currency,
|
|
34
|
+
seller: input.seller,
|
|
35
|
+
buyer: {
|
|
36
|
+
name: textValue(input.buyerSnapshot.name) ?? options.defaultBuyerLabel,
|
|
37
|
+
email: textValue(input.buyerSnapshot.email),
|
|
38
|
+
taxId: textValue(input.buyerSnapshot.taxId),
|
|
39
|
+
companyName: textValue(input.buyerSnapshot.companyName),
|
|
40
|
+
address: mapBuyerAddress(input.buyerSnapshot.address),
|
|
41
|
+
},
|
|
42
|
+
documentKind: input.documentKind,
|
|
43
|
+
governmentClearanceRequired: input.governmentClearanceRequired,
|
|
44
|
+
payment: input.payment,
|
|
45
|
+
lines: mapIssueLinesToChargedTotal(input.linesSnapshot, input.totalGrossMinor, input.totalNetMinor, input.orderMoney, options),
|
|
46
|
+
chargedTotalGrossMinor: input.totalGrossMinor,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Canonical issue snapshots already carry effective item and explicit delivery
|
|
51
|
+
// positions and are verified as a zero-delta passthrough. Historical snapshots
|
|
52
|
+
// without positionKind retain the independent #1693 reconciliation below.
|
|
53
|
+
function mapIssueLinesToChargedTotal(
|
|
54
|
+
linesSnapshot: unknown[],
|
|
55
|
+
chargedTotalGrossMinor: number,
|
|
56
|
+
chargedTotalNetMinor: number,
|
|
57
|
+
orderMoney: unknown,
|
|
58
|
+
options: Options,
|
|
59
|
+
): IssueLines {
|
|
60
|
+
const canonicalKinds = linesSnapshot.map((line) => textValue(isRecord(line) ? line.positionKind : null));
|
|
61
|
+
if (canonicalKinds.some(Boolean)) {
|
|
62
|
+
if (!canonicalKinds.every((kind) => kind === "item" || kind === "delivery")) {
|
|
63
|
+
throw new Error("accounting_invoice_canonical_positions_mixed_or_invalid");
|
|
64
|
+
}
|
|
65
|
+
return mapCanonicalIssueLines(
|
|
66
|
+
linesSnapshot,
|
|
67
|
+
chargedTotalGrossMinor,
|
|
68
|
+
chargedTotalNetMinor,
|
|
69
|
+
options.defaultLineLabel,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const parsed = linesSnapshot.map((line) => parseLineSnapshot(line, options));
|
|
74
|
+
const deliveryGrossMinor = readDeliveryGrossMinor(orderMoney, chargedTotalGrossMinor);
|
|
75
|
+
const chargedTotals = allocateChargedGrossTotals(
|
|
76
|
+
parsed.map((line) => line.totalGrossMinor),
|
|
77
|
+
chargedTotalGrossMinor - deliveryGrossMinor,
|
|
78
|
+
);
|
|
79
|
+
const positions: IssueLines = parsed.map((line, index) => {
|
|
80
|
+
const totalGrossMinor = chargedTotals[index];
|
|
81
|
+
const totalNetMinor = estimateNetMinor(totalGrossMinor, line.vatRate);
|
|
82
|
+
return {
|
|
83
|
+
name: line.name,
|
|
84
|
+
quantity: line.quantity,
|
|
85
|
+
unitGrossMinor: Math.round(totalGrossMinor / line.quantity),
|
|
86
|
+
unitNetMinor: Math.round(totalNetMinor / line.quantity),
|
|
87
|
+
totalGrossMinor,
|
|
88
|
+
totalNetMinor,
|
|
89
|
+
vatRate: line.vatRate,
|
|
90
|
+
};
|
|
91
|
+
});
|
|
92
|
+
if (deliveryGrossMinor > 0) {
|
|
93
|
+
positions.push(buildDeliveryPosition(deliveryGrossMinor, parsed, options));
|
|
94
|
+
}
|
|
95
|
+
return positions;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Legacy-only compatibility: pre-canonical claims had no explicit delivery
|
|
99
|
+
// position, so their effective delivery is the closed header residual. Current
|
|
100
|
+
// canonical claims never enter this path and use persisted shipping columns.
|
|
101
|
+
// Every inconsistent legacy shape degrades to 0 and then fails closed if the
|
|
102
|
+
// catalog lines cannot reconcile to the charged header.
|
|
103
|
+
function readDeliveryGrossMinor(orderMoney: unknown, chargedTotalGrossMinor: number): number {
|
|
104
|
+
if (!isRecord(orderMoney)) return 0;
|
|
105
|
+
const subtotal = numberValue(orderMoney.subtotalCents);
|
|
106
|
+
const discount = numberValue(orderMoney.discountCents);
|
|
107
|
+
const total = numberValue(orderMoney.totalCents);
|
|
108
|
+
if (subtotal === null || discount === null || total === null) return 0;
|
|
109
|
+
if (![subtotal, discount, total].every((value) => Number.isInteger(value) && value >= 0)) return 0;
|
|
110
|
+
if (discount > subtotal) return 0;
|
|
111
|
+
if (total !== chargedTotalGrossMinor) return 0;
|
|
112
|
+
const deliveryGrossMinor = chargedTotalGrossMinor - (subtotal - discount);
|
|
113
|
+
return deliveryGrossMinor > 0 ? deliveryGrossMinor : 0;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Delivery is an ancillary supply and follows the goods' VAT rate; with mixed
|
|
117
|
+
// item rates (or no item line to source a rate from) the correct rate is
|
|
118
|
+
// ambiguous, so the claim fails closed into the outbox last_error.
|
|
119
|
+
function buildDeliveryPosition(
|
|
120
|
+
deliveryGrossMinor: number,
|
|
121
|
+
itemLines: Array<{ vatRate: string }>,
|
|
122
|
+
options: Options,
|
|
123
|
+
): IssueLines[number] {
|
|
124
|
+
const rates = [...new Set(itemLines.map((line) => line.vatRate.trim()))];
|
|
125
|
+
if (rates.length === 0) {
|
|
126
|
+
throw new Error(`accounting_invoice_delivery_vat_rate_unavailable delivery=${deliveryGrossMinor}`);
|
|
127
|
+
}
|
|
128
|
+
if (rates.length > 1) {
|
|
129
|
+
throw new Error(`accounting_invoice_delivery_vat_rates_mixed rates=${rates.join(",")}`);
|
|
130
|
+
}
|
|
131
|
+
const vatRate = rates[0];
|
|
132
|
+
const totalNetMinor = estimateNetMinor(deliveryGrossMinor, vatRate);
|
|
133
|
+
return {
|
|
134
|
+
name: options.deliveryLabel,
|
|
135
|
+
quantity: 1,
|
|
136
|
+
unitGrossMinor: deliveryGrossMinor,
|
|
137
|
+
unitNetMinor: totalNetMinor,
|
|
138
|
+
totalGrossMinor: deliveryGrossMinor,
|
|
139
|
+
totalNetMinor,
|
|
140
|
+
vatRate,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function mapBuyerAddress(value: unknown): NonNullable<InvoiceBuyer["address"]> {
|
|
145
|
+
const address = isRecord(value) ? value : {};
|
|
146
|
+
return {
|
|
147
|
+
line1: textValue(address.line1),
|
|
148
|
+
postalCode: textValue(address.postalCode),
|
|
149
|
+
city: textValue(address.city),
|
|
150
|
+
country: textValue(address.country),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function parseLineSnapshot(value: unknown, options: Options): {
|
|
155
|
+
name: string;
|
|
156
|
+
quantity: number;
|
|
157
|
+
totalGrossMinor: number;
|
|
158
|
+
vatRate: string;
|
|
159
|
+
} {
|
|
160
|
+
const row = isRecord(value) ? value : {};
|
|
161
|
+
const quantity = numberValue(row.quantity) || 1;
|
|
162
|
+
const vatRate = textValue(row.vatRate) ?? options.defaultTaxRate;
|
|
163
|
+
// Legacy (pre-#1693) lines_snapshot rows carry only the per-unit
|
|
164
|
+
// unitGrossMinor, so the line total is unit price times quantity.
|
|
165
|
+
const unitGrossMinor = numberValue(row.unitGrossMinor);
|
|
166
|
+
const totalGrossMinor = numberValue(row.totalGrossMinor) ??
|
|
167
|
+
(unitGrossMinor === null ? 0 : unitGrossMinor * quantity);
|
|
168
|
+
return {
|
|
169
|
+
name: textValue(row.name) ?? options.defaultLineLabel,
|
|
170
|
+
quantity,
|
|
171
|
+
totalGrossMinor,
|
|
172
|
+
vatRate,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function estimateNetMinor(grossMinor: number, vatRate: string): number {
|
|
177
|
+
const rate = Number(vatRate.replace(",", "."));
|
|
178
|
+
return Number.isFinite(rate) ? Math.round(grossMinor / (1 + rate / 100)) : grossMinor;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function textValue(value: unknown): string | null {
|
|
182
|
+
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function numberValue(value: unknown): number | null {
|
|
186
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
191
|
+
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
192
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { SubscriptionCommand, SubscriptionCommandContext, SubscriptionCommandPort } from "./contracts/commands.js";
|
|
2
|
+
|
|
3
|
+
/** Neutral examples. The host authenticates and authorizes actor/client ownership first. */
|
|
4
|
+
export function createSubscriptionCommandExamples(commands: SubscriptionCommandPort) {
|
|
5
|
+
const apply = (context: SubscriptionCommandContext, command: SubscriptionCommand) => {
|
|
6
|
+
if (context.actor.actorId.trim() === "" || context.actor.clientId.trim() === "") throw new Error("subscription_command_actor_required");
|
|
7
|
+
return commands.apply(command);
|
|
8
|
+
};
|
|
9
|
+
return {
|
|
10
|
+
customerSkip(context: SubscriptionCommandContext) {
|
|
11
|
+
if (context.actor.kind !== "customer") throw new Error("subscription_command_customer_required");
|
|
12
|
+
return apply(context, { ...context, action: "skip", reason: null, confirmedChargeTiming: true });
|
|
13
|
+
},
|
|
14
|
+
operatorSlide(context: SubscriptionCommandContext, newNextCycleAt: string) {
|
|
15
|
+
if (context.actor.kind !== "operator") throw new Error("subscription_command_operator_required");
|
|
16
|
+
return apply(context, { ...context, action: "slide", newNextCycleAt, confirmedChargeTiming: true });
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
}
|
|
@@ -7,7 +7,9 @@ export type SubscriptionCommand = SubscriptionCommandContext & (
|
|
|
7
7
|
| { action: "resume" | "skip" | "cancel"; reason: string | null; confirmedChargeTiming: boolean }
|
|
8
8
|
| { action: "slide"; newNextCycleAt: string; confirmedChargeTiming: boolean }
|
|
9
9
|
| { action: "edit_future_terms"; shippingAddressId: string; planDays: number; cadenceDays: number;
|
|
10
|
-
expectedTemplateVersion: number;
|
|
10
|
+
expectedTemplateVersion: number;
|
|
11
|
+
/** Empty string means no quote (semantic no-op). */
|
|
12
|
+
acceptedQuoteHash: string; confirmedChargeTiming: boolean;
|
|
11
13
|
coreLines: readonly SubscriptionBundleLine[]; addonLines: readonly SubscriptionBundleLine[];
|
|
12
14
|
compositionConstraint: string }
|
|
13
15
|
);
|
|
@@ -17,6 +19,37 @@ export interface SubscriptionCommandReceipt {
|
|
|
17
19
|
outcome: "applied" | "replayed" | "noop"; subscriptionId: string;
|
|
18
20
|
originalState: string; nextCycleAt: string | null; revision: string; eventId: string | null;
|
|
19
21
|
}
|
|
22
|
+
export interface SubscriptionCommandRefusal {
|
|
23
|
+
outcome: "refused";
|
|
24
|
+
/** Stable machine code; never provider, PostgreSQL diagnostic or customer text.
|
|
25
|
+
* Unrecognized native or host diagnostics map to subscription_command_invalid_input. */
|
|
26
|
+
reason:
|
|
27
|
+
| "multi_cycle_prepaid_unsupported"
|
|
28
|
+
| "subscription_command_addon_not_found"
|
|
29
|
+
| "subscription_command_bundle_total_mismatch"
|
|
30
|
+
| "subscription_command_charge_timing_not_confirmed"
|
|
31
|
+
| "subscription_command_idempotency_conflict"
|
|
32
|
+
| "subscription_command_invalid_composition_constraint"
|
|
33
|
+
| "subscription_command_invalid_input"
|
|
34
|
+
| "subscription_command_not_active"
|
|
35
|
+
| "subscription_command_operation_busy"
|
|
36
|
+
| "subscription_command_pause_forbidden"
|
|
37
|
+
| "subscription_command_payment_blocked"
|
|
38
|
+
| "subscription_command_preview_unsupported"
|
|
39
|
+
| "subscription_command_renewal_terms_missing"
|
|
40
|
+
| "subscription_command_unsupported_action"
|
|
41
|
+
| "subscription_edit_catalog_unavailable"
|
|
42
|
+
| "subscription_edit_invalid_lines"
|
|
43
|
+
| "subscription_edit_invalid_request"
|
|
44
|
+
| "subscription_edit_quote_expired"
|
|
45
|
+
| "subscription_edit_quote_required"
|
|
46
|
+
| "subscription_edit_stale_template"
|
|
47
|
+
| "subscription_edit_window_closed"
|
|
48
|
+
| "subscription_lifecycle_invalid_transition"
|
|
49
|
+
| "subscription_lifecycle_invalid_skip"
|
|
50
|
+
| "subscription_lifecycle_invalid_slide"
|
|
51
|
+
| "subscription_lifecycle_payment_blocked";
|
|
52
|
+
}
|
|
20
53
|
export type SubscriptionUnsupportedCode = "multi_cycle_prepaid_unsupported" | "native_generic_bundle_commands_unsupported" | "native_command_bound_receipts_unsupported" | "native_prepared_attempt_store_unsupported";
|
|
21
54
|
export interface SubscriptionUnsupported { supported: false; refusalCode: SubscriptionUnsupportedCode }
|
|
22
55
|
/** Readiness must be selected explicitly; a port declaration does not install a store. */
|
|
@@ -35,7 +68,7 @@ export interface SubscriptionCapabilities {
|
|
|
35
68
|
export interface SubscriptionCommandPort {
|
|
36
69
|
readonly capabilities: SubscriptionCapabilities;
|
|
37
70
|
/** Future term writes, actor/command receipt and events must commit atomically. */
|
|
38
|
-
apply(command: SubscriptionCommand): Promise<SubscriptionCommandReceipt | SubscriptionUnsupported>;
|
|
71
|
+
apply(command: SubscriptionCommand): Promise<SubscriptionCommandReceipt | SubscriptionCommandRefusal | SubscriptionUnsupported>;
|
|
39
72
|
}
|
|
40
73
|
export interface SubscriptionActivationPort {
|
|
41
74
|
activate(input: { idempotencyKey: string; orderId: string; paymentIntentId: string;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export type { RenewalSchedule, RenewalIdentity, RenewalAdmission, RenewalLine, RenewalCycleCorrelation, RenewalTemplateSnapshot, RenewalPricingSnapshot, RenewalOrderSnapshot, RenewalFrozenSnapshot, RenewalDue, RenewalOperationState, RenewalReadback, RenewalPaymentEvidence, RenewalTransactionUnits, RenewalReservationResult, RenewalPreparedSettlement, RenewalStorePort, RenewalSettlementPort, RenewalExecutionRequest, RenewalPaymentRail, RenewalResult } from "./renewal.js";
|
|
2
2
|
export type { DunningCaseOpenInput, DunningCaseOpened, DunningCaseAdvanced, DunningRefusalCode, DunningCaseLifecyclePort, RenewalRetryState, RenewalRetryObligation, RenewalRecoveryAuthorization, RenewalFailureHandoffPort } from "./dunning.js";
|
|
3
|
-
export type { StockWaitRecoveryBinding, StockWaitDeficit, StockWaitCase, StockWaitStorePort } from "./stockWait.js";
|
|
4
|
-
export type { SubscriptionCommandContext, SubscriptionCommand, SubscriptionBundleLine, SubscriptionCommandReceipt, SubscriptionUnsupportedCode, SubscriptionUnsupported, SubscriptionCapabilities, SubscriptionCommandPort, SubscriptionActivationPort } from "./commands.js";
|
|
3
|
+
export type { StockWaitRecoveryBinding, StockWaitDeficit, StockWaitCase, StockWaitFrozenCase, StockWaitStorePort } from "./stockWait.js";
|
|
4
|
+
export type { SubscriptionCommandContext, SubscriptionCommand, SubscriptionBundleLine, SubscriptionCommandReceipt, SubscriptionCommandRefusal, SubscriptionUnsupportedCode, SubscriptionUnsupported, SubscriptionCapabilities, SubscriptionCommandPort, SubscriptionActivationPort } from "./commands.js";
|
|
5
5
|
export type { SubscriptionLifecycleFact, SubscriptionLifecycleMessageDescriptor, SubscriptionLifecycleMessagePort } from "./messages.js";
|
|
6
6
|
export type { SubscriptionRenewalOutcome, HostedRenewalDue, SubscriptionRenewalChargeResult, SubscriptionCycleSnapshotResult, SubscriptionCycleOrderInput, SubscriptionCycleOrderResult, SubscriptionReservationPreflightResult, SubscriptionRenewalPersistencePort, FailurePropagationKind, FailurePropagationContext, CycleRetryState, DunningCaseOpenOutcome, CycleChargeFailurePropagationPort, FailurePropagationResult, RenewalPrepareInput, RenewalAttemptResult, RenewalIntentResult, RenewalPaymentPort, RenewalExecutionPort, RenewalDeliveryAdmission, RenewalAllowedAdmission, RenewalDeliveryClient, RenewalFailureDeps, RenewalPreflightDeps, RenewalCycleDeps, RenewalExecutionContext, RenewalOutcomeContext, RenewalReplayContext, RenewalPreparedAttemptPrepareInput, RenewalPreparedAttemptPersistenceResult, RenewalPreparedAttemptFinalizeInput, RenewalPreparedAttemptPersistencePort } from "./renewal.js";
|
|
@@ -5,7 +5,13 @@ export interface StockWaitRecoveryBinding {
|
|
|
5
5
|
}
|
|
6
6
|
export interface StockWaitDeficit {
|
|
7
7
|
skuId: string; sku: string; displayName: string; size: string | null;
|
|
8
|
-
required: number;
|
|
8
|
+
required: number;
|
|
9
|
+
/** This operation's own reserved units. */
|
|
10
|
+
reserved: number;
|
|
11
|
+
/** Free ATP, excluding this operation's own reservation. */
|
|
12
|
+
available: number;
|
|
13
|
+
/** Required units minus both reserved and available units. */
|
|
14
|
+
missing: number;
|
|
9
15
|
}
|
|
10
16
|
export interface StockWaitCase extends RenewalSchedule {
|
|
11
17
|
caseId: string; clientId: string; state: "waiting" | "stock_secured" | "resumed" | "closed";
|
|
@@ -16,12 +22,18 @@ export interface StockWaitCase extends RenewalSchedule {
|
|
|
16
22
|
securedAt: string | null; resumedAt: string | null; closedAt: string | null;
|
|
17
23
|
startedNoticeKey: string; resumedNoticeKey: string; noticeEligible: boolean;
|
|
18
24
|
}
|
|
25
|
+
/** Discovery can precede the durable waiting case; never invent a case ID. */
|
|
26
|
+
export interface StockWaitFrozenCase extends RenewalSchedule {
|
|
27
|
+
operationId: string; case: StockWaitCase | null;
|
|
28
|
+
frozenSnapshot: RenewalFrozenSnapshot; frozenFingerprint: string;
|
|
29
|
+
binding: StockWaitRecoveryBinding;
|
|
30
|
+
}
|
|
19
31
|
/** All-line reserve/reacquire and the wait transition share a DB transaction.
|
|
20
32
|
* A shortage rolls back the subtransaction, retaining previous commitments;
|
|
21
33
|
* record the wait outside that failed subtransaction. Technical failure is unknown.
|
|
22
34
|
*/
|
|
23
35
|
export interface StockWaitStorePort {
|
|
24
|
-
listCandidates(input: { limit: number; asOf: string }): Promise<
|
|
36
|
+
listCandidates(input: { limit: number; asOf: string }): Promise<StockWaitFrozenCase[]>;
|
|
25
37
|
readFrozenCase(input: StockWaitRecoveryBinding): Promise<StockWaitCase | null>;
|
|
26
38
|
reserveAndTransition(input: { caseId: string; frozenFingerprint: string; now: string }): Promise<StockWaitCase>;
|
|
27
39
|
/** Only elapsed, unprepared, unacknowledged, stock-only refusals may reset.
|
|
@@ -2,10 +2,12 @@ export { renewalIdentity } from "./renewalIdentity.js";
|
|
|
2
2
|
export const NATIVE_SUBSCRIPTION_CAPABILITIES = Object.freeze({
|
|
3
3
|
graph: "native",
|
|
4
4
|
multiCyclePrepaid: Object.freeze({ supported: false, refusalCode: "multi_cycle_prepaid_unsupported" }),
|
|
5
|
-
genericBundleCommands: Object.freeze({ supported:
|
|
6
|
-
commandBoundReceipts: Object.freeze({ supported:
|
|
5
|
+
genericBundleCommands: Object.freeze({ supported: true }),
|
|
6
|
+
commandBoundReceipts: Object.freeze({ supported: true }),
|
|
7
7
|
preparedAttemptStore: Object.freeze({ supported: false, refusalCode: "native_prepared_attempt_store_unsupported" }),
|
|
8
|
-
retryRecovery: "selected",
|
|
8
|
+
retryRecovery: "selected",
|
|
9
|
+
// Native N3 adapters exist; selection requires passing real-PG evidence.
|
|
10
|
+
stockRecovery: "unavailable",
|
|
9
11
|
deliveryAdmission: "required", executorCount: 1, defaultEnabled: false,
|
|
10
12
|
} as const satisfies import("./contracts/commands.js").SubscriptionCapabilities);
|
|
11
13
|
export { executeRenewalCycle, prepareRenewalAttempt, checkRenewalDeliveryAdmission } from "./renewalCycleMechanism.js";
|
|
@@ -22,3 +24,28 @@ export { createPostgresRetryStore } from "./postgresRetryStore.js";
|
|
|
22
24
|
export type { NativeRetryFailure, NativeRetryRequest, NativeRetryPositive, NativeRetryPreparation, NativeRetryRow } from "./postgresRetryStore.js";
|
|
23
25
|
export { createPostgresRetryLifecycleBinding } from "./postgresRetryStore.js";
|
|
24
26
|
export type { NativeRetryLifecycleBinding } from "./postgresRetryStore.js";
|
|
27
|
+
|
|
28
|
+
export { createPostgresSubscriptionCommandStore, createPostgresSubscriptionCommands } from "./postgresCommandStore.js";
|
|
29
|
+
export type { NativeSubscriptionCommand, NativeSubscriptionCommandBinding } from "./postgresCommandStore.js";
|
|
30
|
+
|
|
31
|
+
export { createPostgresStockWaitStore } from "./postgresStockWaitStore.js";
|
|
32
|
+
export type { NativeStockWaitBinding, NativeStockWaitAdmission } from "./postgresStockWaitStore.js";
|
|
33
|
+
|
|
34
|
+
/** Managed SQL is exposed for service-role hosts; no StockWaitStorePort or
|
|
35
|
+
* NewPurchaseStockReadPort TypeScript adapter is composed for this graph.
|
|
36
|
+
* SQL behavior is proved by the managed pgTAP/real-PostgreSQL witnesses.
|
|
37
|
+
*/
|
|
38
|
+
export const MANAGED_SUBSCRIPTION_STOCK_CAPABILITIES = Object.freeze({
|
|
39
|
+
graph: "managed",
|
|
40
|
+
stockRecovery: "unavailable",
|
|
41
|
+
stockWaitSurface: "sql",
|
|
42
|
+
stockAvailabilitySurface: "sql",
|
|
43
|
+
defaultEnabled: false,
|
|
44
|
+
} as const);
|
|
45
|
+
|
|
46
|
+
export { runRenewalBatchRows } from "./automaticRenewalExecution.js";
|
|
47
|
+
export type { SubscriptionLifecycleFact } from "./contracts/messages.js";
|
|
48
|
+
|
|
49
|
+
export { subscriptionLifecycleTemplate, createSubscriptionLifecycleMessages } from "./lifecycleMessages.js";
|
|
50
|
+
export { createSubscriptionCommandExamples } from "./commandExamples.js";
|
|
51
|
+
export { createSubscriptionServices } from "./services.js";
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { SubscriptionLifecycleMessageDescriptor, SubscriptionLifecycleMessagePort } from "./contracts/messages.js";
|
|
2
|
+
|
|
3
|
+
/** Matches M5 and the existing M04 lifecycle templates. A mapping is not a send. */
|
|
4
|
+
const templates: Readonly<Record<string, string>> = Object.freeze({
|
|
5
|
+
"subscription.created": "subscription-welcome",
|
|
6
|
+
"subscription.activation_action_required": "subscription-activation-action-required",
|
|
7
|
+
"subscription.cancelled": "subscription-cancelled",
|
|
8
|
+
"subscription.paused": "subscription-paused",
|
|
9
|
+
"subscription.resumed": "subscription-resumed",
|
|
10
|
+
"subscription.pause_reminder_due": "subscription-pause-reminder",
|
|
11
|
+
"subscription.delivery_rescheduled": "subscription-delivery-rescheduled",
|
|
12
|
+
"subscription.cycle_skipped": "subscription-cycle-skipped",
|
|
13
|
+
"subscription.address_changed": "subscription-address-changed",
|
|
14
|
+
"subscription.package_changed": "subscription-package-changed",
|
|
15
|
+
"subscription.renewal_upcoming": "subscription-renewal-upcoming",
|
|
16
|
+
"subscription_stock_wait.started": "subscription-stock-wait-started",
|
|
17
|
+
"subscription_stock_wait.resumed": "subscription-stock-wait-resumed",
|
|
18
|
+
payment_expired: "subscription-payment-expired",
|
|
19
|
+
payment_recovered: "subscription-payment-recovered", winback_eligible: "subscription-winback",
|
|
20
|
+
stock_wait_started: "subscription-stock-wait-started", stock_wait_resumed: "subscription-stock-wait-resumed",
|
|
21
|
+
});
|
|
22
|
+
export function subscriptionLifecycleTemplate(eventType: string, retryAttempt?: number | null): string | null {
|
|
23
|
+
// Installed dunning SQL uses rung 3 for every value other than exactly 1 or 2.
|
|
24
|
+
if (eventType === "payment_failed") return `subscription-payment-failed-${retryAttempt === 1 ? 1 : retryAttempt === 2 ? 2 : 3}`;
|
|
25
|
+
return Object.hasOwn(templates, eventType) ? templates[eventType]! : null;
|
|
26
|
+
}
|
|
27
|
+
/** M04 owns delivery, consent, rendering and durable replay. M10 never synthesizes a pause. */
|
|
28
|
+
export function createSubscriptionLifecycleMessages(delivery: SubscriptionLifecycleMessagePort) {
|
|
29
|
+
return {
|
|
30
|
+
deliver(input: Omit<SubscriptionLifecycleMessageDescriptor, "templateSlug">) {
|
|
31
|
+
const templateSlug = subscriptionLifecycleTemplate(input.fact.kind, input.fact.kind === "payment_failed" ? input.fact.retryAttempt : null);
|
|
32
|
+
if (!templateSlug) throw new Error("subscription_lifecycle_message_unsupported");
|
|
33
|
+
if (input.messageKey !== input.fact.idempotencyKey) throw new Error("subscription_lifecycle_message_identity_mismatch");
|
|
34
|
+
return delivery.enqueue({ ...input, templateSlug });
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -7,7 +7,7 @@ export async function readAndApplyProviderOutcome(deps: AutomaticRenewalRunDeps,
|
|
|
7
7
|
const observed = await rail.readOutcome({ externalAttemptRef, settlementIntentId: prepared.settlementIntentId, orderId: prepared.orderId, subscriptionId: operation.subscriptionId, amountMinor: money.totalAmountMinor, currency: money.currency });
|
|
8
8
|
if (observed.amountMinor !== null && observed.amountMinor !== money.totalAmountMinor) throw new Error("subscription_renewal_provider_amount_mismatch");
|
|
9
9
|
if (observed.currency !== null && observed.currency.toUpperCase() !== money.currency.toUpperCase()) throw new Error("subscription_renewal_provider_currency_mismatch");
|
|
10
|
-
if (observed.status
|
|
10
|
+
if (observed.status !== "succeeded" && observed.status !== "failed") return automaticRenewalResult(operation, "awaiting_provider", `provider_${observed.status}`);
|
|
11
11
|
if (observed.operationId !== operation.operationId || observed.requestFingerprint !== operation.operationFingerprint
|
|
12
12
|
|| observed.externalAttemptRef !== externalAttemptRef || !observed.verifiedEvidenceRef || !observed.eventKey
|
|
13
13
|
|| !observed.firstRecordedAt || !observed.observedAt) throw new Error("subscription_renewal_verified_evidence_required");
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import type { SubscriptionCommand, SubscriptionCommandPort, SubscriptionCommandReceipt, SubscriptionCommandRefusal } from "./contracts/commands.js";
|
|
2
|
+
import { subscriptionRecord, type SubscriptionQueryExecutor } from "./postgresRenewalStore.js";
|
|
3
|
+
import { NATIVE_SUBSCRIPTION_CAPABILITIES } from "./index.js";
|
|
4
|
+
|
|
5
|
+
export interface NativeSubscriptionCommand {
|
|
6
|
+
actorId: string; clientId: string; idempotencyKey: string; subscriptionId: string;
|
|
7
|
+
action: string; payload: Record<string, unknown>; now: string;
|
|
8
|
+
}
|
|
9
|
+
export interface NativeSubscriptionCommandBinding {
|
|
10
|
+
/** The callback must use one connection and commit/rollback the complete unit. */
|
|
11
|
+
withTransaction<T>(work: (executor: SubscriptionQueryExecutor) => Promise<T>): Promise<T>;
|
|
12
|
+
/** Validate address ownership and refuse address mutation; resolve SKUs and
|
|
13
|
+
* composition policy using trusted host evidence. No current-cycle write. */
|
|
14
|
+
futureTerms(command: Extract<SubscriptionCommand, { action: "edit_future_terms" }>): Promise<
|
|
15
|
+
| { coreLines: readonly { variantId: string; qty: number }[];
|
|
16
|
+
addonLines: readonly { variantId: string; qty: number }[]; compositionConstraint: Record<string, unknown> }
|
|
17
|
+
| SubscriptionCommandRefusal>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const refusalCodes = new Set<SubscriptionCommandRefusal["reason"]>([
|
|
21
|
+
"multi_cycle_prepaid_unsupported",
|
|
22
|
+
"subscription_command_addon_not_found",
|
|
23
|
+
"subscription_command_bundle_total_mismatch",
|
|
24
|
+
"subscription_command_charge_timing_not_confirmed",
|
|
25
|
+
"subscription_command_idempotency_conflict",
|
|
26
|
+
"subscription_command_invalid_composition_constraint",
|
|
27
|
+
"subscription_command_invalid_input",
|
|
28
|
+
"subscription_command_not_active",
|
|
29
|
+
"subscription_command_operation_busy",
|
|
30
|
+
"subscription_command_pause_forbidden",
|
|
31
|
+
"subscription_command_payment_blocked",
|
|
32
|
+
"subscription_command_preview_unsupported",
|
|
33
|
+
"subscription_command_renewal_terms_missing",
|
|
34
|
+
"subscription_command_unsupported_action",
|
|
35
|
+
"subscription_edit_catalog_unavailable",
|
|
36
|
+
"subscription_edit_invalid_lines",
|
|
37
|
+
"subscription_edit_invalid_request",
|
|
38
|
+
"subscription_edit_quote_expired",
|
|
39
|
+
"subscription_edit_quote_required",
|
|
40
|
+
"subscription_edit_stale_template",
|
|
41
|
+
"subscription_edit_window_closed",
|
|
42
|
+
"subscription_lifecycle_invalid_transition",
|
|
43
|
+
"subscription_lifecycle_invalid_skip",
|
|
44
|
+
"subscription_lifecycle_invalid_slide",
|
|
45
|
+
"subscription_lifecycle_payment_blocked",
|
|
46
|
+
]);
|
|
47
|
+
function refusalReason(reason: unknown): SubscriptionCommandRefusal["reason"] {
|
|
48
|
+
return typeof reason === "string" && refusalCodes.has(reason as SubscriptionCommandRefusal["reason"])
|
|
49
|
+
? reason as SubscriptionCommandRefusal["reason"] : "subscription_command_invalid_input";
|
|
50
|
+
}
|
|
51
|
+
function commandResult(result: Record<string, unknown>): Record<string, unknown> {
|
|
52
|
+
return result.status === "refused" || result.outcome === "refused"
|
|
53
|
+
? { ...result, reason: refusalReason(result.reason) } : result;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** N2 owns normalization, locking, quote acceptance, receipts and all writes.
|
|
57
|
+
* Preview is a separate persisted transaction; apply never executes payment. */
|
|
58
|
+
export function createPostgresSubscriptionCommandStore(binding: Pick<NativeSubscriptionCommandBinding, "withTransaction">) {
|
|
59
|
+
return {
|
|
60
|
+
preview(input: NativeSubscriptionCommand) { return invokeCommand(binding, input, true); },
|
|
61
|
+
apply(input: NativeSubscriptionCommand) { return invokeCommand(binding, input, false); },
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function invokeCommand(binding: Pick<NativeSubscriptionCommandBinding, "withTransaction">,
|
|
66
|
+
input: NativeSubscriptionCommand, preview: boolean,
|
|
67
|
+
futureTerms?: () => ReturnType<NativeSubscriptionCommandBinding["futureTerms"]>): Promise<Record<string, unknown>> {
|
|
68
|
+
if (input.action === "multi_cycle_prepaid") return {
|
|
69
|
+
status: "refused", reason: NATIVE_SUBSCRIPTION_CAPABILITIES.multiCyclePrepaid.refusalCode,
|
|
70
|
+
};
|
|
71
|
+
return binding.withTransaction(async executor => {
|
|
72
|
+
const values = [input.actorId, input.clientId, input.idempotencyKey, input.subscriptionId, input.action, input.payload, input.now];
|
|
73
|
+
// Share N2's actor/key lock so a concurrent preview/apply cannot change
|
|
74
|
+
// the receipt between this read and native dispatch. Replay ends here,
|
|
75
|
+
// before any lifecycle function or other potentially writing call.
|
|
76
|
+
await executor.query("SELECT pg_advisory_xact_lock(hashtextextended($1::uuid::text||':'||$2::text,0))", [input.actorId, input.idempotencyKey]);
|
|
77
|
+
const stored = await executor.query(`WITH stored AS (
|
|
78
|
+
SELECT r.*, CASE WHEN $9::boolean THEN jsonb_set(r.command,'{payload}',
|
|
79
|
+
(r.command->'payload')-ARRAY['coreLines','addonLines','compositionConstraint']) ELSE r.command END AS requested_command
|
|
80
|
+
FROM public.subscription_command_receipts r WHERE r.actor_id=$1::uuid AND r.idempotency_key=$3::text
|
|
81
|
+
AND $7::timestamptz IS NOT NULL AND isfinite($7::timestamptz)
|
|
82
|
+
AND $3::text=btrim($3::text) AND char_length($3::text) BETWEEN 8 AND 180
|
|
83
|
+
AND $5::text IS NOT NULL AND jsonb_typeof($6::jsonb)='object'
|
|
84
|
+
AND (NOT $8::boolean OR NOT ($6::jsonb ? 'acceptedQuoteHash'))
|
|
85
|
+
AND EXISTS(SELECT 1 FROM public.subscriptions WHERE id=$4::uuid AND client_id=$2::uuid)
|
|
86
|
+
), receipt AS (
|
|
87
|
+
SELECT *, CASE WHEN $8::boolean OR status='previewed' THEN
|
|
88
|
+
jsonb_set(requested_command,'{payload}',(requested_command->'payload')-'acceptedQuoteHash')=
|
|
89
|
+
jsonb_build_object('clientId',$2::uuid,'subscriptionId',$4::uuid,'action',$5::text,'payload',$6::jsonb-'acceptedQuoteHash')
|
|
90
|
+
ELSE requested_command=jsonb_build_object('clientId',$2::uuid,'subscriptionId',$4::uuid,'action',$5::text,'payload',$6::jsonb)
|
|
91
|
+
END AND ($8::boolean OR quote_hash IS NOT DISTINCT FROM ($6::jsonb->>'acceptedQuoteHash')) AS matches
|
|
92
|
+
FROM stored
|
|
93
|
+
) SELECT command, CASE WHEN NOT matches THEN jsonb_build_object('status','refused','reason','subscription_command_idempotency_conflict')
|
|
94
|
+
WHEN $8::boolean OR status='refused' THEN result
|
|
95
|
+
WHEN status<>'previewed' THEN result||jsonb_build_object('status','replayed','originalStatus',status,'commandFingerprint',command_fingerprint)
|
|
96
|
+
END AS response FROM receipt`, [...values, preview, !!futureTerms]);
|
|
97
|
+
if (stored.rows[0]?.response) return commandResult(subscriptionRecord(stored.rows[0].response));
|
|
98
|
+
if (futureTerms) {
|
|
99
|
+
if (stored.rows[0]) {
|
|
100
|
+
// A matching preview already bound the host translation. Apply that
|
|
101
|
+
// exact quote even if the host's mutable policy has since changed.
|
|
102
|
+
values[5] = { ...subscriptionRecord(subscriptionRecord(stored.rows[0].command).payload),
|
|
103
|
+
acceptedQuoteHash: input.payload.acceptedQuoteHash };
|
|
104
|
+
} else {
|
|
105
|
+
const terms = await futureTerms();
|
|
106
|
+
if ("outcome" in terms) return commandResult({ ...terms });
|
|
107
|
+
values[5] = { ...input.payload, ...terms };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
let result: Record<string, unknown> | undefined;
|
|
111
|
+
if (!preview && !Object.hasOwn(input.payload, "acceptedQuoteHash")) {
|
|
112
|
+
// N2 owns every command's no-op decision. Keep only its terminal noop;
|
|
113
|
+
// other direct applies must retain their quote requirement without
|
|
114
|
+
// persisting a speculative quote or refusal from this preview probe.
|
|
115
|
+
await executor.query("SAVEPOINT subscription_command_noop");
|
|
116
|
+
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);
|
|
117
|
+
const candidate = commandResult(subscriptionRecord(rows[0]?.response));
|
|
118
|
+
if (candidate.status === "noop") result = candidate;
|
|
119
|
+
else await executor.query("ROLLBACK TO SAVEPOINT subscription_command_noop");
|
|
120
|
+
await executor.query("RELEASE SAVEPOINT subscription_command_noop");
|
|
121
|
+
}
|
|
122
|
+
if (!result) {
|
|
123
|
+
const name = preview ? "subscription_command_preview" : "subscription_command_apply";
|
|
124
|
+
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);
|
|
125
|
+
result = commandResult(subscriptionRecord(rows[0]?.response));
|
|
126
|
+
}
|
|
127
|
+
if (preview || result.status === "refused") return result;
|
|
128
|
+
const receipt = await executor.query(`SELECT command_fingerprint FROM public.subscription_command_receipts
|
|
129
|
+
WHERE actor_id=$1::uuid AND idempotency_key=$2::text`, [input.actorId, input.idempotencyKey]);
|
|
130
|
+
if (typeof receipt.rows[0]?.command_fingerprint !== "string") throw new Error("subscription_command_receipt_missing");
|
|
131
|
+
return { ...result, commandFingerprint: receipt.rows[0].command_fingerprint };
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Trusted customer/operator authorization precedes this adapter. The native
|
|
136
|
+
* result's subscriptionStatus is the state recorded by the original execution,
|
|
137
|
+
* including on replay; it is not a fresh read of mutable subscription state. */
|
|
138
|
+
export function createPostgresSubscriptionCommands(binding: NativeSubscriptionCommandBinding): SubscriptionCommandPort & {
|
|
139
|
+
preview(command: Extract<SubscriptionCommand, { action: "edit_future_terms" }>): Promise<Record<string, unknown> | SubscriptionCommandRefusal>;
|
|
140
|
+
} {
|
|
141
|
+
function encode(command: SubscriptionCommand): NativeSubscriptionCommand | SubscriptionCommandRefusal {
|
|
142
|
+
// This serving adapter never pauses an active subscription. Existing native
|
|
143
|
+
// callers retain their separate, unchanged lifecycle contract.
|
|
144
|
+
if (command.action === "pause") return { outcome: "refused", reason: "subscription_command_pause_forbidden" };
|
|
145
|
+
const { actor, subscriptionId, idempotencyKey, now, action, ...fields } = command;
|
|
146
|
+
let payload: Record<string, unknown> = { ...fields, actorKind: actor.kind };
|
|
147
|
+
let nativeAction: string = action === "skip" ? "skip_next_cycle" : action === "slide" ? "slide_next_cycle" : action;
|
|
148
|
+
if (command.action === "edit_future_terms") {
|
|
149
|
+
// Preserve the host carriers as well as their native translation in the
|
|
150
|
+
// actual-command fingerprint. The accepted hash is stripped for preview.
|
|
151
|
+
payload = { ...payload, requestedCoreLines: command.coreLines, requestedAddonLines: command.addonLines,
|
|
152
|
+
compositionConstraintRef: command.compositionConstraint };
|
|
153
|
+
delete payload.coreLines;
|
|
154
|
+
delete payload.addonLines;
|
|
155
|
+
delete payload.compositionConstraint;
|
|
156
|
+
// An empty typed hash means no quote; preserve native no-op replay/probing.
|
|
157
|
+
if (command.acceptedQuoteHash === "") delete payload.acceptedQuoteHash;
|
|
158
|
+
nativeAction = "update_package_template";
|
|
159
|
+
}
|
|
160
|
+
return { actorId: actor.actorId, clientId: actor.clientId, subscriptionId, idempotencyKey, now, action: nativeAction, payload };
|
|
161
|
+
}
|
|
162
|
+
return {
|
|
163
|
+
capabilities: NATIVE_SUBSCRIPTION_CAPABILITIES,
|
|
164
|
+
async preview(command) {
|
|
165
|
+
const input = encode(command);
|
|
166
|
+
if ("outcome" in input) return input;
|
|
167
|
+
delete input.payload.acceptedQuoteHash;
|
|
168
|
+
return invokeCommand(binding, input, true, () => binding.futureTerms(command));
|
|
169
|
+
},
|
|
170
|
+
async apply(command) {
|
|
171
|
+
const input = encode(command);
|
|
172
|
+
if ("outcome" in input) return input;
|
|
173
|
+
const result = await invokeCommand(binding, input, false,
|
|
174
|
+
command.action === "edit_future_terms" ? () => binding.futureTerms(command) : undefined);
|
|
175
|
+
if (result.status === "refused" || result.outcome === "refused") return { outcome: "refused", reason: refusalReason(result.reason) };
|
|
176
|
+
if (result.status !== "applied" && result.status !== "replayed" && result.status !== "noop") throw new Error("subscription_command_response_invalid");
|
|
177
|
+
return { actorId: command.actor.actorId, idempotencyKey: command.idempotencyKey,
|
|
178
|
+
commandFingerprint: String(result.commandFingerprint), outcome: result.status, subscriptionId: String(result.subscriptionId),
|
|
179
|
+
originalState: String(result.subscriptionStatus), nextCycleAt: result.nextCycleAt == null ? null : String(result.nextCycleAt),
|
|
180
|
+
revision: String(result.templateVersion), eventId: result.eventId == null ? null : String(result.eventId) } satisfies SubscriptionCommandReceipt;
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
}
|
|
@@ -56,6 +56,17 @@ export function createPostgresRetryStore(executor: SubscriptionQueryExecutor) {
|
|
|
56
56
|
async applyFailure(operationId: string, key: string, input: NativeRetryFailure) {
|
|
57
57
|
return row(await invoke("subscription_retry_apply_failure", [operationId, key, input]));
|
|
58
58
|
},
|
|
59
|
+
/** Read the immutable rung receipt against the current case. An interrupted
|
|
60
|
+
* apply still needs propagation; an already advanced case must not be rewritten. */
|
|
61
|
+
async failureProgress(operationId: string, key: string): Promise<{ replayed: boolean; caseAdvanced: boolean }> {
|
|
62
|
+
const result = await executor.query(`SELECT true AS replayed,
|
|
63
|
+
(c.retry_attempt >= (r.result->>'retry_attempt')::integer OR o.state='recovered') AS case_advanced
|
|
64
|
+
FROM public.subscription_retry_obligations o
|
|
65
|
+
JOIN public.subscription_retry_receipts r ON r.obligation_id=o.id AND r.kind='failure' AND r.receipt_key=$2::text
|
|
66
|
+
LEFT JOIN public.subscription_dunning_cases c ON c.id=o.case_id
|
|
67
|
+
WHERE o.renewal_operation_id=$1::uuid`, [operationId, key]);
|
|
68
|
+
return { replayed: result.rows.length === 1, caseAdvanced: result.rows[0]?.case_advanced === true };
|
|
69
|
+
},
|
|
59
70
|
async openCase(operationId: string, template: string, recoveryPath: string | null) {
|
|
60
71
|
return subscriptionRecord(await invoke("subscription_retry_open_case", [operationId, template, recoveryPath]));
|
|
61
72
|
},
|