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