@openlup/platform 0.23.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/CHANGELOG.md +218 -59
  2. package/README.md +93 -11
  3. package/dist/fulfillment/contracts/dispatch.d.ts +4 -0
  4. package/dist/fulfillment/dispatchProjection.d.ts +1 -1
  5. package/dist/fulfillment/dispatchProjection.js +4 -2
  6. package/dist/fulfillment/omnipack/client.js +14 -6
  7. package/dist/fulfillment/omnipack/index.js +5 -2
  8. package/dist/fulfillment/providerDispatchWorker.d.ts +38 -0
  9. package/dist/fulfillment/providerDispatchWorker.js +19 -1
  10. package/dist/invoicing/accountingCanonicalProviderLines.d.ts +5 -0
  11. package/dist/invoicing/accountingCanonicalProviderLines.js +104 -0
  12. package/dist/invoicing/accountingLineAllocation.d.ts +2 -0
  13. package/dist/invoicing/accountingLineAllocation.js +69 -0
  14. package/dist/invoicing/adapterConformance.d.ts +42 -0
  15. package/dist/invoicing/adapterConformance.js +296 -0
  16. package/dist/invoicing/artifacts.d.ts +7 -0
  17. package/dist/invoicing/artifacts.js +20 -0
  18. package/dist/invoicing/bindings.d.ts +46 -0
  19. package/dist/invoicing/bindings.js +69 -0
  20. package/dist/invoicing/channelPolicy.d.ts +28 -0
  21. package/dist/invoicing/channelPolicy.js +27 -0
  22. package/dist/invoicing/continuity.d.ts +12 -0
  23. package/dist/invoicing/continuity.js +18 -0
  24. package/dist/invoicing/contracts/facts.d.ts +11 -1
  25. package/dist/invoicing/contracts/host.d.ts +3 -4
  26. package/dist/invoicing/contracts/index.d.ts +1 -1
  27. package/dist/invoicing/contracts/store.d.ts +15 -5
  28. package/dist/invoicing/contribution.d.ts +21 -0
  29. package/dist/invoicing/contribution.js +19 -0
  30. package/dist/invoicing/correction.d.ts +42 -0
  31. package/dist/invoicing/correction.js +44 -0
  32. package/dist/invoicing/delivery.d.ts +85 -0
  33. package/dist/invoicing/delivery.js +79 -0
  34. package/dist/invoicing/documentHistory.d.ts +50 -0
  35. package/dist/invoicing/documentHistory.js +213 -0
  36. package/dist/invoicing/documentSync.d.ts +24 -0
  37. package/dist/invoicing/documentSync.js +26 -0
  38. package/dist/invoicing/dutyCollector.d.ts +15 -0
  39. package/dist/invoicing/dutyCollector.js +35 -0
  40. package/dist/invoicing/fulfillmentTrigger.d.ts +56 -0
  41. package/dist/invoicing/fulfillmentTrigger.js +90 -0
  42. package/dist/invoicing/handoff.d.ts +37 -0
  43. package/dist/invoicing/handoff.js +62 -0
  44. package/dist/invoicing/index.d.ts +38 -0
  45. package/dist/invoicing/index.js +36 -0
  46. package/dist/invoicing/issue.d.ts +39 -0
  47. package/dist/invoicing/issue.js +55 -0
  48. package/dist/invoicing/migrations.d.ts +48 -0
  49. package/dist/invoicing/migrations.js +25 -0
  50. package/dist/invoicing/nativeUnsupported.d.ts +9 -0
  51. package/dist/invoicing/nativeUnsupported.js +22 -0
  52. package/dist/invoicing/paymentReadback.d.ts +20 -0
  53. package/dist/invoicing/paymentReadback.js +47 -0
  54. package/dist/invoicing/postgresInvoiceStore.d.ts +55 -0
  55. package/dist/invoicing/postgresInvoiceStore.js +344 -0
  56. package/dist/invoicing/readModel.d.ts +74 -0
  57. package/dist/invoicing/readModel.js +90 -0
  58. package/dist/invoicing/readback.d.ts +4 -0
  59. package/dist/invoicing/readback.js +30 -0
  60. package/dist/invoicing/reversal.d.ts +37 -0
  61. package/dist/invoicing/reversal.js +28 -0
  62. package/dist/invoicing/sellerRevision.d.ts +19 -0
  63. package/dist/invoicing/sellerRevision.js +14 -0
  64. package/dist/invoicing/settlementSync.d.ts +18 -0
  65. package/dist/invoicing/settlementSync.js +32 -0
  66. package/dist/invoicing/snapshot.d.ts +52 -0
  67. package/dist/invoicing/snapshot.js +141 -0
  68. package/package.json +3 -3
  69. package/release-gates.json +21 -7
  70. package/sql/20260809210000_accounting_document_rail.sql +889 -0
  71. package/sql/20260811150000_accounting_payout_rail.sql +333 -0
  72. package/sql/20260812130000_accounting_document_lifecycle_rail.sql +460 -0
  73. package/sql/20261022190000_invoicing_durable_duties.sql +494 -0
  74. package/sql/20261022191000_invoicing_document_effects.sql +249 -0
  75. package/sql/20261022192000_invoicing_payment_decoupling.sql +144 -0
  76. package/sql/20261022195000_invoicing_seller_revision.sql +65 -0
  77. package/src/fulfillment/contracts/dispatch.ts +4 -0
  78. package/src/fulfillment/dispatchProjection.ts +4 -2
  79. package/src/fulfillment/omnipack/client.ts +13 -6
  80. package/src/fulfillment/omnipack/index.ts +5 -2
  81. package/src/fulfillment/providerDispatchWorker.ts +14 -1
  82. package/src/invoicing/accountingCanonicalProviderLines.ts +134 -0
  83. package/src/invoicing/accountingLineAllocation.ts +69 -0
  84. package/src/invoicing/adapterConformance.ts +249 -0
  85. package/src/invoicing/artifacts.ts +19 -0
  86. package/src/invoicing/bindings.ts +80 -0
  87. package/src/invoicing/channelPolicy.ts +50 -0
  88. package/src/invoicing/continuity.ts +15 -0
  89. package/src/invoicing/contracts/facts.ts +12 -1
  90. package/src/invoicing/contracts/host.ts +3 -4
  91. package/src/invoicing/contracts/index.ts +1 -1
  92. package/src/invoicing/contracts/store.ts +11 -7
  93. package/src/invoicing/contribution.ts +28 -0
  94. package/src/invoicing/correction.ts +56 -0
  95. package/src/invoicing/delivery.ts +122 -0
  96. package/src/invoicing/documentHistory.ts +301 -0
  97. package/src/invoicing/documentSync.ts +51 -0
  98. package/src/invoicing/dutyCollector.ts +31 -0
  99. package/src/invoicing/fulfillmentTrigger.ts +144 -0
  100. package/src/invoicing/handoff.ts +71 -0
  101. package/src/invoicing/index.ts +38 -0
  102. package/src/invoicing/issue.ts +64 -0
  103. package/src/invoicing/migrations.ts +25 -0
  104. package/src/invoicing/nativeUnsupported.ts +24 -0
  105. package/src/invoicing/paymentReadback.ts +70 -0
  106. package/src/invoicing/postgresInvoiceStore.ts +334 -0
  107. package/src/invoicing/readModel.ts +80 -0
  108. package/src/invoicing/readback.ts +26 -0
  109. package/src/invoicing/reversal.ts +34 -0
  110. package/src/invoicing/sellerRevision.ts +20 -0
  111. package/src/invoicing/settlementSync.ts +37 -0
  112. package/src/invoicing/snapshot.ts +192 -0
