@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,5 @@
1
+ import type { InvoiceLine } from "./contracts/facts.js";
2
+ export declare function mapCanonicalIssueLines(linesSnapshot: unknown[], chargedTotalGrossMinor: number, chargedTotalNetMinor: number, defaultLabel: string): Array<Pick<InvoiceLine, "quantity" | "unitGrossMinor" | "unitNetMinor" | "totalGrossMinor" | "totalNetMinor"> & {
3
+ name: string;
4
+ vatRate: string;
5
+ }>;
@@ -0,0 +1,104 @@
1
+ import { allocateDiscountedGrossTotals } from "./accountingLineAllocation.js";
2
+ export function mapCanonicalIssueLines(linesSnapshot, chargedTotalGrossMinor, chargedTotalNetMinor, defaultLabel) {
3
+ const lines = linesSnapshot.map((value) => {
4
+ const row = isRecord(value) ? value : {};
5
+ const quantity = requiredPositiveInteger(row.quantity, "quantity");
6
+ const totalGrossMinor = requiredNonnegativeInteger(row.totalGrossMinor, "total_gross");
7
+ const totalNetMinor = requiredNonnegativeInteger(row.totalNetMinor, "total_net");
8
+ const vatRateBps = requiredNonnegativeInteger(row.vatRateBps, "vat_rate_bps");
9
+ if (vatRateBps > 10_000 || netFromGross(totalGrossMinor, vatRateBps) !== totalNetMinor) {
10
+ throw new Error("accounting_invoice_canonical_position_vat_mismatch");
11
+ }
12
+ const vatRate = canonicalVatRate(vatRateBps);
13
+ const suppliedVatRate = textValue(row.vatRate);
14
+ if (suppliedVatRate !== null && normalizeVatRate(suppliedVatRate) !== vatRate) {
15
+ throw new Error("accounting_invoice_canonical_position_vat_label_mismatch");
16
+ }
17
+ return {
18
+ positionKind: textValue(row.positionKind),
19
+ name: textValue(row.name) ?? defaultLabel,
20
+ quantity,
21
+ unitGrossMinor: requiredNonnegativeInteger(row.unitGrossMinor, "unit_gross"),
22
+ unitNetMinor: requiredNonnegativeInteger(row.unitNetMinor, "unit_net"),
23
+ totalGrossMinor,
24
+ totalNetMinor,
25
+ vatRate,
26
+ catalogTotalGrossMinor: numberValue(row.catalogTotalGrossMinor),
27
+ discountAllocatedMinor: numberValue(row.discountAllocatedMinor),
28
+ shippingGrossMinor: numberValue(row.shippingGrossMinor),
29
+ shippingDiscountMinor: numberValue(row.shippingDiscountMinor),
30
+ };
31
+ });
32
+ const items = lines.filter((line) => line.positionKind === "item");
33
+ const delivery = lines.filter((line) => line.positionKind === "delivery");
34
+ if (items.length === 0 || delivery.length > 1) {
35
+ throw new Error("accounting_invoice_canonical_position_cardinality_invalid");
36
+ }
37
+ const actualDelivery = delivery[0]?.totalGrossMinor ?? 0;
38
+ if (delivery.length === 1) {
39
+ const shippingGross = delivery[0].shippingGrossMinor;
40
+ const shippingDiscount = delivery[0].shippingDiscountMinor;
41
+ if (!Number.isSafeInteger(shippingGross) || !Number.isSafeInteger(shippingDiscount)
42
+ || shippingGross < 0 || shippingDiscount < 0 || shippingDiscount > shippingGross
43
+ || shippingGross - shippingDiscount !== actualDelivery) {
44
+ throw new Error("accounting_invoice_canonical_delivery_mismatch");
45
+ }
46
+ }
47
+ const catalogTotals = items.map((line) => {
48
+ if (!Number.isInteger(line.catalogTotalGrossMinor) || (line.catalogTotalGrossMinor ?? -1) < 0) {
49
+ throw new Error("accounting_invoice_canonical_catalog_total_invalid");
50
+ }
51
+ if (!Number.isInteger(line.discountAllocatedMinor) || (line.discountAllocatedMinor ?? -1) < 0) {
52
+ throw new Error("accounting_invoice_canonical_discount_allocation_invalid");
53
+ }
54
+ if (line.totalGrossMinor + line.discountAllocatedMinor !== line.catalogTotalGrossMinor) {
55
+ throw new Error("accounting_invoice_canonical_line_allocation_mismatch");
56
+ }
57
+ return line.catalogTotalGrossMinor;
58
+ });
59
+ const independentlyAllocated = allocateDiscountedGrossTotals(catalogTotals, chargedTotalGrossMinor - actualDelivery);
60
+ if (items.some((line, index) => line.totalGrossMinor !== independentlyAllocated[index])) {
61
+ throw new Error("accounting_invoice_canonical_allocator_delta_nonzero");
62
+ }
63
+ const grossSum = lines.reduce((sum, line) => sum + line.totalGrossMinor, 0);
64
+ const netSum = lines.reduce((sum, line) => sum + line.totalNetMinor, 0);
65
+ if (grossSum !== chargedTotalGrossMinor || netSum !== chargedTotalNetMinor) {
66
+ throw new Error(`accounting_invoice_canonical_positions_header_mismatch gross=${grossSum}/${chargedTotalGrossMinor} net=${netSum}/${chargedTotalNetMinor}`);
67
+ }
68
+ return lines.map(({ positionKind: _kind, catalogTotalGrossMinor: _catalog, discountAllocatedMinor: _discount, shippingGrossMinor: _shippingGross, shippingDiscountMinor: _shippingDiscount, ...line }) => line);
69
+ }
70
+ function requiredNonnegativeInteger(value, field) {
71
+ const number = numberValue(value);
72
+ if (number === null || !Number.isSafeInteger(number) || number < 0) {
73
+ throw new Error(`accounting_invoice_canonical_${field}_invalid`);
74
+ }
75
+ return number;
76
+ }
77
+ function requiredPositiveInteger(value, field) {
78
+ const number = requiredNonnegativeInteger(value, field);
79
+ if (number === 0)
80
+ throw new Error(`accounting_invoice_canonical_${field}_invalid`);
81
+ return number;
82
+ }
83
+ function netFromGross(gross, vatRateBps) {
84
+ return Math.round(gross * 10_000 / (10_000 + vatRateBps));
85
+ }
86
+ function canonicalVatRate(vatRateBps) {
87
+ return normalizeVatRate(String(vatRateBps / 100));
88
+ }
89
+ function normalizeVatRate(value) {
90
+ const parsed = Number(value.replace(",", "."));
91
+ if (!Number.isFinite(parsed) || parsed < 0) {
92
+ throw new Error("accounting_invoice_canonical_position_vat_label_invalid");
93
+ }
94
+ return String(parsed);
95
+ }
96
+ function textValue(value) {
97
+ return typeof value === "string" && value.trim() ? value.trim() : null;
98
+ }
99
+ function numberValue(value) {
100
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
101
+ }
102
+ function isRecord(value) {
103
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
104
+ }
@@ -0,0 +1,2 @@
1
+ export declare function allocateChargedGrossTotals(lineTotals: number[], chargedTotal: number): number[];
2
+ export declare function allocateDiscountedGrossTotals(lineTotals: number[], chargedTotal: number): number[];
@@ -0,0 +1,69 @@
1
+ // Independent document-boundary allocation. The database writer performs its
2
+ // own SQL allocation; this copy deliberately verifies persisted results rather
3
+ // than sharing an implementation with the order writer.
4
+ export function allocateChargedGrossTotals(lineTotals, chargedTotal) {
5
+ if (!Number.isInteger(chargedTotal) || chargedTotal < 0) {
6
+ throw new Error(`accounting_invoice_charged_total_invalid charged=${chargedTotal}`);
7
+ }
8
+ if (lineTotals.some((total) => !Number.isInteger(total) || total < 0)) {
9
+ throw new Error("accounting_invoice_line_totals_invalid");
10
+ }
11
+ const linesTotal = lineTotals.reduce((sum, total) => sum + total, 0);
12
+ if (linesTotal === chargedTotal)
13
+ return lineTotals;
14
+ if (linesTotal < chargedTotal) {
15
+ throw new Error(`accounting_invoice_lines_below_charged_total lines=${linesTotal} charged=${chargedTotal}`);
16
+ }
17
+ const scaled = lineTotals.map((total) => (total * chargedTotal) / linesTotal);
18
+ const allocated = scaled.map((value) => Math.floor(value));
19
+ let remainder = chargedTotal - allocated.reduce((sum, total) => sum + total, 0);
20
+ const byFraction = scaled
21
+ .map((value, index) => ({ index, fraction: value - allocated[index] }))
22
+ .sort((a, b) => b.fraction - a.fraction || a.index - b.index);
23
+ for (const { index } of byFraction) {
24
+ if (remainder <= 0)
25
+ break;
26
+ allocated[index] += 1;
27
+ remainder -= 1;
28
+ }
29
+ return allocated;
30
+ }
31
+ // Mirrors the order writer's discount allocation rule for verification only:
32
+ // allocate the discount (not the charged total) by largest remainder, then
33
+ // subtract it from each catalog line. Allocating the charged total directly
34
+ // reverses the deterministic tie-break for equal lines.
35
+ export function allocateDiscountedGrossTotals(lineTotals, chargedTotal) {
36
+ const linesTotal = lineTotals.reduce((sum, total) => sum + total, 0);
37
+ const discount = linesTotal - chargedTotal;
38
+ if (!Number.isInteger(chargedTotal) || chargedTotal < 0 || discount < 0) {
39
+ throw new Error(`accounting_invoice_charged_total_invalid charged=${chargedTotal}`);
40
+ }
41
+ if (lineTotals.some((total) => !Number.isInteger(total) || total < 0)) {
42
+ throw new Error("accounting_invoice_line_totals_invalid");
43
+ }
44
+ if (discount === 0)
45
+ return lineTotals;
46
+ if (linesTotal === 0) {
47
+ throw new Error("accounting_invoice_discount_allocation_zero_spread");
48
+ }
49
+ const sumExact = BigInt(linesTotal);
50
+ const discountExact = BigInt(discount);
51
+ const scaledDiscounts = lineTotals.map((total) => {
52
+ const product = BigInt(total) * discountExact;
53
+ return { base: product / sumExact, remainder: product % sumExact };
54
+ });
55
+ const allocatedDiscounts = scaledDiscounts.map(({ base }) => Number(base));
56
+ let remainder = discount - allocatedDiscounts.reduce((sum, total) => sum + total, 0);
57
+ const byFraction = scaledDiscounts
58
+ .map((value, index) => ({ index, fraction: value.remainder }))
59
+ .sort((a, b) => a.fraction === b.fraction
60
+ ? a.index - b.index
61
+ : a.fraction > b.fraction ? -1 : 1);
62
+ for (const { index } of byFraction) {
63
+ if (remainder <= 0)
64
+ break;
65
+ allocatedDiscounts[index] += 1;
66
+ remainder -= 1;
67
+ }
68
+ return lineTotals.map((total, index) => total - allocatedDiscounts[index]);
69
+ }
@@ -0,0 +1,42 @@
1
+ import type { InvoiceCapabilities, InvoiceIdentity, InvoiceWriteResult, InvoiceReadbackResult, InvoiceArtifactResult, InvoiceHostPorts, InvoiceServicesOptions } from "./contracts/index.js";
2
+ type Provider = NonNullable<InvoiceServicesOptions["provider"]>;
3
+ type Fixture<Input, Result> = {
4
+ readonly input: Input;
5
+ readonly expected: Result;
6
+ };
7
+ interface Subject {
8
+ readonly subjectId: string;
9
+ readonly capability: InvoiceCapabilities;
10
+ readonly provider: Provider;
11
+ readonly host?: InvoiceHostPorts;
12
+ /** Adapter-owned offline fixtures; count writes across every injected transport. */
13
+ readonly readWriteCount: () => number;
14
+ readonly sensitiveValues: readonly string[];
15
+ readonly fixtures: {
16
+ readonly issue: readonly (Fixture<Parameters<Provider["issue"]>[0], InvoiceWriteResult> & {
17
+ readonly replay?: boolean;
18
+ })[];
19
+ readonly correction: readonly (Fixture<Parameters<Provider["correct"]>[0], InvoiceWriteResult> & {
20
+ readonly replay?: boolean;
21
+ })[];
22
+ readonly readback: readonly Fixture<InvoiceIdentity, InvoiceReadbackResult>[];
23
+ readonly void: readonly Fixture<Parameters<Provider["void"]>[0], Awaited<ReturnType<Provider["void"]>>>[];
24
+ readonly artifact?: readonly Fixture<Parameters<InvoiceHostPorts["artifacts"]["read"]>[0], InvoiceArtifactResult>[];
25
+ readonly clearance?: readonly Fixture<Parameters<InvoiceHostPorts["clearance"]["observe"]>[0], Awaited<ReturnType<InvoiceHostPorts["clearance"]["observe"]>>>[];
26
+ readonly settlement?: readonly Fixture<Parameters<InvoiceHostPorts["settlement"]["record"]>[0], Awaited<ReturnType<InvoiceHostPorts["settlement"]["record"]>>>[];
27
+ readonly companyLookup?: readonly Fixture<Parameters<InvoiceHostPorts["registry"]["lookup"]>[0], Awaited<ReturnType<InvoiceHostPorts["registry"]["lookup"]>>>[];
28
+ };
29
+ }
30
+ /** @consumer Offline provider bridge tests and installed cold consumer. Runner-
31
+ * agnostic contract evidence only; adapters own fixtures, transport and I/O counts.
32
+ * Throws closed failure codes. It neither certifies live fiscal readiness nor logs
33
+ * provider results, secrets, buyer facts or raw adapter errors.
34
+ */
35
+ export declare function runInvoiceAdapterConformance(subjects: readonly Subject[]): Promise<{
36
+ readonly subjects: readonly {
37
+ readonly subjectId: string;
38
+ readonly providerRef: string;
39
+ readonly cases: readonly string[];
40
+ }[];
41
+ }>;
42
+ export {};
@@ -0,0 +1,296 @@
1
+ class ContractFailure extends Error {
2
+ }
3
+ function requireFact(value, code) {
4
+ if (!value)
5
+ throw new ContractFailure(`INVOICE_CONFORMANCE:${code}`);
6
+ }
7
+ function same(a, b) {
8
+ if (a === b)
9
+ return true;
10
+ if (!a || !b || typeof a !== "object" || typeof b !== "object")
11
+ return false;
12
+ if (Array.isArray(a) !== Array.isArray(b))
13
+ return false;
14
+ const left = Object.keys(a), right = Object.keys(b);
15
+ return left.length === right.length && left.every(key => Object.hasOwn(b, key) && same(a[key], b[key]));
16
+ }
17
+ function keys(value, allowed) {
18
+ requireFact(value !== null && typeof value === "object", "proof_required");
19
+ requireFact(Object.keys(value).every(key => allowed.includes(key)), "unexpected_field");
20
+ }
21
+ function proof(value, extra = []) {
22
+ requireFact(value !== null && typeof value === "object", "proof_required");
23
+ keys(value, ["evidenceRef", "fingerprint", "observedAt", ...extra]);
24
+ requireFact([value.evidenceRef, value.fingerprint, value.observedAt].every(v => typeof v === "string" && v.trim().length > 0), "proof_required");
25
+ }
26
+ function identity(value, expected) {
27
+ keys(value, "documentRef" in expected ? ["providerRef", "documentRef", "providerDocumentRef", "orderRef", "invoiceRef", "parentProviderDocumentRef"] : ["providerRef", "requestKey", "providerCreateIdentity", "factRevision", "orderRef", "invoiceRef", "operation", "correctionRef", "parentProviderDocumentRef"]);
28
+ requireFact(same(value, expected), "identity_scope");
29
+ }
30
+ function safe(result, subject) {
31
+ const serialized = JSON.stringify(result);
32
+ requireFact(subject.sensitiveValues.every(value => value.length > 0 && !serialized.includes(value) && !serialized.includes(encodeURIComponent(value))), "sensitive_result");
33
+ }
34
+ function documentResult(result, expected, operation) {
35
+ requireFact(operation === "void" ? result.kind === "unsupported" : operation === "read" ? ["found", "absent", "unknown", "unsupported"].includes(result.kind) : ["issued", "refused", "unknown", "unsupported"].includes(result.kind), "document_result_kind");
36
+ if (result.kind === "unsupported") {
37
+ keys(result, ["kind", "code", "retryable", "effectMayHaveSucceeded"]);
38
+ requireFact(result.retryable === false && result.effectMayHaveSucceeded === false && ["unsupported", "host_binding_required"].includes(result.code), "unsupported_before_write");
39
+ return;
40
+ }
41
+ identity(result.identity, expected);
42
+ if (result.kind === "issued" || result.kind === "found") {
43
+ keys(result, result.kind === "found" ? ["kind", "identity", "providerDocumentRef", "providerDocumentNumber", "proof"] : ["kind", "identity", "providerDocumentRef", "providerDocumentNumber", "proof", "replayed"]);
44
+ proof(result.proof);
45
+ if (result.kind === "issued")
46
+ requireFact(typeof result.replayed === "boolean", "issued_replay");
47
+ requireFact(result.providerDocumentRef.trim().length > 0 && (result.providerDocumentNumber === null || typeof result.providerDocumentNumber === "string"), "issued_identity");
48
+ }
49
+ else if (result.kind === "absent") {
50
+ keys(result, ["kind", "identity", "evidence"]);
51
+ proof(result.evidence, ["exact", "complete", "scope"]);
52
+ requireFact(result.evidence.exact === true && result.evidence.complete === true, "exact_complete_absence");
53
+ identity(result.evidence.scope, expected);
54
+ }
55
+ else if (result.kind === "unknown") {
56
+ keys(result, ["kind", "identity", "code", "effectMayHaveSucceeded"]);
57
+ requireFact(result.code === "unknown" && (operation === "write" ? "effectMayHaveSucceeded" in result && result.effectMayHaveSucceeded === true : !("effectMayHaveSucceeded" in result)), "unknown_not_absent");
58
+ }
59
+ else {
60
+ keys(result, ["kind", "identity", "code", "retryable", "effectMayHaveSucceeded"]);
61
+ requireFact(result.kind === "refused" && result.effectMayHaveSucceeded === false && typeof result.retryable === "boolean" && ["blocked", "retryable", "missing_facts", "identity_mismatch", "parent_mismatch", "money_mismatch"].includes(result.code), "refusal_before_write");
62
+ }
63
+ }
64
+ function hostResult(operation, input, value) {
65
+ const result = value;
66
+ if (result.kind === "unsupported") {
67
+ keys(result, ["kind", "code", "retryable", "effectMayHaveSucceeded"]);
68
+ requireFact(result.retryable === false && result.effectMayHaveSucceeded === false && (result.code === "unsupported" || result.code === "host_binding_required"), "host_unsupported");
69
+ return;
70
+ }
71
+ if (operation === "artifact") {
72
+ const artifact = value, request = input;
73
+ identity(artifact.identity, request.identity);
74
+ if (artifact.kind === "found") {
75
+ keys(artifact, ["kind", "identity", "artifact"]);
76
+ keys(artifact.artifact, ["kind", "ref", "owner", "mediaType", "proof"]);
77
+ requireFact(artifact.artifact.kind === request.kind && artifact.artifact.ref.length > 0 && artifact.artifact.mediaType.length > 0 && ["provider", "host_storage"].includes(artifact.artifact.owner), "artifact_reference");
78
+ proof(artifact.artifact.proof);
79
+ }
80
+ else if (artifact.kind === "absent") {
81
+ keys(artifact, ["kind", "identity", "evidence"]);
82
+ proof(artifact.evidence, ["exact", "complete", "scope"]);
83
+ keys(artifact.evidence.scope, ["identity", "kind", "artifactRef"]);
84
+ requireFact(artifact.evidence.exact === true && artifact.evidence.complete === true && artifact.evidence.scope.kind === request.kind && artifact.evidence.scope.artifactRef === null, "artifact_absence");
85
+ identity(artifact.evidence.scope.identity, request.identity);
86
+ }
87
+ else {
88
+ keys(artifact, ["kind", "identity", "code"]);
89
+ requireFact(artifact.kind === "unknown" && artifact.code === "unknown", "artifact_unknown");
90
+ }
91
+ }
92
+ else if (operation === "clearance") {
93
+ const observation = value;
94
+ if (observation.kind === "observed") {
95
+ keys(observation, ["kind", "observation"]);
96
+ const observed = observation.observation;
97
+ keys(observed, ["state", "reference", "proof"]);
98
+ proof(observed.proof);
99
+ requireFact(["not_submitted", "pending", "rejected", "accepted", "not_required", "unsupported"].includes(observed.state), "clearance_state");
100
+ requireFact(observed.state === "accepted" ? typeof observed.reference === "string" && observed.reference.trim().length > 0 : observed.reference === null || typeof observed.reference === "string", "clearance_reference");
101
+ }
102
+ else {
103
+ keys(observation, ["kind", "code"]);
104
+ requireFact(observation.kind === "unknown" && observation.code === "unknown", "clearance_unknown");
105
+ }
106
+ }
107
+ else if (operation === "settlement") {
108
+ const item = input;
109
+ requireFact([item.grossMinor, item.feeMinor, item.netMinor].every(Number.isSafeInteger) && item.grossMinor - item.feeMinor === item.netMinor, "settlement_money");
110
+ keys(result, ["kind", "itemRef", "settlementItemRef", "replayed", "code"]);
111
+ requireFact(result.itemRef === item.itemRef, "settlement_item_identity");
112
+ requireFact(["recorded", "conflict", "unknown"].includes(String(result.kind)), "settlement_result");
113
+ if (result.kind === "recorded")
114
+ requireFact(typeof result.replayed === "boolean" && typeof result.settlementItemRef === "string" && result.settlementItemRef.length > 0, "settlement_receipt");
115
+ if (result.kind === "conflict")
116
+ requireFact(result.code === "identity_mismatch" || result.code === "money_mismatch", "settlement_conflict");
117
+ if (result.kind === "unknown")
118
+ requireFact(result.code === "unknown", "settlement_unknown");
119
+ }
120
+ else {
121
+ const company = value;
122
+ keys(result, ["kind", "identifier", "legalName", "registrationIds", "address", "taxRegistrations", "sourceRef", "proof", "code"]);
123
+ const request = input;
124
+ requireFact(company.sourceRef === request.sourceRef, "company_source");
125
+ if (company.kind !== "unknown")
126
+ requireFact(same(company.identifier, request.identifier), "company_identity");
127
+ if (company.kind === "found" || company.kind === "partial") {
128
+ proof(company.proof);
129
+ for (const registration of company.taxRegistrations) {
130
+ keys(registration, ["scheme", "status"]);
131
+ requireFact(["active", "exempt", "not_registered", "unknown"].includes(registration.status), "company_tax_status");
132
+ }
133
+ }
134
+ else if (company.kind === "not_found")
135
+ proof(company.proof);
136
+ else
137
+ requireFact(company.kind === "unknown" && company.code === "unknown", "company_unknown");
138
+ }
139
+ }
140
+ /** @consumer Offline provider bridge tests and installed cold consumer. Runner-
141
+ * agnostic contract evidence only; adapters own fixtures, transport and I/O counts.
142
+ * Throws closed failure codes. It neither certifies live fiscal readiness nor logs
143
+ * provider results, secrets, buyer facts or raw adapter errors.
144
+ */
145
+ export async function runInvoiceAdapterConformance(subjects) {
146
+ const evidence = [];
147
+ try {
148
+ requireFact(subjects.length > 0, "subject_required");
149
+ for (const subject of subjects) {
150
+ const { capability: caps, provider, fixtures } = subject;
151
+ const cases = [];
152
+ requireFact(caps.defaultOff === true, "default_off");
153
+ requireFact(caps.version === "invoicing.capabilities.v1" && caps.numberingOwner === "provider"
154
+ && [caps.providerRef, caps.fiscalScope.scopeRef, caps.fiscalScope.version, ...Object.values(caps.policies)].every(value => value.trim().length > 0), "capability_scope");
155
+ for (const operation of ["issue", "correction"]) {
156
+ const rows = fixtures[operation];
157
+ requireFact(rows.length > 0, "write_fixture_required");
158
+ if (caps.operations[operation].supported)
159
+ requireFact(rows.some(row => row.expected.kind === "issued"), "issued_fixture_required");
160
+ if (caps.operations[operation].supported && caps.operations.recovery.supported)
161
+ requireFact(rows.some(row => row.replay), "recovery_fixture_required");
162
+ const unknownRecovery = rows.filter(row => row.input.recoveryLookupRequired
163
+ && row.expected.kind === (caps.operations.recovery.supported ? "unknown" : "unsupported"));
164
+ if (caps.operations[operation].supported) {
165
+ requireFact(unknownRecovery.length > 0, "recovery_unknown_fixture_required");
166
+ if (caps.operations.recovery.supported && caps.operations.readback.supported)
167
+ requireFact(unknownRecovery.every(row => fixtures.readback.some(read => read.expected.kind === "unknown" && same(read.input, row.input.identity))), "recovery_unknown_readback_required");
168
+ }
169
+ for (const row of rows) {
170
+ requireFact(row.input.identity.providerRef === caps.providerRef, "selected_provider");
171
+ const before = subject.readWriteCount();
172
+ const invoke = operation === "issue" ? () => provider.issue(row.input) : () => provider.correct(row.input);
173
+ const result = await invoke();
174
+ documentResult(result, row.input.identity, "write");
175
+ safe(result, subject);
176
+ if (unknownRecovery.includes(row))
177
+ requireFact(result.kind === row.expected.kind && subject.readWriteCount() === before, "recovery_unknown_no_write");
178
+ if (row.input.recoveryLookupRequired === true) {
179
+ if (!caps.operations.recovery.supported)
180
+ requireFact(result.kind !== "issued" && subject.readWriteCount() === before, "recovery_create_without_absence");
181
+ if (subject.readWriteCount() > before || (result.kind === "issued" && result.replayed === false)) {
182
+ requireFact(caps.operations.recovery.supported && caps.operations.readback.supported && caps.operations.exactAbsence.supported
183
+ && fixtures.readback.some(read => same(read.input, row.input.identity) && read.expected.kind === "absent")
184
+ && !fixtures.readback.some(read => same(read.input, row.input.identity) && (read.expected.kind === "unknown" || read.expected.kind === "unsupported")), "recovery_create_without_absence");
185
+ }
186
+ }
187
+ requireFact(same(result, row.expected), "write_fixture");
188
+ if (!caps.operations[operation].supported)
189
+ requireFact(result.kind === "unsupported", "declared_unsupported");
190
+ if (operation === "correction" && caps.operations.correction.supported) {
191
+ const request = row.input;
192
+ requireFact(request.identity.correctionRef.length > 0 && request.identity.parentProviderDocumentRef.length > 0
193
+ && request.before.totalGrossMinor > 0 && Object.values(request.after).every(value => value === 0), "correction_before_after");
194
+ const parent = fixtures.correction.find(fixture => fixture.expected.kind === "issued" && fixture.input.identity.correctionRef === request.identity.correctionRef);
195
+ if (parent && parent.input.identity.parentProviderDocumentRef !== request.identity.parentProviderDocumentRef)
196
+ requireFact((result.kind === "refused" || result.kind === "unknown") && subject.readWriteCount() === before, "parent_mismatch_no_write");
197
+ }
198
+ if (result.kind === "unsupported" || result.kind === "refused")
199
+ requireFact(subject.readWriteCount() === before, "refused_write");
200
+ if (row.replay) {
201
+ requireFact(caps.operations.recovery.supported && result.kind === "issued", "replay_requires_issued");
202
+ const count = subject.readWriteCount();
203
+ const recovered = operation === "issue" ? await provider.issue({ ...row.input, recoveryLookupRequired: true }) : await provider.correct({ ...row.input, recoveryLookupRequired: true });
204
+ documentResult(recovered, row.input.identity, "write");
205
+ safe(recovered, subject);
206
+ requireFact(recovered.kind === "issued" && recovered.replayed === true && recovered.providerDocumentRef === result.providerDocumentRef && recovered.providerDocumentNumber === result.providerDocumentNumber, "recovery_identity");
207
+ requireFact(subject.readWriteCount() === count, "recovery_no_second_write");
208
+ }
209
+ cases.push(operation + ":" + result.kind + (row.replay ? ":recovery" : ""));
210
+ }
211
+ }
212
+ if (caps.operations.correction.supported) {
213
+ const issued = fixtures.correction.find(row => row.expected.kind === "issued");
214
+ requireFact(issued && fixtures.correction.some(row => row.input.recoveryLookupRequired
215
+ && row.input.identity.correctionRef === issued.input.identity.correctionRef
216
+ && row.input.identity.providerCreateIdentity === issued.input.identity.providerCreateIdentity
217
+ && row.input.identity.parentProviderDocumentRef !== issued.input.identity.parentProviderDocumentRef
218
+ && (row.expected.kind === "refused" || row.expected.kind === "unknown")), "correction_parent_fixture_required");
219
+ }
220
+ requireFact(fixtures.readback.length > 0 && fixtures.void.length > 0, "read_void_fixture_required");
221
+ if (caps.operations.readback.supported)
222
+ requireFact(fixtures.readback.some(row => row.expected.kind === "unknown") && fixtures.readback.some(row => row.expected.kind === "found"), "found_unknown_fixtures_required");
223
+ if (caps.operations.exactAbsence.supported)
224
+ requireFact(fixtures.readback.some(row => row.expected.kind === "absent"), "absence_fixture_required");
225
+ for (const row of fixtures.readback) {
226
+ const before = subject.readWriteCount(), result = await provider.read(row.input);
227
+ documentResult(result, row.input, "read");
228
+ safe(result, subject);
229
+ requireFact(same(result, row.expected), "read_fixture");
230
+ requireFact(subject.readWriteCount() === before, "read_no_write");
231
+ if (!caps.operations.readback.supported)
232
+ requireFact(result.kind === "unsupported", "declared_unsupported");
233
+ if (result.kind === "absent")
234
+ requireFact(caps.operations.exactAbsence.supported, "absence_capability");
235
+ cases.push("read:" + result.kind);
236
+ }
237
+ requireFact(caps.operations.remoteVoid.supported === false && caps.operations.remoteVoid.reason === "not_implemented", "remote_void_cut");
238
+ for (const row of fixtures.void) {
239
+ const before = subject.readWriteCount(), result = await provider.void(row.input);
240
+ documentResult(result, row.input, "void");
241
+ safe(result, subject);
242
+ requireFact(same(result, row.expected), "void_fixture");
243
+ requireFact(subject.readWriteCount() === before, "void_refused_write");
244
+ cases.push("void:" + result.kind);
245
+ }
246
+ const settlementReceipts = new Map();
247
+ const settlementRows = fixtures.settlement ?? [];
248
+ const distinctPayoutRows = settlementRows.filter(row => row.expected.kind === "recorded" && settlementRows.some(other => other.expected.kind === "recorded"
249
+ && row.input.itemRef !== other.input.itemRef && row.input.providerRef === other.input.providerRef
250
+ && row.input.batchRef === other.input.batchRef && row.input.providerPaymentRef === other.input.providerPaymentRef));
251
+ if (caps.operations.settlement.supported)
252
+ requireFact(distinctPayoutRows.length >= 2, "distinct_payout_fixtures_required");
253
+ // Optional host-owned capabilities require their own fixtures when declared.
254
+ for (const operation of ["artifact", "clearance", "settlement", "companyLookup"]) {
255
+ const rows = fixtures[operation] ?? [];
256
+ if (caps.operations[operation].supported)
257
+ requireFact(subject.host && rows.some(row => ["found", "partial", "recorded", "observed"].includes(row.expected.kind) && !("observation" in row.expected && row.expected.observation.state === "unsupported")), "host_fixture_required");
258
+ for (const row of rows) {
259
+ requireFact(subject.host, "host_required");
260
+ const before = subject.readWriteCount();
261
+ const result = operation === "artifact" ? await subject.host.artifacts.read(row.input)
262
+ : operation === "clearance" ? await subject.host.clearance.observe(row.input)
263
+ : operation === "settlement" ? await subject.host.settlement.record(row.input)
264
+ : await subject.host.registry.lookup(row.input);
265
+ hostResult(operation, row.input, result);
266
+ if (operation === "settlement" && distinctPayoutRows.some(fixture => fixture === row))
267
+ requireFact(result.kind === "recorded", "distinct_payout_items");
268
+ if (operation === "settlement" && "kind" in result && result.kind === "recorded") {
269
+ const input = row.input;
270
+ const item = JSON.stringify([input.providerRef, input.batchRef, input.itemRef]);
271
+ requireFact(!settlementReceipts.has(result.settlementItemRef) || settlementReceipts.get(result.settlementItemRef) === item, "distinct_payout_items");
272
+ settlementReceipts.set(result.settlementItemRef, item);
273
+ }
274
+ safe(result, subject);
275
+ requireFact(same(result, row.expected), "host_fixture");
276
+ if (result.kind === "unsupported")
277
+ requireFact(subject.readWriteCount() === before, "host_unsupported_write");
278
+ if (operation !== "settlement")
279
+ requireFact(subject.readWriteCount() === before, "host_read_no_write");
280
+ if (!caps.operations[operation].supported)
281
+ requireFact((result.kind === "unsupported" || ("observation" in result && result.observation.state === "unsupported")) && subject.readWriteCount() === before, "host_unsupported_before_write");
282
+ cases.push(operation);
283
+ }
284
+ }
285
+ const row = { subjectId: subject.subjectId, providerRef: caps.providerRef, cases };
286
+ safe(row, subject);
287
+ evidence.push(row);
288
+ }
289
+ return { subjects: evidence };
290
+ }
291
+ catch (error) {
292
+ if (error instanceof ContractFailure)
293
+ throw error;
294
+ throw new ContractFailure("INVOICE_CONFORMANCE:adapter_threw");
295
+ }
296
+ }
@@ -0,0 +1,7 @@
1
+ /** Validate transient bytes; this is neither storage proof nor delivery proof. */
2
+ export declare function assertInvoicePdf(pdf: {
3
+ content: Uint8Array;
4
+ contentType: string;
5
+ }): void;
6
+ /** The host supplies its existing fallback caption. No locale/content default. */
7
+ export declare function invoicePdfFilename(reference: string, fallback?: string): string;
@@ -0,0 +1,20 @@
1
+ const MAX_INVOICE_PDF_BYTES = 25 * 1024 * 1024;
2
+ /** Validate transient bytes; this is neither storage proof nor delivery proof. */
3
+ export function assertInvoicePdf(pdf) {
4
+ const contentType = pdf.contentType.split(";", 1)[0]?.trim().toLowerCase();
5
+ if (contentType !== "application/pdf")
6
+ throw new Error("invoice_pdf_content_type_invalid");
7
+ if (!pdf.content || pdf.content.byteLength === 0)
8
+ throw new Error("invoice_pdf_empty");
9
+ if (pdf.content.byteLength > MAX_INVOICE_PDF_BYTES)
10
+ throw new Error("invoice_pdf_too_large");
11
+ if (String.fromCharCode(...pdf.content.subarray(0, 5)) !== "%PDF-") {
12
+ throw new Error("invoice_pdf_signature_invalid");
13
+ }
14
+ }
15
+ /** The host supplies its existing fallback caption. No locale/content default. */
16
+ export function invoicePdfFilename(reference, fallback = "invoice") {
17
+ const safe = (value) => value.normalize("NFKD")
18
+ .replace(/[^A-Za-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 100);
19
+ return `${safe(reference) || safe(fallback) || "invoice"}.pdf`;
20
+ }
@@ -0,0 +1,46 @@
1
+ import type { InvoiceServicesOptions } from "./contracts/host.js";
2
+ import type { InvoiceUnsupported } from "./contracts/provider.js";
3
+ import { runNativeInvoiceIssueJob } from "./postgresInvoiceStore.js";
4
+ import { syncAcceptedInvoiceDocuments } from "./documentSync.js";
5
+ import { syncInvoicePayoutSettlements } from "./settlementSync.js";
6
+ import { createInvoiceReadModel } from "./readModel.js";
7
+ type Operation = "issue" | "correction" | "delivery" | "documentSync" | "settlementSync" | "collectDuties" | "history";
8
+ type Ports = Pick<InvoiceServicesOptions, "host"> & Required<{
9
+ provider: NonNullable<InvoiceServicesOptions["provider"]>;
10
+ store: NonNullable<InvoiceServicesOptions["store"]>;
11
+ capability: NonNullable<InvoiceServicesOptions["capability"]>;
12
+ }>;
13
+ type Refusal = InvoiceUnsupported | {
14
+ readonly kind: "refused";
15
+ readonly code: "disabled" | "selection_required";
16
+ readonly retryable: false;
17
+ readonly effectMayHaveSucceeded: false;
18
+ };
19
+ /** Host factories bind existing workers/readers, never another executor or cron. */
20
+ export interface InvoiceServices<Result = unknown> {
21
+ readiness(): Promise<{
22
+ readonly kind: "ready";
23
+ } | Refusal>;
24
+ run(operation: Operation): Promise<Result | Refusal>;
25
+ }
26
+ /** Explicit selection and host readiness precede factory construction and claim.
27
+ * Native jobs use the installed module helpers; transport-specific host facades
28
+ * may supply factories. Auth, invocation scope and leases stay host-owned.
29
+ */
30
+ export declare function createInvoiceServices<Result = unknown>(options: InvoiceServicesOptions & {
31
+ readonly factories?: Partial<Record<Operation, (ports: Ports) => () => Promise<Result>>>;
32
+ readonly jobs?: {
33
+ readonly issue?: Omit<Parameters<typeof runNativeInvoiceIssueJob>[0], "store" | "provider" | "money" | "providerRef">;
34
+ readonly delivery?: {
35
+ readonly claimOptions: Parameters<NonNullable<InvoiceServicesOptions["store"]>["claimDeliveries"]>[0];
36
+ filename(claim: Exclude<Awaited<ReturnType<NonNullable<InvoiceServicesOptions["store"]>["claimDeliveries"]>>, InvoiceUnsupported>[number]): string;
37
+ };
38
+ readonly documentSync?: Parameters<typeof syncAcceptedInvoiceDocuments>[0];
39
+ readonly settlementSync?: Omit<Parameters<typeof syncInvoicePayoutSettlements>[0], "settlement">;
40
+ readonly history?: Parameters<ReturnType<typeof createInvoiceReadModel>["history"]>[0];
41
+ readonly collectDuties?: {
42
+ readonly limit?: number;
43
+ };
44
+ };
45
+ }): InvoiceServices<Result>;
46
+ export {};