@openlup/platform 0.22.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 (241) hide show
  1. package/CHANGELOG.md +440 -158
  2. package/README.md +141 -589
  3. package/dist/fulfillment/adapterConformance.js +1 -1
  4. package/dist/fulfillment/bindings.js +1 -1
  5. package/dist/fulfillment/contracts/conformance.d.ts +3 -1
  6. package/dist/fulfillment/contracts/dispatch.d.ts +45 -3
  7. package/dist/fulfillment/contracts/index.d.ts +3 -3
  8. package/dist/fulfillment/contracts/observation.d.ts +21 -1
  9. package/dist/fulfillment/contracts/readback.d.ts +21 -0
  10. package/dist/fulfillment/contracts/store.d.ts +3 -0
  11. package/dist/fulfillment/contracts/sync.d.ts +58 -2
  12. package/dist/fulfillment/dispatchProjection.d.ts +20 -1
  13. package/dist/fulfillment/dispatchProjection.js +97 -6
  14. package/dist/fulfillment/index.d.ts +3 -2
  15. package/dist/fulfillment/index.js +2 -2
  16. package/dist/fulfillment/migrations.d.ts +5 -0
  17. package/dist/fulfillment/migrations.js +1 -0
  18. package/dist/fulfillment/observationProjection.d.ts +24 -3
  19. package/dist/fulfillment/observationProjection.js +65 -12
  20. package/dist/fulfillment/omnipack/client.d.ts +3 -1
  21. package/dist/fulfillment/omnipack/client.js +31 -13
  22. package/dist/fulfillment/omnipack/index.d.ts +4 -1
  23. package/dist/fulfillment/omnipack/index.js +107 -45
  24. package/dist/fulfillment/omnipack/mappers.d.ts +7 -1
  25. package/dist/fulfillment/omnipack/mappers.js +24 -2
  26. package/dist/fulfillment/omnipack/observation.d.ts +11 -4
  27. package/dist/fulfillment/omnipack/observation.js +44 -29
  28. package/dist/fulfillment/omnipack/reconciliation.d.ts +15 -0
  29. package/dist/fulfillment/omnipack/reconciliation.js +55 -0
  30. package/dist/fulfillment/postgresDispatchStore.js +1 -1
  31. package/dist/fulfillment/postgresShipmentSpine.d.ts +8 -1
  32. package/dist/fulfillment/postgresShipmentSpine.js +17 -3
  33. package/dist/fulfillment/postgresStockSyncPort.js +6 -3
  34. package/dist/fulfillment/providerDispatchAcceptance.d.ts +1 -0
  35. package/dist/fulfillment/providerDispatchAcceptance.js +8 -2
  36. package/dist/fulfillment/providerDispatchWorker.d.ts +51 -0
  37. package/dist/fulfillment/providerDispatchWorker.js +65 -20
  38. package/dist/fulfillment/providerReconciliationWorker.d.ts +27 -19
  39. package/dist/fulfillment/providerReconciliationWorker.js +75 -19
  40. package/dist/fulfillment/providerStockSyncWorker.d.ts +5 -1
  41. package/dist/fulfillment/providerStockSyncWorker.js +229 -120
  42. package/dist/fulfillment/providerTrackingReferences.d.ts +3 -2
  43. package/dist/fulfillment/verifiedIngress.d.ts +38 -11
  44. package/dist/fulfillment/verifiedIngress.js +36 -8
  45. package/dist/invoicing/accountingCanonicalProviderLines.d.ts +5 -0
  46. package/dist/invoicing/accountingCanonicalProviderLines.js +104 -0
  47. package/dist/invoicing/accountingLineAllocation.d.ts +2 -0
  48. package/dist/invoicing/accountingLineAllocation.js +69 -0
  49. package/dist/invoicing/adapterConformance.d.ts +42 -0
  50. package/dist/invoicing/adapterConformance.js +296 -0
  51. package/dist/invoicing/artifacts.d.ts +7 -0
  52. package/dist/invoicing/artifacts.js +20 -0
  53. package/dist/invoicing/bindings.d.ts +46 -0
  54. package/dist/invoicing/bindings.js +69 -0
  55. package/dist/invoicing/capabilities.d.ts +11 -0
  56. package/dist/invoicing/capabilities.js +10 -0
  57. package/dist/invoicing/channelPolicy.d.ts +28 -0
  58. package/dist/invoicing/channelPolicy.js +27 -0
  59. package/dist/invoicing/continuity.d.ts +12 -0
  60. package/dist/invoicing/continuity.js +18 -0
  61. package/dist/invoicing/contracts/capabilities.d.ts +40 -0
  62. package/dist/invoicing/contracts/capabilities.js +1 -0
  63. package/dist/invoicing/contracts/companyLookup.d.ts +49 -0
  64. package/dist/invoicing/contracts/companyLookup.js +1 -0
  65. package/dist/invoicing/contracts/delivery.d.ts +74 -0
  66. package/dist/invoicing/contracts/delivery.js +1 -0
  67. package/dist/invoicing/contracts/facts.d.ts +110 -0
  68. package/dist/invoicing/contracts/facts.js +1 -0
  69. package/dist/invoicing/contracts/host.d.ts +245 -0
  70. package/dist/invoicing/contracts/host.js +1 -0
  71. package/dist/invoicing/contracts/index.d.ts +16 -0
  72. package/dist/invoicing/contracts/index.js +1 -0
  73. package/dist/invoicing/contracts/provider.d.ts +126 -0
  74. package/dist/invoicing/contracts/provider.js +1 -0
  75. package/dist/invoicing/contracts/settlement.d.ts +66 -0
  76. package/dist/invoicing/contracts/settlement.js +1 -0
  77. package/dist/invoicing/contracts/store.d.ts +277 -0
  78. package/dist/invoicing/contracts/store.js +1 -0
  79. package/dist/invoicing/contribution.d.ts +21 -0
  80. package/dist/invoicing/contribution.js +19 -0
  81. package/dist/invoicing/correction.d.ts +42 -0
  82. package/dist/invoicing/correction.js +44 -0
  83. package/dist/invoicing/delivery.d.ts +85 -0
  84. package/dist/invoicing/delivery.js +79 -0
  85. package/dist/invoicing/documentHistory.d.ts +50 -0
  86. package/dist/invoicing/documentHistory.js +213 -0
  87. package/dist/invoicing/documentSync.d.ts +24 -0
  88. package/dist/invoicing/documentSync.js +26 -0
  89. package/dist/invoicing/dutyCollector.d.ts +15 -0
  90. package/dist/invoicing/dutyCollector.js +35 -0
  91. package/dist/invoicing/fulfillmentTrigger.d.ts +56 -0
  92. package/dist/invoicing/fulfillmentTrigger.js +90 -0
  93. package/dist/invoicing/handoff.d.ts +37 -0
  94. package/dist/invoicing/handoff.js +62 -0
  95. package/dist/invoicing/index.d.ts +39 -0
  96. package/dist/invoicing/index.js +37 -0
  97. package/dist/invoicing/issue.d.ts +39 -0
  98. package/dist/invoicing/issue.js +55 -0
  99. package/dist/invoicing/migrations.d.ts +48 -0
  100. package/dist/invoicing/migrations.js +25 -0
  101. package/dist/invoicing/nativeUnsupported.d.ts +9 -0
  102. package/dist/invoicing/nativeUnsupported.js +22 -0
  103. package/dist/invoicing/paymentReadback.d.ts +20 -0
  104. package/dist/invoicing/paymentReadback.js +47 -0
  105. package/dist/invoicing/postgresInvoiceStore.d.ts +55 -0
  106. package/dist/invoicing/postgresInvoiceStore.js +344 -0
  107. package/dist/invoicing/readModel.d.ts +74 -0
  108. package/dist/invoicing/readModel.js +90 -0
  109. package/dist/invoicing/readback.d.ts +4 -0
  110. package/dist/invoicing/readback.js +30 -0
  111. package/dist/invoicing/reversal.d.ts +37 -0
  112. package/dist/invoicing/reversal.js +28 -0
  113. package/dist/invoicing/sellerRevision.d.ts +19 -0
  114. package/dist/invoicing/sellerRevision.js +14 -0
  115. package/dist/invoicing/settlementSync.d.ts +18 -0
  116. package/dist/invoicing/settlementSync.js +32 -0
  117. package/dist/invoicing/snapshot.d.ts +52 -0
  118. package/dist/invoicing/snapshot.js +141 -0
  119. package/dist/subscriptions/automaticRenewal.d.ts +44 -0
  120. package/dist/subscriptions/automaticRenewal.js +81 -0
  121. package/dist/subscriptions/automaticRenewalExecution.d.ts +21 -0
  122. package/dist/subscriptions/automaticRenewalExecution.js +44 -0
  123. package/dist/subscriptions/contracts/commands.d.ts +115 -0
  124. package/dist/subscriptions/contracts/commands.js +1 -0
  125. package/dist/subscriptions/contracts/dunning.d.ts +133 -0
  126. package/dist/subscriptions/contracts/dunning.js +1 -0
  127. package/dist/subscriptions/contracts/index.d.ts +6 -0
  128. package/dist/subscriptions/contracts/index.js +1 -0
  129. package/dist/subscriptions/contracts/messages.d.ts +43 -0
  130. package/dist/subscriptions/contracts/messages.js +1 -0
  131. package/dist/subscriptions/contracts/renewal.d.ts +533 -0
  132. package/dist/subscriptions/contracts/renewal.js +1 -0
  133. package/dist/subscriptions/contracts/stockWait.d.ts +73 -0
  134. package/dist/subscriptions/contracts/stockWait.js +1 -0
  135. package/dist/subscriptions/index.d.ts +39 -0
  136. package/dist/subscriptions/index.js +17 -0
  137. package/dist/subscriptions/paymentOutcomes.d.ts +8 -0
  138. package/dist/subscriptions/paymentOutcomes.js +27 -0
  139. package/dist/subscriptions/postgresRenewalStore.d.ts +20 -0
  140. package/dist/subscriptions/postgresRenewalStore.js +61 -0
  141. package/dist/subscriptions/postgresRetryStore.d.ts +78 -0
  142. package/dist/subscriptions/postgresRetryStore.js +142 -0
  143. package/dist/subscriptions/renewalCycleFailureMechanism.d.ts +34 -0
  144. package/dist/subscriptions/renewalCycleFailureMechanism.js +79 -0
  145. package/dist/subscriptions/renewalCycleMechanism.d.ts +46 -0
  146. package/dist/subscriptions/renewalCycleMechanism.js +157 -0
  147. package/dist/subscriptions/renewalCycleRefusalMechanism.d.ts +41 -0
  148. package/dist/subscriptions/renewalCycleRefusalMechanism.js +160 -0
  149. package/dist/subscriptions/renewalIdentity.d.ts +3 -0
  150. package/dist/subscriptions/renewalIdentity.js +4 -0
  151. package/package.json +23 -3
  152. package/release-gates.json +74 -7
  153. package/sql/20260809210000_accounting_document_rail.sql +889 -0
  154. package/sql/20260811150000_accounting_payout_rail.sql +333 -0
  155. package/sql/20260812130000_accounting_document_lifecycle_rail.sql +460 -0
  156. package/sql/20261022160000_subscription_retry_lifecycle.sql +328 -0
  157. package/sql/20261022165000_fulfillment_stock_evidence_replay_reopen.sql +30 -0
  158. package/sql/20261022190000_invoicing_durable_duties.sql +494 -0
  159. package/sql/20261022191000_invoicing_document_effects.sql +249 -0
  160. package/sql/20261022192000_invoicing_payment_decoupling.sql +144 -0
  161. package/sql/20261022195000_invoicing_seller_revision.sql +65 -0
  162. package/src/fulfillment/adapterConformance.ts +1 -1
  163. package/src/fulfillment/bindings.ts +1 -1
  164. package/src/fulfillment/contracts/conformance.ts +3 -1
  165. package/src/fulfillment/contracts/dispatch.ts +24 -5
  166. package/src/fulfillment/contracts/index.ts +3 -3
  167. package/src/fulfillment/contracts/observation.ts +11 -3
  168. package/src/fulfillment/contracts/readback.ts +13 -0
  169. package/src/fulfillment/contracts/store.ts +1 -0
  170. package/src/fulfillment/contracts/sync.ts +13 -2
  171. package/src/fulfillment/dispatchProjection.ts +94 -7
  172. package/src/fulfillment/index.ts +3 -2
  173. package/src/fulfillment/migrations.ts +1 -0
  174. package/src/fulfillment/observationProjection.ts +64 -13
  175. package/src/fulfillment/omnipack/client.ts +32 -13
  176. package/src/fulfillment/omnipack/index.ts +99 -39
  177. package/src/fulfillment/omnipack/mappers.ts +23 -2
  178. package/src/fulfillment/omnipack/observation.ts +45 -27
  179. package/src/fulfillment/omnipack/reconciliation.ts +56 -0
  180. package/src/fulfillment/postgresDispatchStore.ts +1 -1
  181. package/src/fulfillment/postgresShipmentSpine.ts +21 -4
  182. package/src/fulfillment/postgresStockSyncPort.ts +6 -3
  183. package/src/fulfillment/providerDispatchAcceptance.ts +9 -3
  184. package/src/fulfillment/providerDispatchWorker.ts +61 -20
  185. package/src/fulfillment/providerReconciliationWorker.ts +81 -25
  186. package/src/fulfillment/providerStockSyncWorker.ts +222 -126
  187. package/src/fulfillment/providerTrackingReferences.ts +3 -2
  188. package/src/fulfillment/verifiedIngress.ts +44 -11
  189. package/src/invoicing/accountingCanonicalProviderLines.ts +134 -0
  190. package/src/invoicing/accountingLineAllocation.ts +69 -0
  191. package/src/invoicing/adapterConformance.ts +249 -0
  192. package/src/invoicing/artifacts.ts +19 -0
  193. package/src/invoicing/bindings.ts +80 -0
  194. package/src/invoicing/capabilities.ts +9 -0
  195. package/src/invoicing/channelPolicy.ts +50 -0
  196. package/src/invoicing/continuity.ts +15 -0
  197. package/src/invoicing/contracts/capabilities.ts +15 -0
  198. package/src/invoicing/contracts/companyLookup.ts +13 -0
  199. package/src/invoicing/contracts/delivery.ts +24 -0
  200. package/src/invoicing/contracts/facts.ts +71 -0
  201. package/src/invoicing/contracts/host.ts +84 -0
  202. package/src/invoicing/contracts/index.ts +16 -0
  203. package/src/invoicing/contracts/provider.ts +78 -0
  204. package/src/invoicing/contracts/settlement.ts +39 -0
  205. package/src/invoicing/contracts/store.ts +102 -0
  206. package/src/invoicing/contribution.ts +28 -0
  207. package/src/invoicing/correction.ts +56 -0
  208. package/src/invoicing/delivery.ts +122 -0
  209. package/src/invoicing/documentHistory.ts +301 -0
  210. package/src/invoicing/documentSync.ts +51 -0
  211. package/src/invoicing/dutyCollector.ts +31 -0
  212. package/src/invoicing/fulfillmentTrigger.ts +144 -0
  213. package/src/invoicing/handoff.ts +71 -0
  214. package/src/invoicing/index.ts +39 -0
  215. package/src/invoicing/issue.ts +64 -0
  216. package/src/invoicing/migrations.ts +25 -0
  217. package/src/invoicing/nativeUnsupported.ts +24 -0
  218. package/src/invoicing/paymentReadback.ts +70 -0
  219. package/src/invoicing/postgresInvoiceStore.ts +334 -0
  220. package/src/invoicing/readModel.ts +80 -0
  221. package/src/invoicing/readback.ts +26 -0
  222. package/src/invoicing/reversal.ts +34 -0
  223. package/src/invoicing/sellerRevision.ts +20 -0
  224. package/src/invoicing/settlementSync.ts +37 -0
  225. package/src/invoicing/snapshot.ts +192 -0
  226. package/src/subscriptions/automaticRenewal.ts +87 -0
  227. package/src/subscriptions/automaticRenewalExecution.ts +39 -0
  228. package/src/subscriptions/contracts/commands.ts +46 -0
  229. package/src/subscriptions/contracts/dunning.ts +48 -0
  230. package/src/subscriptions/contracts/index.ts +6 -0
  231. package/src/subscriptions/contracts/messages.ts +18 -0
  232. package/src/subscriptions/contracts/renewal.ts +325 -0
  233. package/src/subscriptions/contracts/stockWait.ts +34 -0
  234. package/src/subscriptions/index.ts +24 -0
  235. package/src/subscriptions/paymentOutcomes.ts +23 -0
  236. package/src/subscriptions/postgresRenewalStore.ts +76 -0
  237. package/src/subscriptions/postgresRetryStore.ts +174 -0
  238. package/src/subscriptions/renewalCycleFailureMechanism.ts +119 -0
  239. package/src/subscriptions/renewalCycleMechanism.ts +225 -0
  240. package/src/subscriptions/renewalCycleRefusalMechanism.ts +215 -0
  241. package/src/subscriptions/renewalIdentity.ts +5 -0
