@openlup/platform 0.23.0 → 0.24.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.
Files changed (112) hide show
  1. package/CHANGELOG.md +218 -59
  2. package/README.md +93 -11
  3. package/dist/fulfillment/contracts/dispatch.d.ts +4 -0
  4. package/dist/fulfillment/dispatchProjection.d.ts +1 -1
  5. package/dist/fulfillment/dispatchProjection.js +4 -2
  6. package/dist/fulfillment/omnipack/client.js +14 -6
  7. package/dist/fulfillment/omnipack/index.js +5 -2
  8. package/dist/fulfillment/providerDispatchWorker.d.ts +38 -0
  9. package/dist/fulfillment/providerDispatchWorker.js +19 -1
  10. package/dist/invoicing/accountingCanonicalProviderLines.d.ts +5 -0
  11. package/dist/invoicing/accountingCanonicalProviderLines.js +104 -0
  12. package/dist/invoicing/accountingLineAllocation.d.ts +2 -0
  13. package/dist/invoicing/accountingLineAllocation.js +69 -0
  14. package/dist/invoicing/adapterConformance.d.ts +42 -0
  15. package/dist/invoicing/adapterConformance.js +296 -0
  16. package/dist/invoicing/artifacts.d.ts +7 -0
  17. package/dist/invoicing/artifacts.js +20 -0
  18. package/dist/invoicing/bindings.d.ts +46 -0
  19. package/dist/invoicing/bindings.js +69 -0
  20. package/dist/invoicing/channelPolicy.d.ts +28 -0
  21. package/dist/invoicing/channelPolicy.js +27 -0
  22. package/dist/invoicing/continuity.d.ts +12 -0
  23. package/dist/invoicing/continuity.js +18 -0
  24. package/dist/invoicing/contracts/facts.d.ts +11 -1
  25. package/dist/invoicing/contracts/host.d.ts +3 -4
  26. package/dist/invoicing/contracts/index.d.ts +1 -1
  27. package/dist/invoicing/contracts/store.d.ts +15 -5
  28. package/dist/invoicing/contribution.d.ts +21 -0
  29. package/dist/invoicing/contribution.js +19 -0
  30. package/dist/invoicing/correction.d.ts +42 -0
  31. package/dist/invoicing/correction.js +44 -0
  32. package/dist/invoicing/delivery.d.ts +85 -0
  33. package/dist/invoicing/delivery.js +79 -0
  34. package/dist/invoicing/documentHistory.d.ts +50 -0
  35. package/dist/invoicing/documentHistory.js +213 -0
  36. package/dist/invoicing/documentSync.d.ts +24 -0
  37. package/dist/invoicing/documentSync.js +26 -0
  38. package/dist/invoicing/dutyCollector.d.ts +15 -0
  39. package/dist/invoicing/dutyCollector.js +35 -0
  40. package/dist/invoicing/fulfillmentTrigger.d.ts +56 -0
  41. package/dist/invoicing/fulfillmentTrigger.js +90 -0
  42. package/dist/invoicing/handoff.d.ts +37 -0
  43. package/dist/invoicing/handoff.js +62 -0
  44. package/dist/invoicing/index.d.ts +38 -0
  45. package/dist/invoicing/index.js +36 -0
  46. package/dist/invoicing/issue.d.ts +39 -0
  47. package/dist/invoicing/issue.js +55 -0
  48. package/dist/invoicing/migrations.d.ts +48 -0
  49. package/dist/invoicing/migrations.js +25 -0
  50. package/dist/invoicing/nativeUnsupported.d.ts +9 -0
  51. package/dist/invoicing/nativeUnsupported.js +22 -0
  52. package/dist/invoicing/paymentReadback.d.ts +20 -0
  53. package/dist/invoicing/paymentReadback.js +47 -0
  54. package/dist/invoicing/postgresInvoiceStore.d.ts +55 -0
  55. package/dist/invoicing/postgresInvoiceStore.js +344 -0
  56. package/dist/invoicing/readModel.d.ts +74 -0
  57. package/dist/invoicing/readModel.js +90 -0
  58. package/dist/invoicing/readback.d.ts +4 -0
  59. package/dist/invoicing/readback.js +30 -0
  60. package/dist/invoicing/reversal.d.ts +37 -0
  61. package/dist/invoicing/reversal.js +28 -0
  62. package/dist/invoicing/sellerRevision.d.ts +19 -0
  63. package/dist/invoicing/sellerRevision.js +14 -0
  64. package/dist/invoicing/settlementSync.d.ts +18 -0
  65. package/dist/invoicing/settlementSync.js +32 -0
  66. package/dist/invoicing/snapshot.d.ts +52 -0
  67. package/dist/invoicing/snapshot.js +141 -0
  68. package/package.json +3 -3
  69. package/release-gates.json +21 -7
  70. package/sql/20260809210000_accounting_document_rail.sql +889 -0
  71. package/sql/20260811150000_accounting_payout_rail.sql +333 -0
  72. package/sql/20260812130000_accounting_document_lifecycle_rail.sql +460 -0
  73. package/sql/20261022190000_invoicing_durable_duties.sql +494 -0
  74. package/sql/20261022191000_invoicing_document_effects.sql +249 -0
  75. package/sql/20261022192000_invoicing_payment_decoupling.sql +144 -0
  76. package/sql/20261022195000_invoicing_seller_revision.sql +65 -0
  77. package/src/fulfillment/contracts/dispatch.ts +4 -0
  78. package/src/fulfillment/dispatchProjection.ts +4 -2
  79. package/src/fulfillment/omnipack/client.ts +13 -6
  80. package/src/fulfillment/omnipack/index.ts +5 -2
  81. package/src/fulfillment/providerDispatchWorker.ts +14 -1
  82. package/src/invoicing/accountingCanonicalProviderLines.ts +134 -0
  83. package/src/invoicing/accountingLineAllocation.ts +69 -0
  84. package/src/invoicing/adapterConformance.ts +249 -0
  85. package/src/invoicing/artifacts.ts +19 -0
  86. package/src/invoicing/bindings.ts +80 -0
  87. package/src/invoicing/channelPolicy.ts +50 -0
  88. package/src/invoicing/continuity.ts +15 -0
  89. package/src/invoicing/contracts/facts.ts +12 -1
  90. package/src/invoicing/contracts/host.ts +3 -4
  91. package/src/invoicing/contracts/index.ts +1 -1
  92. package/src/invoicing/contracts/store.ts +11 -7
  93. package/src/invoicing/contribution.ts +28 -0
  94. package/src/invoicing/correction.ts +56 -0
  95. package/src/invoicing/delivery.ts +122 -0
  96. package/src/invoicing/documentHistory.ts +301 -0
  97. package/src/invoicing/documentSync.ts +51 -0
  98. package/src/invoicing/dutyCollector.ts +31 -0
  99. package/src/invoicing/fulfillmentTrigger.ts +144 -0
  100. package/src/invoicing/handoff.ts +71 -0
  101. package/src/invoicing/index.ts +38 -0
  102. package/src/invoicing/issue.ts +64 -0
  103. package/src/invoicing/migrations.ts +25 -0
  104. package/src/invoicing/nativeUnsupported.ts +24 -0
  105. package/src/invoicing/paymentReadback.ts +70 -0
  106. package/src/invoicing/postgresInvoiceStore.ts +334 -0
  107. package/src/invoicing/readModel.ts +80 -0
  108. package/src/invoicing/readback.ts +26 -0
  109. package/src/invoicing/reversal.ts +34 -0
  110. package/src/invoicing/sellerRevision.ts +20 -0
  111. package/src/invoicing/settlementSync.ts +37 -0
  112. package/src/invoicing/snapshot.ts +192 -0