@@ -0,0 +1,51 @@
1
+ /** Direct donor extraction: host supplies attachment kinds, durable provider
2
+ * references, status/event keys, hashing and recording. Downloads confer no blob
3
+ * storage proof. The optional observation mode retains polling without adding
4
+ * another public runner/export or provider-specific status vocabulary. */
5
+ export async function syncAcceptedInvoiceDocuments<Target, Status, Document>(input: {
6
+ target: Target;
7
+ status: Status;
8
+ downloads: readonly ((target: Target) => Promise<Document>)[];
9
+ record(target: Target, status: Status, documents: readonly Document[]): Promise<unknown>;
10
+ }): Promise<void>;
11
+ export async function syncAcceptedInvoiceDocuments<Target, Status, Document>(input: {
12
+ targets(): Promise<readonly Target[]>;
13
+ observe(target: Target): Promise<Status>;
14
+ recordStatus(target: Target, status: Status): Promise<unknown>;
15
+ accepted(status: Status): boolean;
16
+ downloads: readonly ((target: Target) => Promise<Document>)[];
17
+ record(target: Target, status: Status, documents: readonly Document[]): Promise<unknown>;
18
+ }): Promise<{ ok: boolean; checked: number; updated: number; skipped: false; failures: number }>;
19
+ export async function syncAcceptedInvoiceDocuments<Target, Status, Document>(input: {
20
+ target?: Target;
21
+ status?: Status;
22
+ targets?: () => Promise<readonly Target[]>;
23
+ observe?: (target: Target) => Promise<Status>;
24
+ recordStatus?: (target: Target, status: Status) => Promise<unknown>;
25
+ accepted?: (status: Status) => boolean;
26
+ downloads: readonly ((target: Target) => Promise<Document>)[];
27
+ record(target: Target, status: Status, documents: readonly Document[]): Promise<unknown>;
28
+ }): Promise<void | { ok: boolean; checked: number; updated: number; skipped: false; failures: number }> {
29
+ const sync = async (target: Target, status: Status) => {
30
+ const documents = await Promise.all(input.downloads.map(download => download(target)));
31
+ await input.record(target, status, documents);
32
+ };
33
+ if (!input.targets) {
34
+ await sync(input.target as Target, input.status as Status);
35
+ return;
36
+ }
37
+ const targets = await input.targets();
38
+ let updated = 0;
39
+ let failures = 0;
40
+ for (const target of targets) {
41
+ try {
42
+ const status = await input.observe!(target);
43
+ await input.recordStatus!(target, status);
44
+ if (input.accepted!(status)) await sync(target, status);
45
+ updated += 1;
46
+ } catch {
47
+ failures += 1;
48
+ }
49
+ }
50
+ return { ok: failures === 0, checked: targets.length, updated, skipped: false as const, failures };
51
+ }
@@ -0,0 +1,31 @@
1
+ import type { InvoiceStorePort, InvoiceUnsupported } from "./contracts/index.js";
2
+
3
+ /** Collect retained sources, then prepare each leased duty in separate transactions.
4
+ * The host commits every store call; no money/parcel callback or provider I/O is
5
+ * accepted here. Missing facts stay owed until the host installs their revision.
6
+ * @consumer Independent invoice duty binding.
7
+ */
8
+ export async function collectInvoiceDuties(input: {
9
+ store: Pick<InvoiceStorePort, "sweep" | "prepareFacts" | "finishFacts">;
10
+ limit?: number;
11
+ }): Promise<{ kind: "collected"; collected: number; prepared: number; failures: number } | InvoiceUnsupported> {
12
+ const batch = await input.store.sweep(input.limit ?? 10);
13
+ if ("kind" in batch) return batch;
14
+ let prepared = 0, failures = 0;
15
+ for (const claim of batch.claims) {
16
+ try {
17
+ const result = await input.store.prepareFacts(claim);
18
+ if ("kind" in result) { failures++; continue; }
19
+ if (result.status === "stale") continue;
20
+ const finished = await input.store.finishFacts({ ...claim, result: { ...result, status: result.status } });
21
+ if ("kind" in finished) failures++;
22
+ else if (finished.status === "ready" || finished.status === "issued" || finished.status === "voided") prepared++;
23
+ else if (finished.status !== "stale") failures++;
24
+ } catch {
25
+ // A failed prepare/finalize leaves the persisted lease reclaimable. Never
26
+ // manufacture a successful finish or prevent another claim's progress.
27
+ failures++;
28
+ }
29
+ }
30
+ return { kind: "collected", collected: batch.collected, prepared, failures };
31
+ }
@@ -0,0 +1,144 @@
1
+ interface AccountingInvoiceIssuePort {
2
+ requestInvoiceIssueFromPaidOrder(input: { idempotencyKey: string; orderId: string; providerKind: string }): Promise<unknown>;
3
+ requestInvoiceIssueFromFulfillmentHandoff(input: { idempotencyKey: string; fulfillmentOrderId: string; providerKind: string }): Promise<unknown>;
4
+ }
5
+ import {
6
+ decideChannelInvoice,
7
+ type OrderInvoicePolicyReader,
8
+ } from "./channelPolicy.js";
9
+
10
+ // Structural mirror of the commerce OrderPaidFulfillmentPort, declared locally
11
+ // so the accounting domain does not import the commerce domain (the
12
+ // architecture guardrail forbids importing another domain's non-public
13
+ // internals). The composition root in api/cron passes the real commerce port,
14
+ // which is structurally compatible.
15
+ export type AutoDispatchFulfillmentResult =
16
+ | { kind: "completed" | "skipped"; detail?: Record<string, unknown> }
17
+ | { kind: "retryable" | "snooze" | "fatal"; reason: string }
18
+ // A paid order routed to manual review (split shipment unsupported): no
19
+ // handoff, so no accounting invoice is requested (readHandedOverFulfillmentOrderId
20
+ // returns null). Mirrors OrderPaidFulfillmentResult so the commerce port stays
21
+ // structurally assignable here.
22
+ | { kind: "manual_review"; reason: string; detail?: Record<string, unknown> };
23
+
24
+ export interface AutoDispatchFulfillmentPort {
25
+ ensureFulfilledFromPaidOrder(input: {
26
+ orderUuid: string;
27
+ outboxEventId: string;
28
+ signal: AbortSignal;
29
+ }): Promise<AutoDispatchFulfillmentResult>;
30
+ }
31
+
32
+ type FulfillmentHandoffRequest = {
33
+ idempotencyKey: string;
34
+ fulfillmentOrderId: string;
35
+ actorUserId: string;
36
+ };
37
+
38
+ type FulfillmentHandoffPort<TResult = unknown> = {
39
+ handOffCommerceFulfillmentOrder: (request: FulfillmentHandoffRequest) => Promise<TResult>;
40
+ };
41
+
42
+ export interface AccountingHandoffTriggerOptions {
43
+ accountingPort: AccountingInvoiceIssuePort;
44
+ providerKind: string;
45
+ keys: { shadow(key: string): string; paid(orderId: string): string; handoff(orderId: string): string };
46
+ failOnAccountingError?: boolean;
47
+ issueTrigger?: "handoff" | "paid";
48
+ // Wave B6. Absent everywhere this wave did not wire it, and an absent reader
49
+ // decides `issue` — so every composition that predates it keeps today's
50
+ // behaviour byte for byte.
51
+ invoicePolicyReader?: OrderInvoicePolicyReader;
52
+ }
53
+
54
+ export function withAccountingInvoiceShadowTrigger<TResult, TPort extends FulfillmentHandoffPort<TResult>>(
55
+ fulfillmentPort: TPort,
56
+ options: Omit<AccountingHandoffTriggerOptions, "accountingPort"> & {
57
+ accountingPort: Pick<AccountingInvoiceIssuePort, "requestInvoiceIssueFromFulfillmentHandoff"> & Partial<AccountingInvoiceIssuePort>;
58
+ },
59
+ ): TPort {
60
+ return {
61
+ ...fulfillmentPort,
62
+ async handOffCommerceFulfillmentOrder(request: FulfillmentHandoffRequest) {
63
+ const result = await fulfillmentPort.handOffCommerceFulfillmentOrder(request);
64
+ try {
65
+ // The operator hand-off reaches this wrapper holding only the
66
+ // fulfillment order id, so the policy is read by that key.
67
+ const decision = await decideChannelInvoice(options.invoicePolicyReader, {
68
+ by: "fulfillmentOrder",
69
+ fulfillmentOrderId: request.fulfillmentOrderId,
70
+ });
71
+ if (decision.issue) {
72
+ await options.accountingPort.requestInvoiceIssueFromFulfillmentHandoff({
73
+ idempotencyKey: options.keys.shadow(request.idempotencyKey),
74
+ fulfillmentOrderId: request.fulfillmentOrderId,
75
+ providerKind: options.providerKind,
76
+ });
77
+ }
78
+ } catch (error) {
79
+ if (options.failOnAccountingError) throw error;
80
+ }
81
+ return result;
82
+ },
83
+ } as TPort;
84
+ }
85
+
86
+ // Auto-dispatch sibling of withAccountingInvoiceShadowTrigger. The outbox
87
+ // commerce.order.paid -> fulfillment path drives handoff through the
88
+ // OrderPaidFulfillmentPort (ensureFulfilledFromPaidOrder), which issues the
89
+ // mark-handed-over RPC directly and therefore never reaches the admin
90
+ // hand-off wrapper above. Without this, an auto-dispatched paid order reaches
91
+ // handed_over but no accounting invoice is ever requested. Same semantics:
92
+ // idempotent (the issue RPC dedups on the order-derived invoice_ref, so any
93
+ // key is duplicate-safe) and fail-soft (accounting errors never fail the
94
+ // fulfillment dispatch — the outbox event still settles as processed).
95
+ export function withAutoDispatchAccountingInvoiceTrigger(
96
+ fulfillmentPort: AutoDispatchFulfillmentPort,
97
+ options: AccountingHandoffTriggerOptions,
98
+ ): AutoDispatchFulfillmentPort {
99
+ return {
100
+ async ensureFulfilledFromPaidOrder(input) {
101
+ // Both branches hold the order uuid, so both ask by the order key — the
102
+ // strongest key available, and the one that needs no join.
103
+ const lookup = { by: "order" as const, orderUuid: input.orderUuid };
104
+ if (options.issueTrigger === "paid") {
105
+ try {
106
+ const decision = await decideChannelInvoice(options.invoicePolicyReader, lookup);
107
+ if (decision.issue) {
108
+ await options.accountingPort.requestInvoiceIssueFromPaidOrder({
109
+ idempotencyKey: options.keys.paid(input.orderUuid),
110
+ orderId: input.orderUuid,
111
+ providerKind: options.providerKind,
112
+ });
113
+ }
114
+ } catch (error) {
115
+ if (options.failOnAccountingError) throw error;
116
+ }
117
+ }
118
+ const result = await fulfillmentPort.ensureFulfilledFromPaidOrder(input);
119
+ if (options.issueTrigger === "paid") return result;
120
+ const fulfillmentOrderId = readHandedOverFulfillmentOrderId(result);
121
+ if (fulfillmentOrderId) {
122
+ try {
123
+ const decision = await decideChannelInvoice(options.invoicePolicyReader, lookup);
124
+ if (decision.issue) {
125
+ await options.accountingPort.requestInvoiceIssueFromFulfillmentHandoff({
126
+ idempotencyKey: options.keys.handoff(input.orderUuid),
127
+ fulfillmentOrderId,
128
+ providerKind: options.providerKind,
129
+ });
130
+ }
131
+ } catch (error) {
132
+ if (options.failOnAccountingError) throw error;
133
+ }
134
+ }
135
+ return result;
136
+ },
137
+ };
138
+ }
139
+
140
+ function readHandedOverFulfillmentOrderId(result: AutoDispatchFulfillmentResult): string | null {
141
+ if (result.kind !== "completed" && result.kind !== "skipped") return null;
142
+ const id = result.detail?.fulfillmentOrderId;
143
+ return typeof id === "string" ? id : null;
144
+ }
@@ -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
+ }
@@ -1 +1,39 @@
1
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
+ }