@@ -0,0 +1,71 @@
1
+ import type { ChannelInvoiceDecision } from "./channelPolicy.js";
2
+
3
+ // SQLSTATEs the issue-request RPCs raise for permanently non-retryable
4
+ // conditions. Retrying these can never succeed, so the event must be discarded
5
+ // (terminal) rather than retry-looped into a `failed` backlog that the staging
6
+ // outbox-preview gate treats as blocking (see outboxOrderPaidFulfillmentHandler,
7
+ // which likewise maps fatal -> discard).
8
+ // - 22023 (invalid_parameter_value): every validation RAISE in
9
+ // accounting_invoice_issue_request_from_handoff / _from_paid_order — invalid
10
+ // input, missing fulfillment order (deleted), missing order/client/address,
11
+ // payment-not-succeeded, package-not-shipped. The handed_over event commits
12
+ // atomically with handed_over_at, so a missing row means it was deleted, not
13
+ // a visibility race.
14
+ // - 23514 (check_violation): the #1811 provider-immutability guard
15
+ // (accounting_invoice_provider_mismatch). The provider is immutable, so a
16
+ // replay against a different provider can never be persisted.
17
+ const PERMANENT_ISSUE_SQLSTATES = new Set(["22023", "23514"]);
18
+
19
+ // The subset of permanent (22023) RAISE identifiers that mean the aggregate or
20
+ // its graph was legitimately deleted before this async event dispatched — an
21
+ // orphan from smoke/order cleanup or a real order deletion. Discarding these is
22
+ // the expected, correct outcome, so they are classified `benign` (logged at warn,
23
+ // not error). Every OTHER permanent condition — payment-not-succeeded,
24
+ // package-not-shipped, invalid-input, and the 23514 provider-immutability guard —
25
+ // is still discarded but kept at error level because it signals a genuine anomaly.
26
+ const BENIGN_ISSUE_RAISE_IDENTIFIERS = new Set([
27
+ "accounting_invoice_handoff_not_found",
28
+ "accounting_invoice_order_not_found",
29
+ "accounting_invoice_client_not_found",
30
+ "accounting_invoice_address_not_found",
31
+ ]);
32
+
33
+ /** Host retains event schema, key scopes, RPC error class and compatibility detail. */
34
+ export function createFulfillmentHandoffInvoiceHandler<Row extends { payload: unknown }, Payload>(input: {
35
+ descriptor: { eventType: string; timeoutMs: number };
36
+ parsePayload(value: unknown): { success: true; data: Payload } | { success: false };
37
+ decide(payload: Payload): Promise<ChannelInvoiceDecision>;
38
+ request(row: Row, payload: Payload): Promise<Record<string, unknown>>;
39
+ errorDetails(error: unknown): { message: string; causeCode?: string; causeMessage?: string };
40
+ }) {
41
+ return {
42
+ ...input.descriptor,
43
+ async handle(row: Row, signal: AbortSignal): Promise<
44
+ { kind: "processed"; detail: Record<string, unknown> } |
45
+ { kind: "discard"; reason: string; benign?: boolean } | { kind: "retry"; reason: string }
46
+ > {
47
+ const parsed = input.parsePayload(row.payload);
48
+ if (!parsed.success) return { kind: "discard", reason: "fulfillment_handoff_payload_invalid" };
49
+ if (signal.aborted) return { kind: "retry", reason: "fulfillment_handoff_handler_aborted" };
50
+ try {
51
+ const decision = await input.decide(parsed.data);
52
+ if (!decision.issue) return { kind: "processed", detail: decision.detail };
53
+ return { kind: "processed", detail: await input.request(row, parsed.data) };
54
+ } catch (error) {
55
+ const { message, causeCode, causeMessage } = input.errorDetails(error);
56
+ // Surface the RPC's own RAISE identifier so a discard reads explicitly
57
+ // (e.g. "…: accounting_invoice_handoff_not_found (22023)") instead of an
58
+ // opaque SQLSTATE — this is what lets an orphan discard be told apart
59
+ // from a real payment-not-succeeded failure in the logs.
60
+ const detail = causeMessage && causeMessage !== message ? `${message}: ${causeMessage}` : message;
61
+ const reason = (causeCode ? `${detail} (${causeCode})` : detail).slice(0, 300);
62
+ if (causeCode !== undefined && PERMANENT_ISSUE_SQLSTATES.has(causeCode)) {
63
+ const benign = causeMessage !== undefined
64
+ && BENIGN_ISSUE_RAISE_IDENTIFIERS.has(causeMessage);
65
+ return benign ? { kind: "discard", reason, benign: true } : { kind: "discard", reason };
66
+ }
67
+ return { kind: "retry", reason };
68
+ }
69
+ },
70
+ };
71
+ }
@@ -0,0 +1,39 @@
1
+ export { readInvoiceCapabilities } from "./capabilities.js";
2
+ export { createNativeUnsupportedInvoicePorts } from "./nativeUnsupported.js";
3
+ export { runInvoiceAdapterConformance } from "./adapterConformance.js";
4
+ export { runInvoiceIssueJob } from "./issue.js";
5
+ /** @consumer server/domains/accounting/accountingProviderSnapshot.ts */
6
+ export { mapInvoiceClaimToProviderSnapshot } from "./snapshot.js";
7
+ /** @consumer server/domains/accounting/accountingCanonicalProviderLines.ts */
8
+ export { mapCanonicalIssueLines } from "./accountingCanonicalProviderLines.js";
9
+ /** @consumer server/domains/accounting/accountingLineAllocation.ts */
10
+ export { allocateChargedGrossTotals } from "./accountingLineAllocation.js";
11
+ /** @consumer server/domains/accounting/accountingLineAllocation.ts */
12
+ export { allocateDiscountedGrossTotals } from "./accountingLineAllocation.js";
13
+ /** @consumer server/domains/accounting/accountingPaymentReadback.ts */
14
+ export { readInvoicePaymentEvidence } from "./paymentReadback.js";
15
+ /** @consumer server/domains/accounting/fulfillmentHandoffInvoiceHandler.ts */
16
+ export { createFulfillmentHandoffInvoiceHandler } from "./handoff.js";
17
+ /** @consumer server/domains/accounting/fulfillmentInvoiceTrigger.ts */
18
+ export { withAccountingInvoiceShadowTrigger } from "./fulfillmentTrigger.js";
19
+ /** @consumer server/domains/accounting/fulfillmentInvoiceTrigger.ts */
20
+ export { withAutoDispatchAccountingInvoiceTrigger } from "./fulfillmentTrigger.js";
21
+ export { runInvoiceCorrectionJob } from "./correction.js";
22
+ export { createInvoiceReversalHandlers } from "./reversal.js";
23
+ /** @consumer Reference native accounting adapter/runtime and installed-consumer conformance. */
24
+ export { createPostgresInvoiceStore, runNativeInvoiceIssueJob } from "./postgresInvoiceStore.js";
25
+ /** @consumer Native migration conformance and installed-consumer asset verification. */
26
+ export { INVOICING_MIGRATIONS } from "./migrations.js";
27
+ export { collectInvoiceDuties } from "./dutyCollector.js";
28
+ export { createIndependentInvoiceDutyBinding } from "./continuity.js";
29
+ export { runInvoiceDeliveryJob } from "./delivery.js";
30
+ export { assertInvoicePdf, invoicePdfFilename } from "./artifacts.js";
31
+ export { syncAcceptedInvoiceDocuments } from "./documentSync.js";
32
+ export { syncInvoicePayoutSettlements } from "./settlementSync.js";
33
+ export { resolveInvoiceDocumentHistory } from "./documentHistory.js";
34
+ export { createInvoiceReadModel } from "./readModel.js";
35
+ export { createInvoiceServices } from "./bindings.js";
36
+ export type { InvoiceServices } from "./bindings.js";
37
+ export { createInvoiceContribution } from "./contribution.js";
38
+ export { claimInvoiceSellerRevision } from "./sellerRevision.js";
39
+ export type { InvoiceSellerRevision, InvoiceClaimedFacts } from "./contracts/facts.js";
@@ -0,0 +1,64 @@
1
+ import type { InvoiceWriteResult } from "./contracts/provider.js";
2
+
3
+ /** Donor issue sequencing over explicit compatibility callbacks. No transaction
4
+ * spans a callback; hosts bind their existing claim/preflight/finalizer contracts.
5
+ * @consumer server/domains/accounting/accountingJobService.ts
6
+ */
7
+ export async function runInvoiceIssueJob<Claim extends { attemptCount: number }, Snapshot, Evidence, Result extends Pick<Extract<InvoiceWriteResult, { kind: "issued" }>, "providerDocumentRef" | "providerDocumentNumber">>(input: {
8
+ createEnabled: boolean;
9
+ providerEnabled: boolean;
10
+ claim(): Promise<readonly Claim[]>;
11
+ localPreflight(claim: Claim): Promise<boolean>;
12
+ project(claim: Claim): Snapshot;
13
+ blockMapper(claim: Claim, code: string, error: unknown): Promise<unknown>;
14
+ readPayment(claim: Claim): Promise<Evidence>;
15
+ freshPreflight(claim: Claim, evidence: Evidence): Promise<boolean>;
16
+ /** Resolve only accepted/recovered document receipts; unknown/absent is not an ACK. */
17
+ create(snapshot: Snapshot, options: { recoveryLookupRequired: boolean }): Promise<Result>;
18
+ finish(claim: Claim, result: Result): Promise<unknown>;
19
+ isClaimFenceError(error: unknown): boolean;
20
+ fail(claim: Claim, error: unknown, retrySeconds: number): Promise<unknown>;
21
+ }) {
22
+ if (!input.createEnabled) return skipped("create_disabled");
23
+ if (!input.providerEnabled) return skipped("provider_disabled");
24
+ const claims = await input.claim();
25
+ let updated = 0;
26
+ let failures = 0;
27
+ for (const claim of claims) {
28
+ try {
29
+ if (!await input.localPreflight(claim)) {
30
+ failures += 1;
31
+ continue;
32
+ }
33
+ let snapshot: Snapshot;
34
+ try {
35
+ snapshot = input.project(claim);
36
+ } catch (error) {
37
+ const canonicalCode = canonicalMapperErrorCode(error);
38
+ if (!canonicalCode) throw error;
39
+ await input.blockMapper(claim, canonicalCode, error);
40
+ failures += 1;
41
+ continue;
42
+ }
43
+ const providerEvidence = await input.readPayment(claim);
44
+ if (!await input.freshPreflight(claim, providerEvidence)) {
45
+ failures += 1;
46
+ continue;
47
+ }
48
+ const result = await input.create(snapshot, { recoveryLookupRequired: claim.attemptCount > 1 });
49
+ await input.finish(claim, result);
50
+ updated += 1;
51
+ } catch (error) {
52
+ failures += 1;
53
+ if (input.isClaimFenceError(error)) continue;
54
+ await input.fail(claim, error, Math.min(3600, 300 * Math.max(1, claim.attemptCount)));
55
+ }
56
+ }
57
+ return { ok: failures === 0, checked: claims.length, updated, skipped: false, failures };
58
+ }
59
+ function canonicalMapperErrorCode(error: unknown): string | null {
60
+ if (!(error instanceof Error)) return null;
61
+ const match = /\b(accounting_invoice_canonical_[a-z0-9_]+)/.exec(error.message);
62
+ return match?.[1] ?? null;
63
+ }
64
+ function skipped(reason: string) { return { ok: true, checked: 0, updated: 0, skipped: true, reason, failures: 0 }; }
@@ -0,0 +1,25 @@
1
+ /** Sparse native projections, not an installation chain. Install the full catalogue. */
2
+ export const INVOICING_MIGRATIONS = {
3
+ native: {
4
+ correctionStorage: "child_document",
5
+ fiscalScope: "explicit_host_facts",
6
+ units: [
7
+ { position: 7, catalogPath: "db/platform/migrations/20260809210000_accounting_document_rail.sql", sha256: "f5c6c4b3d5ccdd7c7137669edba7d45c63685edc57fe982e3e8fd559214ccc8e", url: new URL("../../sql/20260809210000_accounting_document_rail.sql", import.meta.url) },
8
+ { position: 13, catalogPath: "db/platform/migrations/20260811150000_accounting_payout_rail.sql", sha256: "b127631196362d59ceb94700426766659589601c69a5adfb1b5d6bbeadf5b0db", url: new URL("../../sql/20260811150000_accounting_payout_rail.sql", import.meta.url) },
9
+ { position: 15, catalogPath: "db/platform/migrations/20260812130000_accounting_document_lifecycle_rail.sql", sha256: "450ae24b8ba9438ea267cfbf52f9c78a790b1803f369c216577566952fa43b5f", url: new URL("../../sql/20260812130000_accounting_document_lifecycle_rail.sql", import.meta.url) },
10
+ { position: 77, catalogPath: "db/platform/migrations/20261022190000_invoicing_durable_duties.sql", sha256: "2d7bf98924b4ef29b3e8f902d768e45dd9c850242f4358a0193a0c00d0f3d081", url: new URL("../../sql/20261022190000_invoicing_durable_duties.sql", import.meta.url) },
11
+ { position: 78, catalogPath: "db/platform/migrations/20261022191000_invoicing_document_effects.sql", sha256: "0eac3453e2b82c43e4fff9e35bcd6daefdd0bb5f992937f7f411d690550428cf", url: new URL("../../sql/20261022191000_invoicing_document_effects.sql", import.meta.url) },
12
+ { position: 79, catalogPath: "db/platform/migrations/20261022192000_invoicing_payment_decoupling.sql", sha256: "f4af30ee4c9365ea51bd0e95e5cc7ae869cf15dce5867fdd640f679438b36058", url: new URL("../../sql/20261022192000_invoicing_payment_decoupling.sql", import.meta.url) },
13
+ { position: 80, catalogPath: "db/platform/migrations/20261022195000_invoicing_seller_revision.sql", sha256: "5a6348d9af296cfc6383894e3489f70af1f3bb5026c0cc329daa9f3f95802b22", url: new URL("../../sql/20261022195000_invoicing_seller_revision.sql", import.meta.url) },
14
+ ],
15
+ },
16
+ managed: {
17
+ correctionStorage: "base_row",
18
+ fiscalScope: "host_managed_projection",
19
+ units: [
20
+ "supabase/migrations/20261022193000_invoicing_correction_claims.sql",
21
+ "supabase/migrations/20261022193100_invoicing_correction_compatibility.sql",
22
+ "supabase/migrations/20261022195100_invoicing_seller_revision.sql",
23
+ ],
24
+ },
25
+ } as const;
@@ -0,0 +1,24 @@
1
+ import type { InvoiceHostPorts, InvoiceStorePort, InvoiceServicesOptions, InvoiceWriteResult } from "./contracts/index.js";
2
+
3
+ const refusal: Extract<InvoiceWriteResult, { kind: "unsupported" }> = Object.freeze({ kind: "unsupported", code: "host_binding_required", retryable: false, effectMayHaveSucceeded: false });
4
+ /** @consumer Host native binding / installed cold consumer. No executor, inputs,
5
+ * provider or storage is inspected: every missing native seam refuses before writes.
6
+ */
7
+ export function createNativeUnsupportedInvoicePorts(): { host: InvoiceHostPorts; store: InvoiceStorePort; provider: NonNullable<InvoiceServicesOptions["provider"]> } {
8
+ const unsupported = async () => refusal;
9
+ return {
10
+ host: {
11
+ facts: { read: unsupported }, seller: { read: unsupported }, money: { read: unsupported }, fiscal: { verify: unsupported },
12
+ artifacts: { read: unsupported, download: unsupported, persist: unsupported }, registry: { lookup: unsupported },
13
+ delivery: { send: unsupported }, clearance: { observe: unsupported }, settlement: { record: unsupported, resolve: unsupported },
14
+ policy: { decide: unsupported, receipts: unsupported }, accountHistory: { read: unsupported, resolveDownload: unsupported },
15
+ },
16
+ store: {
17
+ captureDuty: unsupported, installFacts: unsupported, sweep: unsupported, prepareFacts: unsupported, finishFacts: unsupported,
18
+ readDuties: unsupported, claim: unsupported, preflight: unsupported, finish: unsupported, fail: unsupported,
19
+ requestReversal: unsupported, requestLocalDocument: unsupported, listClearanceTargets: unsupported,
20
+ recordObservation: unsupported, claimDeliveries: unsupported, settleDelivery: unsupported,
21
+ },
22
+ provider: { issue: unsupported, correct: unsupported, read: unsupported, void: unsupported },
23
+ };
24
+ }
@@ -0,0 +1,70 @@
1
+ type PaymentEvidence = {
2
+ providerStatus: "succeeded" | "failed" | "pending" | "unknown" | "unavailable";
3
+ providerAmountCents: number | null;
4
+ providerCurrency: string | null;
5
+ providerEvidence: Record<string, unknown>;
6
+ };
7
+
8
+ export async function readInvoicePaymentEvidence(
9
+ payment: { provider: string; providerPaymentId: string | null },
10
+ provider: { readPayment(input: { providerPaymentId: string }): Promise<{
11
+ status: "succeeded" | "failed" | "pending" | "unknown"; providerStatus: string; amountMinor: number | null; currency: string | null;
12
+ }> } | undefined,
13
+ ): Promise<PaymentEvidence> {
14
+ const providerName = payment.provider;
15
+ if (!provider || !payment.providerPaymentId) {
16
+ return unavailable(
17
+ providerName,
18
+ payment.providerPaymentId,
19
+ provider ? "provider_payment_id_missing" : "provider_not_configured",
20
+ );
21
+ }
22
+
23
+ try {
24
+ const readback = await provider.readPayment({
25
+ providerPaymentId: payment.providerPaymentId,
26
+ });
27
+ return {
28
+ providerStatus: readback.status,
29
+ providerAmountCents: integerOrNull(readback.amountMinor),
30
+ providerCurrency: currencyOrNull(readback.currency),
31
+ providerEvidence: {
32
+ source: "provider_api",
33
+ provider: providerName,
34
+ providerPaymentId: payment.providerPaymentId,
35
+ providerStatus: readback.providerStatus.slice(0, 80),
36
+ amountAvailable: integerOrNull(readback.amountMinor) !== null,
37
+ currencyAvailable: currencyOrNull(readback.currency) !== null,
38
+ },
39
+ };
40
+ } catch {
41
+ return unavailable(providerName, payment.providerPaymentId, "provider_read_failed");
42
+ }
43
+ }
44
+
45
+ function unavailable(
46
+ provider: string,
47
+ providerPaymentId: string | null,
48
+ reason: string,
49
+ ): PaymentEvidence {
50
+ return {
51
+ providerStatus: "unavailable",
52
+ providerAmountCents: null,
53
+ providerCurrency: null,
54
+ providerEvidence: {
55
+ source: "provider_api",
56
+ provider,
57
+ providerPaymentId,
58
+ unavailableReason: reason,
59
+ },
60
+ };
61
+ }
62
+
63
+ function integerOrNull(value: number | null): number | null {
64
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null;
65
+ }
66
+
67
+ function currencyOrNull(value: string | null): string | null {
68
+ const normalized = value?.trim().toUpperCase() ?? "";
69
+ return /^[A-Z]{3}$/.test(normalized) ? normalized : null;
70
+ }
@@ -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
+ }