@@ -0,0 +1,344 @@
1
+ import { claimInvoiceSellerRevision } from "./sellerRevision.js";
2
+ import { createNativeUnsupportedInvoicePorts } from "./nativeUnsupported.js";
3
+ const unsupported = { kind: "unsupported", code: "host_binding_required", retryable: false, effectMayHaveSucceeded: false };
4
+ /** Bind an autocommit executor. Never pass a transaction spanning host/provider I/O.
5
+ * Missing fiscal facts or delivery binding refuse before writes; no scheduler is installed.
6
+ */
7
+ export function createPostgresInvoiceStore(db, options) {
8
+ async function call(name, values) {
9
+ const result = await db.query(`SELECT public.${name}(${values.map((_, i) => `$${i + 1}`).join(",")}) AS response`, values);
10
+ if (result.rows[0]?.response === undefined)
11
+ throw new Error("invoicing_native_response_missing");
12
+ return result.rows[0].response;
13
+ }
14
+ const fence = (claim) => [claim.dutyId, claim.token, claim.generation];
15
+ return {
16
+ ...createNativeUnsupportedInvoicePorts().store,
17
+ async discoverDuties(limit, afterDutyId = null) {
18
+ const result = await db.query(`SELECT d.id AS "dutyId", d.order_id AS "orderId"
19
+ FROM public.invoicing_duties d
20
+ WHERE ($2::uuid IS NULL OR d.id>$2::uuid)
21
+ AND d.request_id IS NULL AND d.status IN ('owed','blocked')
22
+ AND d.reversal IS NULL AND d.available_at<=now()
23
+ AND NOT EXISTS (SELECT 1 FROM public.invoicing_fact_revisions f WHERE f.duty_id=d.id)
24
+ ORDER BY d.id LIMIT least(greatest(coalesce($1::integer,10),1),100)`, [limit, afterDutyId]);
25
+ return result.rows.map(row => ({ dutyId: row.dutyId, orderId: row.orderId }));
26
+ },
27
+ async captureDuty(v) {
28
+ try {
29
+ return await call("invoicing_request_with_seller_policy", [v.requestKey, v.sourceKind, v.sourceId, v.orderId, v.evidence, v.occurredAt]);
30
+ }
31
+ catch (error) {
32
+ if (isConflict(error))
33
+ return { kind: "conflict", code: "identity_mismatch" };
34
+ throw error;
35
+ }
36
+ },
37
+ async installFacts(v) {
38
+ if (!validFacts(v.facts))
39
+ return unsupported;
40
+ return call("invoicing_install_facts", [v.dutyId, v.facts, v.facts.factRevision]);
41
+ },
42
+ sweep: limit => call("invoicing_sweep_duties", [limit]),
43
+ async prepareFacts(v) {
44
+ const result = await call("invoicing_prepare_facts", [v.dutyId, v.token]);
45
+ const { kind, ...rest } = result;
46
+ return kind ? { ...rest, code: kind } : rest;
47
+ },
48
+ async finishFacts(v) {
49
+ const result = await call("invoicing_finish_facts", [v.dutyId, v.token, { ...v.result, kind: v.result.code }]);
50
+ return { ...result, dutyId: v.dutyId };
51
+ },
52
+ readDuties: orderId => call("invoicing_duty_readback", [orderId]),
53
+ async claim(v) {
54
+ if (!options?.seller)
55
+ return unsupported;
56
+ const selected = await claimInvoiceSellerRevision(() => options.seller.read({ orderId: v.orderId ?? null }));
57
+ if (selected.kind !== "found")
58
+ return unsupported;
59
+ // Revisions are immutable. Refuse legacy/incomplete host projections before
60
+ // the lease RPC mutates a duty; supported writers install through this store.
61
+ const candidates = await db.query(`SELECT f.facts FROM public.invoicing_duties d
62
+ JOIN public.invoicing_fact_revisions f ON f.duty_id=d.id
63
+ WHERE ($1::uuid IS NULL OR d.order_id=$1) AND (d.provider IS NULL OR d.provider=$2)
64
+ AND d.status IN ('ready','processing','unknown') AND d.available_at<=now()
65
+ AND (d.status<>'processing' OR d.lease_until<=now())`, [v.orderId ?? null, v.providerRef]);
66
+ if (candidates.rows.some(row => !validFacts(row.facts)))
67
+ return unsupported;
68
+ const rows = await call("invoicing_claim_with_seller", [v.limit, v.visibilitySeconds, v.providerRef, selected.seller, selected.revisionRef, v.orderId ?? null]);
69
+ return rows.map(row => {
70
+ const facts = row.facts;
71
+ if (!validFacts(facts))
72
+ throw new Error("invoicing_native_facts_invalid");
73
+ const correction = row.parent_duty_id !== null;
74
+ return {
75
+ dutyId: row.id, documentId: row.document_id, requestId: row.request_id,
76
+ token: row.claim_token, generation: row.generation, leaseUntil: row.lease_until,
77
+ identity: identity(row, facts), facts: { ...facts, seller: row.seller, sellerRevision: row.sellerRevision, sellerMode: row.sellerMode },
78
+ action: row.last_result.action,
79
+ correction: correction ? { before: facts.before, reason: facts.reason, after: { totalNetMinor: 0, totalTaxMinor: 0, totalGrossMinor: 0 } } : null,
80
+ };
81
+ });
82
+ },
83
+ preflight: v => call("invoicing_preflight_duty", [...fence(v.claim), {
84
+ kind: v.status === "unavailable" || v.status === "unknown" || v.status === "absent" || v.amountMinor === null || v.currency === null ? "unavailable" : "observed",
85
+ outcome: v.status === "succeeded" ? "captured" : v.status,
86
+ amountMinor: v.amountMinor, currency: v.currency, proof: v.status === "absent" ? v.evidence : v.proof,
87
+ }]),
88
+ async finish(v) {
89
+ if (!sameIdentity(v.claim.identity, v.result.identity))
90
+ return unsupported;
91
+ return call(v.claim.correction ? "invoicing_finish_correction" : "invoicing_finish_issue", [
92
+ ...fence(v.claim), v.result.providerDocumentRef, v.result.providerDocumentNumber,
93
+ { kind: v.result.kind === "issued" ? "issued" : "readback_found", externalId: v.result.providerDocumentRef,
94
+ providerCreateIdentity: v.claim.identity.providerCreateIdentity, parentExternalId: v.claim.identity.parentProviderDocumentRef, proof: v.result.proof },
95
+ ]);
96
+ },
97
+ async fail(v) {
98
+ if (v.kind === "absent" && (!sameIdentity(v.claim.identity, v.evidence.identity)
99
+ || !sameIdentity(v.claim.identity, v.evidence.evidence.scope)
100
+ || v.evidence.evidence.exact !== true || v.evidence.evidence.complete !== true))
101
+ return unsupported;
102
+ const evidence = v.kind === "absent"
103
+ ? { kind: "authoritative_absence", providerCreateIdentity: v.claim.identity.providerCreateIdentity, proof: v.evidence.evidence }
104
+ : { ...(v.kind === "retryable" ? { phase: "before_send" } : {}), proof: v.proof };
105
+ const result = await call("invoicing_fail_duty", [...fence(v.claim), v.kind, evidence, v.retrySeconds]);
106
+ return { kind: result.kind === "stale" || result.kind === "voided" ? result.kind : "recorded" };
107
+ },
108
+ async requestReversal(v) {
109
+ try {
110
+ const result = await call("invoicing_request_with_seller_policy", [v.requestKey, "correction", null, v.orderId, { ...v.proof, reason: v.reason }, null]);
111
+ return result.kind === "unsupported" ? { ...result, kind: "unsupported_recorded" } : { ...result, kind: result.kind };
112
+ }
113
+ catch (error) {
114
+ if (isConflict(error))
115
+ return { kind: "conflict", code: "identity_mismatch" };
116
+ throw error;
117
+ }
118
+ },
119
+ async requestLocalDocument(v) {
120
+ const result = await db.query(LOCAL_DOCUMENT_SQL, [v.requestKey, v.orderId, v.providerRef]);
121
+ const response = result.rows[0]?.response;
122
+ if (typeof response?.document?.id !== "string" || typeof response.replayed !== "boolean" || typeof response.document.status !== "string")
123
+ throw new Error("accounting_paid_order_document_response_invalid");
124
+ const document = (await db.query("SELECT document_ref FROM public.accounting_documents WHERE id=$1::uuid", [response.document.id])).rows[0];
125
+ if (typeof document?.document_ref !== "string")
126
+ throw new Error("accounting_paid_order_document_reference_missing");
127
+ return { kind: "requested", documentId: response.document.id, invoiceRef: document.document_ref, replayed: response.replayed, status: response.document.status };
128
+ },
129
+ async claimDeliveries(v) {
130
+ if (typeof options?.projectDelivery !== "function")
131
+ return unsupported;
132
+ const rows = await call("invoicing_claim_deliveries", [v.limit, v.visibilitySeconds, v.requireClearance, v.orderId ?? null]);
133
+ const claims = [];
134
+ for (const row of rows) {
135
+ let retryable = true;
136
+ try {
137
+ const projected = await options.projectDelivery({ deliveryRef: row.delivery_id, documentId: row.document_id,
138
+ channel: row.channel, recipientRef: row.recipient_reference, attemptCount: row.generation });
139
+ const doc = (await db.query("SELECT order_id, provider_reference FROM public.accounting_documents WHERE id=$1::uuid", [row.document_id])).rows[0];
140
+ const { identity, delivery } = projected;
141
+ if (!doc || !nonempty(doc.provider_reference) || identity.documentRef !== row.document_id
142
+ || identity.providerDocumentRef !== doc.provider_reference || delivery.documentRef !== row.document_id
143
+ || delivery.deliveryRef !== row.delivery_id || delivery.orderId !== doc.order_id
144
+ || delivery.providerDocumentRef !== doc.provider_reference || delivery.attemptCount !== row.generation
145
+ || delivery.orderRef !== identity.orderRef || delivery.invoiceRef !== identity.invoiceRef
146
+ || !nonempty(delivery.requestKey) || !nonempty(delivery.recipient)) {
147
+ retryable = false;
148
+ throw new Error("invoicing_delivery_projection_invalid");
149
+ }
150
+ claims.push({ ...projected, token: row.claim_token, generation: row.generation, providerDocumentRef: doc.provider_reference });
151
+ }
152
+ catch {
153
+ const settled = await call("invoicing_settle_delivery", [
154
+ row.delivery_id, row.claim_token, row.generation, "failed_before_send", null,
155
+ { phase: "before_send", retryable, retrySeconds: Math.min(3600, 300 * Math.max(1, row.generation)),
156
+ code: retryable ? "retryable" : "identity_mismatch" },
157
+ ]);
158
+ if (settled.kind !== "failed_before_send")
159
+ throw new Error("invoicing_delivery_fence");
160
+ }
161
+ }
162
+ return claims;
163
+ },
164
+ async settleDelivery(v) {
165
+ if (typeof options?.projectDelivery !== "function")
166
+ return unsupported;
167
+ const doc = (await db.query(`SELECT doc.provider_reference FROM public.accounting_document_deliveries d
168
+ JOIN public.accounting_documents doc ON doc.id=d.document_id WHERE d.id=$1::uuid`, [v.deliveryRef])).rows[0];
169
+ if (!doc || doc.provider_reference !== v.providerDocumentRef)
170
+ return unsupported;
171
+ const result = v.result;
172
+ const kind = result.kind === "provider_accepted" ? "sent" : result.kind;
173
+ const receipt = result.kind === "provider_accepted" ? result.receiptRef : null;
174
+ const evidence = result.kind === "failed_before_send"
175
+ ? { phase: "before_send", retryable: result.retryable, retrySeconds: v.retrySeconds, code: result.code }
176
+ : result.kind === "uncertain" ? { code: result.code, receiptHint: result.receiptRef } : { proof: result.proof };
177
+ const settled = await call("invoicing_settle_delivery", [v.deliveryRef, v.token, v.generation, kind, receipt, evidence]);
178
+ return { kind: settled.kind === "stale" ? "stale" : "recorded", replayed: settled.replayed ?? false };
179
+ },
180
+ };
181
+ }
182
+ function isConflict(error) { return typeof error === "object" && error !== null && "code" in error && error.code === "23505"; }
183
+ function sameIdentity(a, b) {
184
+ return Object.keys(a).every(key => a[key] === b[key]);
185
+ }
186
+ function identity(row, facts) {
187
+ const correction = row.parent_duty_id !== null;
188
+ return {
189
+ providerRef: row.provider, requestKey: row.original_key,
190
+ providerCreateIdentity: row.providerCreateIdentity, factRevision: facts.factRevision,
191
+ orderRef: facts.refs.orderRef, invoiceRef: facts.refs.invoiceRef,
192
+ operation: correction ? "full_reversal" : "issue", correctionRef: correction ? `invoicing:${row.id}` : null,
193
+ parentProviderDocumentRef: correction ? facts.parentExternalId : null,
194
+ };
195
+ }
196
+ function nonempty(value) { return typeof value === "string" && value.trim().length > 0; }
197
+ function validFacts(facts) {
198
+ if (!facts || !nonempty(facts.factRevision) || !nonempty(facts.policyVersion) || !nonempty(facts.documentKind)
199
+ || !facts.refs?.orderId || !facts.refs.orderRef || !facts.refs.invoiceRef || !facts.dates?.issueDate || !facts.dates.sellDate
200
+ || !facts.buyer?.name || !facts.payment?.intentRef || facts.payment.currency !== facts.currency || typeof facts.clearanceRequired !== "boolean"
201
+ || facts.currency?.length !== 3 || !Array.isArray(facts.lines) || !facts.lines.length)
202
+ return false;
203
+ const sums = [0, 0, 0];
204
+ for (const line of facts.lines) {
205
+ if (!line || !nonempty(line.description) || !Number.isSafeInteger(line.quantity) || line.quantity < 1
206
+ || !Number.isSafeInteger(line.taxRateBps) || line.taxRateBps < 0 || line.taxRateBps > 10000
207
+ || ![line.unitNetMinor, line.unitGrossMinor, line.totalNetMinor, line.totalTaxMinor, line.totalGrossMinor].every(Number.isSafeInteger)
208
+ || line.totalNetMinor < 0 || line.totalTaxMinor < 0 || line.totalNetMinor + line.totalTaxMinor !== line.totalGrossMinor)
209
+ return false;
210
+ sums[0] += line.totalNetMinor;
211
+ sums[1] += line.totalTaxMinor;
212
+ sums[2] += line.totalGrossMinor;
213
+ }
214
+ return [facts.totalNetMinor, facts.totalTaxMinor, facts.totalGrossMinor].every((n, i) => Number.isSafeInteger(n) && n === sums[i])
215
+ && facts.chargedTotalGrossMinor === facts.totalGrossMinor;
216
+ }
217
+ // Incumbent local-only constructor, byte-preserved SQL; zero tax is not a fiscal projection.
218
+ const LOCAL_DOCUMENT_SQL = `
219
+ WITH snapshot AS (
220
+ SELECT
221
+ order_row.total_amount_minor,
222
+ COALESCE(
223
+ jsonb_agg(
224
+ jsonb_build_object(
225
+ 'description', 'order position ' || item.line_ordinal::text,
226
+ 'quantity', item.quantity,
227
+ 'unitGrossMinor', item.unit_amount_minor,
228
+ 'totalGrossMinor', item.line_amount_minor,
229
+ 'taxRateBps', 0
230
+ ) ORDER BY item.line_ordinal
231
+ ) FILTER (WHERE item.id IS NOT NULL),
232
+ '[]'::jsonb
233
+ ) AS positions
234
+ FROM public.commerce_orders order_row
235
+ LEFT JOIN public.commerce_order_items item ON item.order_id = order_row.id
236
+ WHERE order_row.id = $2::uuid
237
+ GROUP BY order_row.id, order_row.total_amount_minor
238
+ )
239
+ SELECT public.accounting_document_request_from_paid_order(
240
+ $1, $2::uuid, snapshot.positions, snapshot.total_amount_minor,
241
+ jsonb_build_object('providerKind', $3::text)
242
+ ) AS response
243
+ FROM snapshot`;
244
+ class ClaimFenceError extends Error {
245
+ }
246
+ /** Native issue/readback binding; every store operation commits before host I/O.
247
+ * @consumer Explicit native host issue binding and installed-consumer conformance.
248
+ */
249
+ export async function runNativeInvoiceIssueJob(input) {
250
+ const batch = await input.store.claim({ limit: input.limit ?? 10, visibilitySeconds: input.visibilitySeconds ?? 300,
251
+ providerRef: input.providerRef, orderId: input.orderId });
252
+ if ("kind" in batch)
253
+ return batch;
254
+ let updated = 0, failures = 0;
255
+ for (const native of batch) {
256
+ const claim = { ...native, attemptCount: native.generation };
257
+ let remoteStarted = false;
258
+ const retrySeconds = Math.min(3600, 300 * Math.max(1, claim.attemptCount));
259
+ const check = (result) => {
260
+ if (result.kind === "stale")
261
+ throw new ClaimFenceError("invoicing_claim_fence");
262
+ if (result.kind === "unsupported")
263
+ throw new Error("invoicing_host_binding_required");
264
+ };
265
+ const fail = async (outcome) => {
266
+ check(await input.store.fail(outcome));
267
+ };
268
+ const preflight = async (evidence) => {
269
+ const common = { claim, amountMinor: evidence.kind === "observed" ? evidence.amountMinor : null,
270
+ currency: evidence.kind === "observed" ? evidence.currency : null };
271
+ const result = await input.store.preflight(evidence.kind === "absent"
272
+ ? { ...common, status: "absent", evidence: evidence.evidence }
273
+ : { ...common, status: evidence.kind === "observed" ? evidence.status : "unavailable",
274
+ proof: evidence.kind === "observed" ? evidence.proof : null });
275
+ check(result);
276
+ return result.kind;
277
+ };
278
+ try {
279
+ if (claim.action === "readback") {
280
+ remoteStarted = true;
281
+ const result = await input.provider.read(claim.identity);
282
+ if (result.kind === "found") {
283
+ check(await input.store.finish({ claim, result }));
284
+ updated++;
285
+ }
286
+ else {
287
+ await fail(result.kind === "absent" ? { claim, retrySeconds, kind: "absent", evidence: result }
288
+ : { claim, retrySeconds, kind: "unknown", proof: null });
289
+ failures++;
290
+ }
291
+ continue;
292
+ }
293
+ // Non-observed local evidence is unavailable, never a reason to stop the
294
+ // owed duty. A fresh authoritative host read still runs before dispatch.
295
+ const local = await preflight(await input.readLocalPayment(claim));
296
+ if (local === "blocked" || local === "mismatch") {
297
+ await fail({ claim, retrySeconds, kind: "blocked", proof: null });
298
+ failures++;
299
+ continue;
300
+ }
301
+ const fresh = await preflight(await input.money.read({ intentRef: claim.facts.payment.intentRef,
302
+ providerRef: claim.facts.payment.providerRef ?? input.providerRef,
303
+ providerPaymentRef: claim.facts.payment.providerPaymentRef,
304
+ expectedMoney: { amountMinor: claim.facts.totalGrossMinor, currency: claim.facts.currency } }));
305
+ if (fresh !== "ready") {
306
+ await fail(fresh === "unavailable" ? { claim, retrySeconds, kind: "retryable", phase: "before_send", proof: null }
307
+ : { claim, retrySeconds, kind: "blocked", proof: null });
308
+ failures++;
309
+ continue;
310
+ }
311
+ const correctionFacts = claim.correction && { ...claim.correction.before,
312
+ seller: claim.facts.seller, sellerRevision: claim.facts.sellerRevision, sellerMode: claim.facts.sellerMode };
313
+ remoteStarted = true;
314
+ const result = claim.correction
315
+ ? await input.provider.correct({ identity: { ...claim.identity, operation: "full_reversal",
316
+ correctionRef: claim.identity.correctionRef, parentProviderDocumentRef: claim.identity.parentProviderDocumentRef },
317
+ ...claim.correction,
318
+ before: correctionFacts,
319
+ recoveryLookupRequired: claim.attemptCount > 1 })
320
+ : await input.provider.issue({ identity: { ...claim.identity, operation: "issue", correctionRef: null, parentProviderDocumentRef: null },
321
+ facts: claim.facts, recoveryLookupRequired: claim.attemptCount > 1 });
322
+ if (result.kind === "issued") {
323
+ check(await input.store.finish({ claim, result }));
324
+ updated++;
325
+ }
326
+ else {
327
+ await fail(result.kind === "refused" && result.retryable
328
+ ? { claim, retrySeconds, kind: "retryable", phase: "before_send", proof: null }
329
+ : { claim, retrySeconds, kind: result.kind === "refused" ? "blocked" : result.kind === "unsupported" ? "unsupported" : "unknown", proof: null });
330
+ failures++;
331
+ }
332
+ }
333
+ catch (error) {
334
+ failures++;
335
+ if (error instanceof ClaimFenceError)
336
+ continue;
337
+ const recorded = await input.store.fail(remoteStarted ? { claim, retrySeconds, kind: "unknown", proof: null }
338
+ : { claim, retrySeconds, kind: "retryable", phase: "before_send", proof: null });
339
+ if (recorded.kind !== "stale")
340
+ check(recorded);
341
+ }
342
+ }
343
+ return { checked: batch.length, updated, failures, ok: failures === 0 };
344
+ }
@@ -0,0 +1,74 @@
1
+ import type { InvoiceHostPorts, InvoiceIdentity, InvoiceProviderPort, InvoiceStorePort } from "./contracts/index.js";
2
+ /** Read-only orchestration. Auth, owner resolution and queries stay in host
3
+ * callbacks. Safe native account projections preserve negative correction totals,
4
+ * currency and parent identity without exposing provider payloads or URLs.
5
+ * @consumer Customer download/history and authorized operator host facades.
6
+ */
7
+ export declare function createInvoiceReadModel(ports?: {
8
+ accountHistory?: InvoiceHostPorts["accountHistory"];
9
+ artifacts?: Pick<InvoiceHostPorts["artifacts"], "download">;
10
+ provider?: Pick<InvoiceProviderPort, "read">;
11
+ duties?: Pick<InvoiceStorePort, "readDuties">;
12
+ }): {
13
+ history(input: Parameters<InvoiceHostPorts["accountHistory"]["read"]>[0]): ReturnType<InvoiceHostPorts["accountHistory"]["read"]>;
14
+ download(input: Parameters<InvoiceHostPorts["accountHistory"]["resolveDownload"]>[0]): Promise<{
15
+ readonly kind: "unsupported";
16
+ readonly code: "host_binding_required";
17
+ readonly retryable: false;
18
+ readonly effectMayHaveSucceeded: false;
19
+ } | {
20
+ readonly kind: "unknown";
21
+ readonly code: "unknown";
22
+ } | {
23
+ kind: "denied";
24
+ content?: undefined;
25
+ contentType?: undefined;
26
+ fileName?: undefined;
27
+ parentDocumentRef?: undefined;
28
+ } | {
29
+ kind: "downloaded";
30
+ content: Uint8Array<ArrayBufferLike>;
31
+ contentType: string;
32
+ fileName: string;
33
+ parentDocumentRef: string | null;
34
+ }>;
35
+ readback(identity: InvoiceIdentity): Promise<import("./contracts/provider.js").InvoiceReadbackResult | {
36
+ readonly kind: "unsupported";
37
+ readonly code: "host_binding_required";
38
+ readonly retryable: false;
39
+ readonly effectMayHaveSucceeded: false;
40
+ }>;
41
+ /** Operator-only duty readback. The host must authorize the caller before
42
+ * invoking it, as operator() does; this method has no auth hook of its own. */
43
+ readDuties(orderId: string): Promise<import("./contracts/provider.js").InvoiceUnsupported | import("./contracts/store.js").InvoiceDutyReadback | {
44
+ readonly kind: "unknown";
45
+ readonly code: "unknown";
46
+ }>;
47
+ /** The authorize callback runs before the query. HTTP status mapping stays host-owned. */
48
+ operator<Summary>(input: {
49
+ authorize(): Promise<boolean>;
50
+ read(): Promise<Summary>;
51
+ }): Promise<{
52
+ kind: "denied";
53
+ value?: undefined;
54
+ } | {
55
+ kind: "found";
56
+ value: Awaited<Summary>;
57
+ }>;
58
+ /** Legacy ticket resolver verifies the real order owner before any PDF I/O. */
59
+ ownedPdf<Ticket, Pdf extends {
60
+ content: Uint8Array;
61
+ contentType: string;
62
+ }>(input: {
63
+ resolve(): Promise<Ticket | null>;
64
+ download(ticket: Ticket): Promise<Pdf>;
65
+ }): Promise<{
66
+ kind: "denied";
67
+ ticket?: undefined;
68
+ pdf?: undefined;
69
+ } | {
70
+ kind: "downloaded";
71
+ ticket: NonNullable<Awaited<Ticket>>;
72
+ pdf: Pdf;
73
+ }>;
74
+ };
@@ -0,0 +1,90 @@
1
+ import { assertInvoicePdf } from "./artifacts.js";
2
+ import { opaqueReference, readInvoiceReadback } from "./readback.js";
3
+ const unsupported = () => ({ kind: "unsupported", code: "host_binding_required", retryable: false, effectMayHaveSucceeded: false });
4
+ const unknown = () => ({ kind: "unknown", code: "unknown" });
5
+ /** Read-only orchestration. Auth, owner resolution and queries stay in host
6
+ * callbacks. Safe native account projections preserve negative correction totals,
7
+ * currency and parent identity without exposing provider payloads or URLs.
8
+ * @consumer Customer download/history and authorized operator host facades.
9
+ */
10
+ export function createInvoiceReadModel(ports = {}) {
11
+ return {
12
+ async history(input) {
13
+ if (!ports.accountHistory)
14
+ return unsupported();
15
+ try {
16
+ const result = await ports.accountHistory.read(input);
17
+ if (result.kind !== "found")
18
+ return result.kind === "unsupported" ? unsupported() : result.kind === "denied" ? { kind: "denied" } : unknown();
19
+ if (result.accountRef !== input.accountRef || result.orderId !== input.orderId)
20
+ return { kind: "denied" };
21
+ return {
22
+ kind: "found", accountRef: input.accountRef, orderId: input.orderId,
23
+ documents: result.documents.map(document => ({
24
+ documentRef: document.documentRef, invoiceRef: document.invoiceRef,
25
+ parentDocumentRef: document.parentDocumentRef, role: document.role,
26
+ status: document.status, issuedAt: document.issuedAt,
27
+ clearance: document.clearance ? { state: document.clearance.state, reference: document.clearance.reference } : null,
28
+ documentKind: document.documentKind, artifact: document.artifact,
29
+ providerDocumentNumber: document.providerDocumentNumber,
30
+ totalGrossMinor: document.totalGrossMinor, currency: document.currency,
31
+ emailState: document.emailState, isCurrent: document.isCurrent,
32
+ createdAt: document.createdAt, downloadAvailable: document.downloadAvailable,
33
+ })),
34
+ };
35
+ }
36
+ catch {
37
+ return unknown();
38
+ }
39
+ },
40
+ async download(input) {
41
+ if (!ports.accountHistory || !ports.artifacts)
42
+ return unsupported();
43
+ try {
44
+ const ticket = await ports.accountHistory.resolveDownload(input);
45
+ if (ticket.kind !== "authorized")
46
+ return ticket.kind === "unsupported" ? unsupported() : ticket.kind === "denied" ? { kind: "denied" } : unknown();
47
+ if (ticket.identity.documentRef !== input.documentRef || !opaqueReference(ticket.artifactRef))
48
+ return { kind: "denied" };
49
+ const pdf = await ports.artifacts.download({ identity: ticket.identity, artifactRef: ticket.artifactRef });
50
+ if (pdf.kind !== "downloaded")
51
+ return pdf.kind === "unsupported" ? unsupported() : unknown();
52
+ assertInvoicePdf(pdf);
53
+ return { kind: "downloaded", content: pdf.content, contentType: pdf.contentType, fileName: ticket.fileName, parentDocumentRef: ticket.parentDocumentRef };
54
+ }
55
+ catch {
56
+ return unknown();
57
+ }
58
+ },
59
+ async readback(identity) {
60
+ return ports.provider ? readInvoiceReadback(ports.provider, identity) : unsupported();
61
+ },
62
+ /** Operator-only duty readback. The host must authorize the caller before
63
+ * invoking it, as operator() does; this method has no auth hook of its own. */
64
+ async readDuties(orderId) {
65
+ if (!ports.duties)
66
+ return unsupported();
67
+ try {
68
+ return await ports.duties.readDuties(orderId);
69
+ }
70
+ catch {
71
+ return unknown();
72
+ }
73
+ },
74
+ /** The authorize callback runs before the query. HTTP status mapping stays host-owned. */
75
+ async operator(input) {
76
+ if (!await input.authorize())
77
+ return { kind: "denied" };
78
+ return { kind: "found", value: await input.read() };
79
+ },
80
+ /** Legacy ticket resolver verifies the real order owner before any PDF I/O. */
81
+ async ownedPdf(input) {
82
+ const ticket = await input.resolve();
83
+ if (!ticket)
84
+ return { kind: "denied" };
85
+ const pdf = await input.download(ticket);
86
+ assertInvoicePdf(pdf);
87
+ return { kind: "downloaded", ticket, pdf };
88
+ },
89
+ };
90
+ }
@@ -0,0 +1,4 @@
1
+ import type { InvoiceIdentity, InvoiceProviderPort, InvoiceReadbackResult } from "./contracts/provider.js";
2
+ /** Internal validation used by the read model. No nullable lookup shortcut. */
3
+ export declare function readInvoiceReadback(provider: Pick<InvoiceProviderPort, "read">, identity: InvoiceIdentity): Promise<InvoiceReadbackResult>;
4
+ export declare function opaqueReference(ref: string): boolean;
@@ -0,0 +1,30 @@
1
+ const identityKeys = ["providerRef", "requestKey", "providerCreateIdentity", "factRevision", "orderRef", "invoiceRef", "operation", "correctionRef", "parentProviderDocumentRef"];
2
+ /** Internal validation used by the read model. No nullable lookup shortcut. */
3
+ export async function readInvoiceReadback(provider, identity) {
4
+ const unknown = () => ({ kind: "unknown", identity, code: "unknown" });
5
+ try {
6
+ const result = await provider.read(identity);
7
+ if (result.kind === "unsupported")
8
+ return { kind: "unsupported", code: result.code, retryable: false, effectMayHaveSucceeded: false };
9
+ if (!sameIdentity(identity, result.identity))
10
+ return unknown();
11
+ if (result.kind === "found") {
12
+ if (!opaqueReference(result.providerDocumentRef))
13
+ return unknown();
14
+ return { kind: "found", identity, providerDocumentRef: result.providerDocumentRef, providerDocumentNumber: result.providerDocumentNumber, proof: { evidenceRef: result.proof.evidenceRef, fingerprint: result.proof.fingerprint, observedAt: result.proof.observedAt } };
15
+ }
16
+ if (result.kind === "absent" && result.evidence.exact === true && result.evidence.complete === true && sameIdentity(identity, result.evidence.scope)) {
17
+ return { kind: "absent", identity, evidence: { evidenceRef: result.evidence.evidenceRef, fingerprint: result.evidence.fingerprint, observedAt: result.evidence.observedAt, exact: true, complete: true, scope: identity } };
18
+ }
19
+ return unknown();
20
+ }
21
+ catch {
22
+ return unknown();
23
+ }
24
+ }
25
+ function sameIdentity(a, b) {
26
+ return Boolean(b) && identityKeys.every(key => a[key] === b[key]);
27
+ }
28
+ export function opaqueReference(ref) {
29
+ return typeof ref === "string" && ref.trim().length > 0 && !/^(?:https?:|data:|blob:|\/\/)/i.test(ref.trim());
30
+ }
@@ -0,0 +1,37 @@
1
+ /** Donor reversal outcome/order semantics over host event adapters and writer.
2
+ * @consumer api/_cron/accountingOrderReversalHandlers.ts
3
+ */
4
+ export declare function createInvoiceReversalHandlers<Row, Request, Result extends {
5
+ action: string;
6
+ invoice: {
7
+ status: string;
8
+ } | null;
9
+ }>(input: {
10
+ enabled: boolean;
11
+ events: readonly {
12
+ eventType: string;
13
+ adapt(row: Row): {
14
+ kind: "ready";
15
+ request: Request;
16
+ } | {
17
+ kind: "invalid_payload";
18
+ };
19
+ }[];
20
+ request(request: Request): Promise<Result>;
21
+ }): {
22
+ eventType: string;
23
+ timeoutMs: number;
24
+ handle(row: Row): Promise<{
25
+ kind: "discard";
26
+ reason: string;
27
+ } | {
28
+ kind: "processed";
29
+ detail: {
30
+ accountingReversalAction: string;
31
+ invoiceStatus: string;
32
+ };
33
+ } | {
34
+ kind: "retry";
35
+ reason: string;
36
+ }>;
37
+ }[];
@@ -0,0 +1,28 @@
1
+ /** Donor reversal outcome/order semantics over host event adapters and writer.
2
+ * @consumer api/_cron/accountingOrderReversalHandlers.ts
3
+ */
4
+ export function createInvoiceReversalHandlers(input) {
5
+ if (!input.enabled)
6
+ return [];
7
+ return input.events.map(event => ({
8
+ eventType: event.eventType,
9
+ timeoutMs: 10_000,
10
+ async handle(row) {
11
+ const compatibility = event.adapt(row);
12
+ if (compatibility.kind === "invalid_payload") {
13
+ return { kind: "discard", reason: "accounting_reversal_payload_invalid" };
14
+ }
15
+ try {
16
+ const result = await input.request(compatibility.request);
17
+ return {
18
+ kind: "processed",
19
+ detail: { accountingReversalAction: result.action, invoiceStatus: result.invoice?.status ?? "no_invoice" },
20
+ };
21
+ }
22
+ catch (error) {
23
+ const message = error instanceof Error ? error.message : String(error);
24
+ return { kind: "retry", reason: message.slice(0, 300) };
25
+ }
26
+ },
27
+ }));
28
+ }
@@ -0,0 +1,19 @@
1
+ import type { InvoiceSellerRevision } from "./contracts/facts.js";
2
+ import type { InvoiceUnsupported } from "./contracts/provider.js";
3
+ /** Read the host selection before leasing. Persistence and race serialization
4
+ * belong exclusively to the pinned SQL wrapper, never to an in-memory cache.
5
+ * Unknown/unsupported selection consumes no claim and must be retried by the host.
6
+ */
7
+ export declare function claimInvoiceSellerRevision<Seller extends object>(read: () => Promise<({
8
+ readonly kind: "found";
9
+ } & InvoiceSellerRevision<Seller>) | {
10
+ readonly kind: "unknown";
11
+ readonly code: "unknown";
12
+ } | InvoiceUnsupported>): Promise<InvoiceUnsupported | {
13
+ readonly kind: "unknown";
14
+ readonly code: "unknown";
15
+ } | {
16
+ readonly kind: "found";
17
+ readonly seller: Seller;
18
+ readonly revisionRef: string;
19
+ }>;
@@ -0,0 +1,14 @@
1
+ /** Read the host selection before leasing. Persistence and race serialization
2
+ * belong exclusively to the pinned SQL wrapper, never to an in-memory cache.
3
+ * Unknown/unsupported selection consumes no claim and must be retried by the host.
4
+ */
5
+ export async function claimInvoiceSellerRevision(read) {
6
+ const selected = await read();
7
+ if (selected.kind !== "found")
8
+ return selected;
9
+ if (!selected.seller || typeof selected.seller !== "object" || Array.isArray(selected.seller)
10
+ || typeof selected.revisionRef !== "string" || !selected.revisionRef.trim()) {
11
+ return { kind: "unsupported", code: "host_binding_required", retryable: false, effectMayHaveSucceeded: false };
12
+ }
13
+ return { kind: "found", seller: selected.seller, revisionRef: selected.revisionRef };
14
+ }