@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.
- package/CHANGELOG.md +440 -158
- package/README.md +141 -589
- package/dist/fulfillment/adapterConformance.js +1 -1
- package/dist/fulfillment/bindings.js +1 -1
- package/dist/fulfillment/contracts/conformance.d.ts +3 -1
- package/dist/fulfillment/contracts/dispatch.d.ts +45 -3
- package/dist/fulfillment/contracts/index.d.ts +3 -3
- package/dist/fulfillment/contracts/observation.d.ts +21 -1
- package/dist/fulfillment/contracts/readback.d.ts +21 -0
- package/dist/fulfillment/contracts/store.d.ts +3 -0
- package/dist/fulfillment/contracts/sync.d.ts +58 -2
- package/dist/fulfillment/dispatchProjection.d.ts +20 -1
- package/dist/fulfillment/dispatchProjection.js +97 -6
- package/dist/fulfillment/index.d.ts +3 -2
- package/dist/fulfillment/index.js +2 -2
- package/dist/fulfillment/migrations.d.ts +5 -0
- package/dist/fulfillment/migrations.js +1 -0
- package/dist/fulfillment/observationProjection.d.ts +24 -3
- package/dist/fulfillment/observationProjection.js +65 -12
- package/dist/fulfillment/omnipack/client.d.ts +3 -1
- package/dist/fulfillment/omnipack/client.js +31 -13
- package/dist/fulfillment/omnipack/index.d.ts +4 -1
- package/dist/fulfillment/omnipack/index.js +107 -45
- package/dist/fulfillment/omnipack/mappers.d.ts +7 -1
- package/dist/fulfillment/omnipack/mappers.js +24 -2
- package/dist/fulfillment/omnipack/observation.d.ts +11 -4
- package/dist/fulfillment/omnipack/observation.js +44 -29
- package/dist/fulfillment/omnipack/reconciliation.d.ts +15 -0
- package/dist/fulfillment/omnipack/reconciliation.js +55 -0
- package/dist/fulfillment/postgresDispatchStore.js +1 -1
- package/dist/fulfillment/postgresShipmentSpine.d.ts +8 -1
- package/dist/fulfillment/postgresShipmentSpine.js +17 -3
- package/dist/fulfillment/postgresStockSyncPort.js +6 -3
- package/dist/fulfillment/providerDispatchAcceptance.d.ts +1 -0
- package/dist/fulfillment/providerDispatchAcceptance.js +8 -2
- package/dist/fulfillment/providerDispatchWorker.d.ts +51 -0
- package/dist/fulfillment/providerDispatchWorker.js +65 -20
- package/dist/fulfillment/providerReconciliationWorker.d.ts +27 -19
- package/dist/fulfillment/providerReconciliationWorker.js +75 -19
- package/dist/fulfillment/providerStockSyncWorker.d.ts +5 -1
- package/dist/fulfillment/providerStockSyncWorker.js +229 -120
- package/dist/fulfillment/providerTrackingReferences.d.ts +3 -2
- package/dist/fulfillment/verifiedIngress.d.ts +38 -11
- package/dist/fulfillment/verifiedIngress.js +36 -8
- package/dist/invoicing/accountingCanonicalProviderLines.d.ts +5 -0
- package/dist/invoicing/accountingCanonicalProviderLines.js +104 -0
- package/dist/invoicing/accountingLineAllocation.d.ts +2 -0
- package/dist/invoicing/accountingLineAllocation.js +69 -0
- package/dist/invoicing/adapterConformance.d.ts +42 -0
- package/dist/invoicing/adapterConformance.js +296 -0
- package/dist/invoicing/artifacts.d.ts +7 -0
- package/dist/invoicing/artifacts.js +20 -0
- package/dist/invoicing/bindings.d.ts +46 -0
- package/dist/invoicing/bindings.js +69 -0
- package/dist/invoicing/capabilities.d.ts +11 -0
- package/dist/invoicing/capabilities.js +10 -0
- package/dist/invoicing/channelPolicy.d.ts +28 -0
- package/dist/invoicing/channelPolicy.js +27 -0
- package/dist/invoicing/continuity.d.ts +12 -0
- package/dist/invoicing/continuity.js +18 -0
- package/dist/invoicing/contracts/capabilities.d.ts +40 -0
- package/dist/invoicing/contracts/capabilities.js +1 -0
- package/dist/invoicing/contracts/companyLookup.d.ts +49 -0
- package/dist/invoicing/contracts/companyLookup.js +1 -0
- package/dist/invoicing/contracts/delivery.d.ts +74 -0
- package/dist/invoicing/contracts/delivery.js +1 -0
- package/dist/invoicing/contracts/facts.d.ts +110 -0
- package/dist/invoicing/contracts/facts.js +1 -0
- package/dist/invoicing/contracts/host.d.ts +245 -0
- package/dist/invoicing/contracts/host.js +1 -0
- package/dist/invoicing/contracts/index.d.ts +16 -0
- package/dist/invoicing/contracts/index.js +1 -0
- package/dist/invoicing/contracts/provider.d.ts +126 -0
- package/dist/invoicing/contracts/provider.js +1 -0
- package/dist/invoicing/contracts/settlement.d.ts +66 -0
- package/dist/invoicing/contracts/settlement.js +1 -0
- package/dist/invoicing/contracts/store.d.ts +277 -0
- package/dist/invoicing/contracts/store.js +1 -0
- package/dist/invoicing/contribution.d.ts +21 -0
- package/dist/invoicing/contribution.js +19 -0
- package/dist/invoicing/correction.d.ts +42 -0
- package/dist/invoicing/correction.js +44 -0
- package/dist/invoicing/delivery.d.ts +85 -0
- package/dist/invoicing/delivery.js +79 -0
- package/dist/invoicing/documentHistory.d.ts +50 -0
- package/dist/invoicing/documentHistory.js +213 -0
- package/dist/invoicing/documentSync.d.ts +24 -0
- package/dist/invoicing/documentSync.js +26 -0
- package/dist/invoicing/dutyCollector.d.ts +15 -0
- package/dist/invoicing/dutyCollector.js +35 -0
- package/dist/invoicing/fulfillmentTrigger.d.ts +56 -0
- package/dist/invoicing/fulfillmentTrigger.js +90 -0
- package/dist/invoicing/handoff.d.ts +37 -0
- package/dist/invoicing/handoff.js +62 -0
- package/dist/invoicing/index.d.ts +39 -0
- package/dist/invoicing/index.js +37 -0
- package/dist/invoicing/issue.d.ts +39 -0
- package/dist/invoicing/issue.js +55 -0
- package/dist/invoicing/migrations.d.ts +48 -0
- package/dist/invoicing/migrations.js +25 -0
- package/dist/invoicing/nativeUnsupported.d.ts +9 -0
- package/dist/invoicing/nativeUnsupported.js +22 -0
- package/dist/invoicing/paymentReadback.d.ts +20 -0
- package/dist/invoicing/paymentReadback.js +47 -0
- package/dist/invoicing/postgresInvoiceStore.d.ts +55 -0
- package/dist/invoicing/postgresInvoiceStore.js +344 -0
- package/dist/invoicing/readModel.d.ts +74 -0
- package/dist/invoicing/readModel.js +90 -0
- package/dist/invoicing/readback.d.ts +4 -0
- package/dist/invoicing/readback.js +30 -0
- package/dist/invoicing/reversal.d.ts +37 -0
- package/dist/invoicing/reversal.js +28 -0
- package/dist/invoicing/sellerRevision.d.ts +19 -0
- package/dist/invoicing/sellerRevision.js +14 -0
- package/dist/invoicing/settlementSync.d.ts +18 -0
- package/dist/invoicing/settlementSync.js +32 -0
- package/dist/invoicing/snapshot.d.ts +52 -0
- package/dist/invoicing/snapshot.js +141 -0
- package/dist/subscriptions/automaticRenewal.d.ts +44 -0
- package/dist/subscriptions/automaticRenewal.js +81 -0
- package/dist/subscriptions/automaticRenewalExecution.d.ts +21 -0
- package/dist/subscriptions/automaticRenewalExecution.js +44 -0
- package/dist/subscriptions/contracts/commands.d.ts +115 -0
- package/dist/subscriptions/contracts/commands.js +1 -0
- package/dist/subscriptions/contracts/dunning.d.ts +133 -0
- package/dist/subscriptions/contracts/dunning.js +1 -0
- package/dist/subscriptions/contracts/index.d.ts +6 -0
- package/dist/subscriptions/contracts/index.js +1 -0
- package/dist/subscriptions/contracts/messages.d.ts +43 -0
- package/dist/subscriptions/contracts/messages.js +1 -0
- package/dist/subscriptions/contracts/renewal.d.ts +533 -0
- package/dist/subscriptions/contracts/renewal.js +1 -0
- package/dist/subscriptions/contracts/stockWait.d.ts +73 -0
- package/dist/subscriptions/contracts/stockWait.js +1 -0
- package/dist/subscriptions/index.d.ts +39 -0
- package/dist/subscriptions/index.js +17 -0
- package/dist/subscriptions/paymentOutcomes.d.ts +8 -0
- package/dist/subscriptions/paymentOutcomes.js +27 -0
- package/dist/subscriptions/postgresRenewalStore.d.ts +20 -0
- package/dist/subscriptions/postgresRenewalStore.js +61 -0
- package/dist/subscriptions/postgresRetryStore.d.ts +78 -0
- package/dist/subscriptions/postgresRetryStore.js +142 -0
- package/dist/subscriptions/renewalCycleFailureMechanism.d.ts +34 -0
- package/dist/subscriptions/renewalCycleFailureMechanism.js +79 -0
- package/dist/subscriptions/renewalCycleMechanism.d.ts +46 -0
- package/dist/subscriptions/renewalCycleMechanism.js +157 -0
- package/dist/subscriptions/renewalCycleRefusalMechanism.d.ts +41 -0
- package/dist/subscriptions/renewalCycleRefusalMechanism.js +160 -0
- package/dist/subscriptions/renewalIdentity.d.ts +3 -0
- package/dist/subscriptions/renewalIdentity.js +4 -0
- package/package.json +23 -3
- package/release-gates.json +74 -7
- package/sql/20260809210000_accounting_document_rail.sql +889 -0
- package/sql/20260811150000_accounting_payout_rail.sql +333 -0
- package/sql/20260812130000_accounting_document_lifecycle_rail.sql +460 -0
- package/sql/20261022160000_subscription_retry_lifecycle.sql +328 -0
- package/sql/20261022165000_fulfillment_stock_evidence_replay_reopen.sql +30 -0
- package/sql/20261022190000_invoicing_durable_duties.sql +494 -0
- package/sql/20261022191000_invoicing_document_effects.sql +249 -0
- package/sql/20261022192000_invoicing_payment_decoupling.sql +144 -0
- package/sql/20261022195000_invoicing_seller_revision.sql +65 -0
- package/src/fulfillment/adapterConformance.ts +1 -1
- package/src/fulfillment/bindings.ts +1 -1
- package/src/fulfillment/contracts/conformance.ts +3 -1
- package/src/fulfillment/contracts/dispatch.ts +24 -5
- package/src/fulfillment/contracts/index.ts +3 -3
- package/src/fulfillment/contracts/observation.ts +11 -3
- package/src/fulfillment/contracts/readback.ts +13 -0
- package/src/fulfillment/contracts/store.ts +1 -0
- package/src/fulfillment/contracts/sync.ts +13 -2
- package/src/fulfillment/dispatchProjection.ts +94 -7
- package/src/fulfillment/index.ts +3 -2
- package/src/fulfillment/migrations.ts +1 -0
- package/src/fulfillment/observationProjection.ts +64 -13
- package/src/fulfillment/omnipack/client.ts +32 -13
- package/src/fulfillment/omnipack/index.ts +99 -39
- package/src/fulfillment/omnipack/mappers.ts +23 -2
- package/src/fulfillment/omnipack/observation.ts +45 -27
- package/src/fulfillment/omnipack/reconciliation.ts +56 -0
- package/src/fulfillment/postgresDispatchStore.ts +1 -1
- package/src/fulfillment/postgresShipmentSpine.ts +21 -4
- package/src/fulfillment/postgresStockSyncPort.ts +6 -3
- package/src/fulfillment/providerDispatchAcceptance.ts +9 -3
- package/src/fulfillment/providerDispatchWorker.ts +61 -20
- package/src/fulfillment/providerReconciliationWorker.ts +81 -25
- package/src/fulfillment/providerStockSyncWorker.ts +222 -126
- package/src/fulfillment/providerTrackingReferences.ts +3 -2
- package/src/fulfillment/verifiedIngress.ts +44 -11
- package/src/invoicing/accountingCanonicalProviderLines.ts +134 -0
- package/src/invoicing/accountingLineAllocation.ts +69 -0
- package/src/invoicing/adapterConformance.ts +249 -0
- package/src/invoicing/artifacts.ts +19 -0
- package/src/invoicing/bindings.ts +80 -0
- package/src/invoicing/capabilities.ts +9 -0
- package/src/invoicing/channelPolicy.ts +50 -0
- package/src/invoicing/continuity.ts +15 -0
- package/src/invoicing/contracts/capabilities.ts +15 -0
- package/src/invoicing/contracts/companyLookup.ts +13 -0
- package/src/invoicing/contracts/delivery.ts +24 -0
- package/src/invoicing/contracts/facts.ts +71 -0
- package/src/invoicing/contracts/host.ts +84 -0
- package/src/invoicing/contracts/index.ts +16 -0
- package/src/invoicing/contracts/provider.ts +78 -0
- package/src/invoicing/contracts/settlement.ts +39 -0
- package/src/invoicing/contracts/store.ts +102 -0
- package/src/invoicing/contribution.ts +28 -0
- package/src/invoicing/correction.ts +56 -0
- package/src/invoicing/delivery.ts +122 -0
- package/src/invoicing/documentHistory.ts +301 -0
- package/src/invoicing/documentSync.ts +51 -0
- package/src/invoicing/dutyCollector.ts +31 -0
- package/src/invoicing/fulfillmentTrigger.ts +144 -0
- package/src/invoicing/handoff.ts +71 -0
- package/src/invoicing/index.ts +39 -0
- package/src/invoicing/issue.ts +64 -0
- package/src/invoicing/migrations.ts +25 -0
- package/src/invoicing/nativeUnsupported.ts +24 -0
- package/src/invoicing/paymentReadback.ts +70 -0
- package/src/invoicing/postgresInvoiceStore.ts +334 -0
- package/src/invoicing/readModel.ts +80 -0
- package/src/invoicing/readback.ts +26 -0
- package/src/invoicing/reversal.ts +34 -0
- package/src/invoicing/sellerRevision.ts +20 -0
- package/src/invoicing/settlementSync.ts +37 -0
- package/src/invoicing/snapshot.ts +192 -0
- package/src/subscriptions/automaticRenewal.ts +87 -0
- package/src/subscriptions/automaticRenewalExecution.ts +39 -0
- package/src/subscriptions/contracts/commands.ts +46 -0
- package/src/subscriptions/contracts/dunning.ts +48 -0
- package/src/subscriptions/contracts/index.ts +6 -0
- package/src/subscriptions/contracts/messages.ts +18 -0
- package/src/subscriptions/contracts/renewal.ts +325 -0
- package/src/subscriptions/contracts/stockWait.ts +34 -0
- package/src/subscriptions/index.ts +24 -0
- package/src/subscriptions/paymentOutcomes.ts +23 -0
- package/src/subscriptions/postgresRenewalStore.ts +76 -0
- package/src/subscriptions/postgresRetryStore.ts +174 -0
- package/src/subscriptions/renewalCycleFailureMechanism.ts +119 -0
- package/src/subscriptions/renewalCycleMechanism.ts +225 -0
- package/src/subscriptions/renewalCycleRefusalMechanism.ts +215 -0
- package/src/subscriptions/renewalIdentity.ts +5 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import type { InvoiceLine } from "./contracts/facts.js";
|
|
2
|
+
import { allocateDiscountedGrossTotals } from "./accountingLineAllocation.js";
|
|
3
|
+
|
|
4
|
+
export function mapCanonicalIssueLines(
|
|
5
|
+
linesSnapshot: unknown[],
|
|
6
|
+
chargedTotalGrossMinor: number,
|
|
7
|
+
chargedTotalNetMinor: number,
|
|
8
|
+
defaultLabel: string,
|
|
9
|
+
): Array<Pick<InvoiceLine, "quantity" | "unitGrossMinor" | "unitNetMinor" | "totalGrossMinor" | "totalNetMinor"> & { name: string; vatRate: string }> {
|
|
10
|
+
const lines = linesSnapshot.map((value) => {
|
|
11
|
+
const row = isRecord(value) ? value : {};
|
|
12
|
+
const quantity = requiredPositiveInteger(row.quantity, "quantity");
|
|
13
|
+
const totalGrossMinor = requiredNonnegativeInteger(row.totalGrossMinor, "total_gross");
|
|
14
|
+
const totalNetMinor = requiredNonnegativeInteger(row.totalNetMinor, "total_net");
|
|
15
|
+
const vatRateBps = requiredNonnegativeInteger(row.vatRateBps, "vat_rate_bps");
|
|
16
|
+
if (vatRateBps > 10_000 || netFromGross(totalGrossMinor, vatRateBps) !== totalNetMinor) {
|
|
17
|
+
throw new Error("accounting_invoice_canonical_position_vat_mismatch");
|
|
18
|
+
}
|
|
19
|
+
const vatRate = canonicalVatRate(vatRateBps);
|
|
20
|
+
const suppliedVatRate = textValue(row.vatRate);
|
|
21
|
+
if (suppliedVatRate !== null && normalizeVatRate(suppliedVatRate) !== vatRate) {
|
|
22
|
+
throw new Error("accounting_invoice_canonical_position_vat_label_mismatch");
|
|
23
|
+
}
|
|
24
|
+
return {
|
|
25
|
+
positionKind: textValue(row.positionKind),
|
|
26
|
+
name: textValue(row.name) ?? defaultLabel,
|
|
27
|
+
quantity,
|
|
28
|
+
unitGrossMinor: requiredNonnegativeInteger(row.unitGrossMinor, "unit_gross"),
|
|
29
|
+
unitNetMinor: requiredNonnegativeInteger(row.unitNetMinor, "unit_net"),
|
|
30
|
+
totalGrossMinor,
|
|
31
|
+
totalNetMinor,
|
|
32
|
+
vatRate,
|
|
33
|
+
catalogTotalGrossMinor: numberValue(row.catalogTotalGrossMinor),
|
|
34
|
+
discountAllocatedMinor: numberValue(row.discountAllocatedMinor),
|
|
35
|
+
shippingGrossMinor: numberValue(row.shippingGrossMinor),
|
|
36
|
+
shippingDiscountMinor: numberValue(row.shippingDiscountMinor),
|
|
37
|
+
};
|
|
38
|
+
});
|
|
39
|
+
const items = lines.filter((line) => line.positionKind === "item");
|
|
40
|
+
const delivery = lines.filter((line) => line.positionKind === "delivery");
|
|
41
|
+
if (items.length === 0 || delivery.length > 1) {
|
|
42
|
+
throw new Error("accounting_invoice_canonical_position_cardinality_invalid");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const actualDelivery = delivery[0]?.totalGrossMinor ?? 0;
|
|
46
|
+
if (delivery.length === 1) {
|
|
47
|
+
const shippingGross = delivery[0]!.shippingGrossMinor;
|
|
48
|
+
const shippingDiscount = delivery[0]!.shippingDiscountMinor;
|
|
49
|
+
if (!Number.isSafeInteger(shippingGross) || !Number.isSafeInteger(shippingDiscount)
|
|
50
|
+
|| shippingGross! < 0 || shippingDiscount! < 0 || shippingDiscount! > shippingGross!
|
|
51
|
+
|| shippingGross! - shippingDiscount! !== actualDelivery) {
|
|
52
|
+
throw new Error("accounting_invoice_canonical_delivery_mismatch");
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const catalogTotals = items.map((line) => {
|
|
57
|
+
if (!Number.isInteger(line.catalogTotalGrossMinor) || (line.catalogTotalGrossMinor ?? -1) < 0) {
|
|
58
|
+
throw new Error("accounting_invoice_canonical_catalog_total_invalid");
|
|
59
|
+
}
|
|
60
|
+
if (!Number.isInteger(line.discountAllocatedMinor) || (line.discountAllocatedMinor ?? -1) < 0) {
|
|
61
|
+
throw new Error("accounting_invoice_canonical_discount_allocation_invalid");
|
|
62
|
+
}
|
|
63
|
+
if (line.totalGrossMinor + line.discountAllocatedMinor! !== line.catalogTotalGrossMinor) {
|
|
64
|
+
throw new Error("accounting_invoice_canonical_line_allocation_mismatch");
|
|
65
|
+
}
|
|
66
|
+
return line.catalogTotalGrossMinor!;
|
|
67
|
+
});
|
|
68
|
+
const independentlyAllocated = allocateDiscountedGrossTotals(
|
|
69
|
+
catalogTotals,
|
|
70
|
+
chargedTotalGrossMinor - actualDelivery,
|
|
71
|
+
);
|
|
72
|
+
if (items.some((line, index) => line.totalGrossMinor !== independentlyAllocated[index])) {
|
|
73
|
+
throw new Error("accounting_invoice_canonical_allocator_delta_nonzero");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const grossSum = lines.reduce((sum, line) => sum + line.totalGrossMinor, 0);
|
|
77
|
+
const netSum = lines.reduce((sum, line) => sum + line.totalNetMinor, 0);
|
|
78
|
+
if (grossSum !== chargedTotalGrossMinor || netSum !== chargedTotalNetMinor) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
`accounting_invoice_canonical_positions_header_mismatch gross=${grossSum}/${chargedTotalGrossMinor} net=${netSum}/${chargedTotalNetMinor}`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return lines.map(({
|
|
85
|
+
positionKind: _kind,
|
|
86
|
+
catalogTotalGrossMinor: _catalog,
|
|
87
|
+
discountAllocatedMinor: _discount,
|
|
88
|
+
shippingGrossMinor: _shippingGross,
|
|
89
|
+
shippingDiscountMinor: _shippingDiscount,
|
|
90
|
+
...line
|
|
91
|
+
}) => line);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function requiredNonnegativeInteger(value: unknown, field: string): number {
|
|
95
|
+
const number = numberValue(value);
|
|
96
|
+
if (number === null || !Number.isSafeInteger(number) || number < 0) {
|
|
97
|
+
throw new Error(`accounting_invoice_canonical_${field}_invalid`);
|
|
98
|
+
}
|
|
99
|
+
return number;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function requiredPositiveInteger(value: unknown, field: string): number {
|
|
103
|
+
const number = requiredNonnegativeInteger(value, field);
|
|
104
|
+
if (number === 0) throw new Error(`accounting_invoice_canonical_${field}_invalid`);
|
|
105
|
+
return number;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function netFromGross(gross: number, vatRateBps: number): number {
|
|
109
|
+
return Math.round(gross * 10_000 / (10_000 + vatRateBps));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function canonicalVatRate(vatRateBps: number): string {
|
|
113
|
+
return normalizeVatRate(String(vatRateBps / 100));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function normalizeVatRate(value: string): string {
|
|
117
|
+
const parsed = Number(value.replace(",", "."));
|
|
118
|
+
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
119
|
+
throw new Error("accounting_invoice_canonical_position_vat_label_invalid");
|
|
120
|
+
}
|
|
121
|
+
return String(parsed);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function textValue(value: unknown): string | null {
|
|
125
|
+
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function numberValue(value: unknown): number | null {
|
|
129
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
133
|
+
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
134
|
+
}
|
|
@@ -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: number[], chargedTotal: number): number[] {
|
|
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) return lineTotals;
|
|
13
|
+
if (linesTotal < chargedTotal) {
|
|
14
|
+
throw new Error(
|
|
15
|
+
`accounting_invoice_lines_below_charged_total lines=${linesTotal} charged=${chargedTotal}`,
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
const scaled = lineTotals.map((total) => (total * chargedTotal) / linesTotal);
|
|
19
|
+
const allocated = scaled.map((value) => Math.floor(value));
|
|
20
|
+
let remainder = chargedTotal - allocated.reduce((sum, total) => sum + total, 0);
|
|
21
|
+
const byFraction = scaled
|
|
22
|
+
.map((value, index) => ({ index, fraction: value - allocated[index] }))
|
|
23
|
+
.sort((a, b) => b.fraction - a.fraction || a.index - b.index);
|
|
24
|
+
for (const { index } of byFraction) {
|
|
25
|
+
if (remainder <= 0) break;
|
|
26
|
+
allocated[index] += 1;
|
|
27
|
+
remainder -= 1;
|
|
28
|
+
}
|
|
29
|
+
return allocated;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Mirrors the order writer's discount allocation rule for verification only:
|
|
33
|
+
// allocate the discount (not the charged total) by largest remainder, then
|
|
34
|
+
// subtract it from each catalog line. Allocating the charged total directly
|
|
35
|
+
// reverses the deterministic tie-break for equal lines.
|
|
36
|
+
export function allocateDiscountedGrossTotals(lineTotals: number[], chargedTotal: number): number[] {
|
|
37
|
+
const linesTotal = lineTotals.reduce((sum, total) => sum + total, 0);
|
|
38
|
+
const discount = linesTotal - chargedTotal;
|
|
39
|
+
if (!Number.isInteger(chargedTotal) || chargedTotal < 0 || discount < 0) {
|
|
40
|
+
throw new Error(`accounting_invoice_charged_total_invalid charged=${chargedTotal}`);
|
|
41
|
+
}
|
|
42
|
+
if (lineTotals.some((total) => !Number.isInteger(total) || total < 0)) {
|
|
43
|
+
throw new Error("accounting_invoice_line_totals_invalid");
|
|
44
|
+
}
|
|
45
|
+
if (discount === 0) return lineTotals;
|
|
46
|
+
if (linesTotal === 0) {
|
|
47
|
+
throw new Error("accounting_invoice_discount_allocation_zero_spread");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const sumExact = BigInt(linesTotal);
|
|
51
|
+
const discountExact = BigInt(discount);
|
|
52
|
+
const scaledDiscounts = lineTotals.map((total) => {
|
|
53
|
+
const product = BigInt(total) * discountExact;
|
|
54
|
+
return { base: product / sumExact, remainder: product % sumExact };
|
|
55
|
+
});
|
|
56
|
+
const allocatedDiscounts = scaledDiscounts.map(({ base }) => Number(base));
|
|
57
|
+
let remainder = discount - allocatedDiscounts.reduce((sum, total) => sum + total, 0);
|
|
58
|
+
const byFraction = scaledDiscounts
|
|
59
|
+
.map((value, index) => ({ index, fraction: value.remainder }))
|
|
60
|
+
.sort((a, b) => a.fraction === b.fraction
|
|
61
|
+
? a.index - b.index
|
|
62
|
+
: a.fraction > b.fraction ? -1 : 1);
|
|
63
|
+
for (const { index } of byFraction) {
|
|
64
|
+
if (remainder <= 0) break;
|
|
65
|
+
allocatedDiscounts[index] += 1;
|
|
66
|
+
remainder -= 1;
|
|
67
|
+
}
|
|
68
|
+
return lineTotals.map((total, index) => total - allocatedDiscounts[index]);
|
|
69
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import type { InvoiceCapabilities, InvoiceDocumentIdentity, InvoiceIdentity, InvoiceWriteResult, InvoiceReadbackResult, InvoiceArtifactResult, InvoiceHostPorts, InvoiceServicesOptions } from "./contracts/index.js";
|
|
2
|
+
|
|
3
|
+
type Provider = NonNullable<InvoiceServicesOptions["provider"]>;
|
|
4
|
+
type Fixture<Input, Result> = { readonly input: Input; readonly expected: Result };
|
|
5
|
+
interface Subject {
|
|
6
|
+
readonly subjectId: string;
|
|
7
|
+
readonly capability: InvoiceCapabilities;
|
|
8
|
+
readonly provider: Provider;
|
|
9
|
+
readonly host?: InvoiceHostPorts;
|
|
10
|
+
/** Adapter-owned offline fixtures; count writes across every injected transport. */
|
|
11
|
+
readonly readWriteCount: () => number;
|
|
12
|
+
readonly sensitiveValues: readonly string[];
|
|
13
|
+
readonly fixtures: {
|
|
14
|
+
readonly issue: readonly (Fixture<Parameters<Provider["issue"]>[0], InvoiceWriteResult> & { readonly replay?: boolean })[];
|
|
15
|
+
readonly correction: readonly (Fixture<Parameters<Provider["correct"]>[0], InvoiceWriteResult> & { readonly replay?: boolean })[];
|
|
16
|
+
readonly readback: readonly Fixture<InvoiceIdentity, InvoiceReadbackResult>[];
|
|
17
|
+
readonly void: readonly Fixture<Parameters<Provider["void"]>[0], Awaited<ReturnType<Provider["void"]>>>[];
|
|
18
|
+
readonly artifact?: readonly Fixture<Parameters<InvoiceHostPorts["artifacts"]["read"]>[0], InvoiceArtifactResult>[];
|
|
19
|
+
readonly clearance?: readonly Fixture<Parameters<InvoiceHostPorts["clearance"]["observe"]>[0], Awaited<ReturnType<InvoiceHostPorts["clearance"]["observe"]>>>[];
|
|
20
|
+
readonly settlement?: readonly Fixture<Parameters<InvoiceHostPorts["settlement"]["record"]>[0], Awaited<ReturnType<InvoiceHostPorts["settlement"]["record"]>>>[];
|
|
21
|
+
readonly companyLookup?: readonly Fixture<Parameters<InvoiceHostPorts["registry"]["lookup"]>[0], Awaited<ReturnType<InvoiceHostPorts["registry"]["lookup"]>>>[];
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
class ContractFailure extends Error {}
|
|
25
|
+
function requireFact(value: unknown, code: string): asserts value {
|
|
26
|
+
if (!value) throw new ContractFailure(`INVOICE_CONFORMANCE:${code}`);
|
|
27
|
+
}
|
|
28
|
+
function same(a: unknown, b: unknown): boolean {
|
|
29
|
+
if (a === b) return true;
|
|
30
|
+
if (!a || !b || typeof a !== "object" || typeof b !== "object") return false;
|
|
31
|
+
if (Array.isArray(a) !== Array.isArray(b)) return false;
|
|
32
|
+
const left = Object.keys(a), right = Object.keys(b);
|
|
33
|
+
return left.length === right.length && left.every(key => Object.hasOwn(b, key) && same((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key]));
|
|
34
|
+
}
|
|
35
|
+
function keys(value: object, allowed: readonly string[]): void {
|
|
36
|
+
requireFact(value !== null && typeof value === "object", "proof_required");
|
|
37
|
+
requireFact(Object.keys(value).every(key => allowed.includes(key)), "unexpected_field");
|
|
38
|
+
}
|
|
39
|
+
function proof(value: { evidenceRef: string; fingerprint: string; observedAt: string }, extra: readonly string[] = []): void {
|
|
40
|
+
requireFact(value !== null && typeof value === "object", "proof_required");
|
|
41
|
+
keys(value, ["evidenceRef", "fingerprint", "observedAt", ...extra]);
|
|
42
|
+
requireFact([value.evidenceRef, value.fingerprint, value.observedAt].every(v => typeof v === "string" && v.trim().length > 0), "proof_required");
|
|
43
|
+
}
|
|
44
|
+
function identity(value: InvoiceIdentity | InvoiceDocumentIdentity, expected: InvoiceIdentity | InvoiceDocumentIdentity): void {
|
|
45
|
+
keys(value, "documentRef" in expected ? ["providerRef", "documentRef", "providerDocumentRef", "orderRef", "invoiceRef", "parentProviderDocumentRef"] : ["providerRef", "requestKey", "providerCreateIdentity", "factRevision", "orderRef", "invoiceRef", "operation", "correctionRef", "parentProviderDocumentRef"]);
|
|
46
|
+
requireFact(same(value, expected), "identity_scope");
|
|
47
|
+
}
|
|
48
|
+
function safe(result: unknown, subject: Subject): void {
|
|
49
|
+
const serialized = JSON.stringify(result);
|
|
50
|
+
requireFact(subject.sensitiveValues.every(value => value.length > 0 && !serialized.includes(value) && !serialized.includes(encodeURIComponent(value))), "sensitive_result");
|
|
51
|
+
}
|
|
52
|
+
function documentResult(result: InvoiceWriteResult | InvoiceReadbackResult | Awaited<ReturnType<Provider["void"]>>, expected: InvoiceIdentity, operation: "write" | "read" | "void"): void {
|
|
53
|
+
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");
|
|
54
|
+
if (result.kind === "unsupported") {
|
|
55
|
+
keys(result, ["kind", "code", "retryable", "effectMayHaveSucceeded"]);
|
|
56
|
+
requireFact(result.retryable === false && result.effectMayHaveSucceeded === false && ["unsupported", "host_binding_required"].includes(result.code), "unsupported_before_write");
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
identity(result.identity, expected);
|
|
60
|
+
if (result.kind === "issued" || result.kind === "found") {
|
|
61
|
+
keys(result, result.kind === "found" ? ["kind", "identity", "providerDocumentRef", "providerDocumentNumber", "proof"] : ["kind", "identity", "providerDocumentRef", "providerDocumentNumber", "proof", "replayed"]);
|
|
62
|
+
proof(result.proof);
|
|
63
|
+
if (result.kind === "issued") requireFact(typeof result.replayed === "boolean", "issued_replay");
|
|
64
|
+
requireFact(result.providerDocumentRef.trim().length > 0 && (result.providerDocumentNumber === null || typeof result.providerDocumentNumber === "string"), "issued_identity");
|
|
65
|
+
} else if (result.kind === "absent") {
|
|
66
|
+
keys(result, ["kind", "identity", "evidence"]); proof(result.evidence, ["exact", "complete", "scope"]);
|
|
67
|
+
requireFact(result.evidence.exact === true && result.evidence.complete === true, "exact_complete_absence");
|
|
68
|
+
identity(result.evidence.scope, expected);
|
|
69
|
+
} else if (result.kind === "unknown") {
|
|
70
|
+
keys(result, ["kind", "identity", "code", "effectMayHaveSucceeded"]);
|
|
71
|
+
requireFact(result.code === "unknown" && (operation === "write" ? "effectMayHaveSucceeded" in result && result.effectMayHaveSucceeded === true : !("effectMayHaveSucceeded" in result)), "unknown_not_absent");
|
|
72
|
+
} else {
|
|
73
|
+
keys(result, ["kind", "identity", "code", "retryable", "effectMayHaveSucceeded"]);
|
|
74
|
+
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");
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function hostResult(operation: "artifact" | "clearance" | "settlement" | "companyLookup", input: unknown, value: unknown): void {
|
|
78
|
+
const result = value as Record<string, unknown>;
|
|
79
|
+
if (result.kind === "unsupported") {
|
|
80
|
+
keys(result, ["kind", "code", "retryable", "effectMayHaveSucceeded"]);
|
|
81
|
+
requireFact(result.retryable === false && result.effectMayHaveSucceeded === false && (result.code === "unsupported" || result.code === "host_binding_required"), "host_unsupported"); return;
|
|
82
|
+
}
|
|
83
|
+
if (operation === "artifact") {
|
|
84
|
+
const artifact = value as Exclude<InvoiceArtifactResult, { kind: "unsupported" }>, request = input as Parameters<InvoiceHostPorts["artifacts"]["read"]>[0];
|
|
85
|
+
identity(artifact.identity, request.identity);
|
|
86
|
+
if (artifact.kind === "found") {
|
|
87
|
+
keys(artifact, ["kind", "identity", "artifact"]); keys(artifact.artifact, ["kind", "ref", "owner", "mediaType", "proof"]);
|
|
88
|
+
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"); proof(artifact.artifact.proof);
|
|
89
|
+
} else if (artifact.kind === "absent") {
|
|
90
|
+
keys(artifact, ["kind", "identity", "evidence"]); proof(artifact.evidence, ["exact", "complete", "scope"]);
|
|
91
|
+
keys(artifact.evidence.scope, ["identity", "kind", "artifactRef"]);
|
|
92
|
+
requireFact(artifact.evidence.exact === true && artifact.evidence.complete === true && artifact.evidence.scope.kind === request.kind && artifact.evidence.scope.artifactRef === null, "artifact_absence"); identity(artifact.evidence.scope.identity, request.identity);
|
|
93
|
+
} else { keys(artifact, ["kind", "identity", "code"]); requireFact(artifact.kind === "unknown" && artifact.code === "unknown", "artifact_unknown"); }
|
|
94
|
+
} else if (operation === "clearance") {
|
|
95
|
+
const observation = value as Awaited<ReturnType<InvoiceHostPorts["clearance"]["observe"]>>;
|
|
96
|
+
if (observation.kind === "observed") {
|
|
97
|
+
keys(observation, ["kind", "observation"]);
|
|
98
|
+
const observed = observation.observation;
|
|
99
|
+
keys(observed, ["state", "reference", "proof"]); proof(observed.proof);
|
|
100
|
+
requireFact(["not_submitted", "pending", "rejected", "accepted", "not_required", "unsupported"].includes(observed.state), "clearance_state");
|
|
101
|
+
requireFact(observed.state === "accepted" ? typeof observed.reference === "string" && observed.reference.trim().length > 0 : observed.reference === null || typeof observed.reference === "string", "clearance_reference");
|
|
102
|
+
} else { keys(observation, ["kind", "code"]); requireFact(observation.kind === "unknown" && observation.code === "unknown", "clearance_unknown"); }
|
|
103
|
+
} else if (operation === "settlement") {
|
|
104
|
+
const item = input as Parameters<InvoiceHostPorts["settlement"]["record"]>[0];
|
|
105
|
+
requireFact([item.grossMinor, item.feeMinor, item.netMinor].every(Number.isSafeInteger) && item.grossMinor - item.feeMinor === item.netMinor, "settlement_money");
|
|
106
|
+
keys(result, ["kind", "itemRef", "settlementItemRef", "replayed", "code"]); requireFact(result.itemRef === item.itemRef, "settlement_item_identity");
|
|
107
|
+
requireFact(["recorded", "conflict", "unknown"].includes(String(result.kind)), "settlement_result");
|
|
108
|
+
if (result.kind === "recorded") requireFact(typeof result.replayed === "boolean" && typeof result.settlementItemRef === "string" && result.settlementItemRef.length > 0, "settlement_receipt");
|
|
109
|
+
if (result.kind === "conflict") requireFact(result.code === "identity_mismatch" || result.code === "money_mismatch", "settlement_conflict");
|
|
110
|
+
if (result.kind === "unknown") requireFact(result.code === "unknown", "settlement_unknown");
|
|
111
|
+
} else {
|
|
112
|
+
const company = value as Exclude<Awaited<ReturnType<InvoiceHostPorts["registry"]["lookup"]>>, { kind: "unsupported" }>;
|
|
113
|
+
keys(result, ["kind", "identifier", "legalName", "registrationIds", "address", "taxRegistrations", "sourceRef", "proof", "code"]);
|
|
114
|
+
const request = input as Parameters<InvoiceHostPorts["registry"]["lookup"]>[0];
|
|
115
|
+
requireFact(company.sourceRef === request.sourceRef, "company_source");
|
|
116
|
+
if (company.kind !== "unknown") requireFact(same(company.identifier, request.identifier), "company_identity");
|
|
117
|
+
if (company.kind === "found" || company.kind === "partial") {
|
|
118
|
+
proof(company.proof);
|
|
119
|
+
for (const registration of company.taxRegistrations) { keys(registration, ["scheme", "status"]); requireFact(["active", "exempt", "not_registered", "unknown"].includes(registration.status), "company_tax_status"); }
|
|
120
|
+
} else if (company.kind === "not_found") proof(company.proof);
|
|
121
|
+
else requireFact(company.kind === "unknown" && company.code === "unknown", "company_unknown");
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/** @consumer Offline provider bridge tests and installed cold consumer. Runner-
|
|
125
|
+
* agnostic contract evidence only; adapters own fixtures, transport and I/O counts.
|
|
126
|
+
* Throws closed failure codes. It neither certifies live fiscal readiness nor logs
|
|
127
|
+
* provider results, secrets, buyer facts or raw adapter errors.
|
|
128
|
+
*/
|
|
129
|
+
export async function runInvoiceAdapterConformance(subjects: readonly Subject[]): Promise<{ readonly subjects: readonly { readonly subjectId: string; readonly providerRef: string; readonly cases: readonly string[] }[] }> {
|
|
130
|
+
const evidence: { subjectId: string; providerRef: string; cases: string[] }[] = [];
|
|
131
|
+
try {
|
|
132
|
+
requireFact(subjects.length > 0, "subject_required");
|
|
133
|
+
for (const subject of subjects) {
|
|
134
|
+
const { capability: caps, provider, fixtures } = subject;
|
|
135
|
+
const cases: string[] = [];
|
|
136
|
+
requireFact(caps.defaultOff === true, "default_off");
|
|
137
|
+
requireFact(caps.version === "invoicing.capabilities.v1" && caps.numberingOwner === "provider"
|
|
138
|
+
&& [caps.providerRef, caps.fiscalScope.scopeRef, caps.fiscalScope.version, ...Object.values(caps.policies)].every(value => value.trim().length > 0), "capability_scope");
|
|
139
|
+
for (const operation of ["issue", "correction"] as const) {
|
|
140
|
+
const rows = fixtures[operation]; requireFact(rows.length > 0, "write_fixture_required");
|
|
141
|
+
if (caps.operations[operation].supported) requireFact(rows.some(row => row.expected.kind === "issued"), "issued_fixture_required");
|
|
142
|
+
if (caps.operations[operation].supported && caps.operations.recovery.supported) requireFact(rows.some(row => row.replay), "recovery_fixture_required");
|
|
143
|
+
const unknownRecovery = rows.filter(row => row.input.recoveryLookupRequired
|
|
144
|
+
&& row.expected.kind === (caps.operations.recovery.supported ? "unknown" : "unsupported"));
|
|
145
|
+
if (caps.operations[operation].supported) {
|
|
146
|
+
requireFact(unknownRecovery.length > 0, "recovery_unknown_fixture_required");
|
|
147
|
+
if (caps.operations.recovery.supported && caps.operations.readback.supported) requireFact(unknownRecovery.every(row => fixtures.readback.some(read => read.expected.kind === "unknown" && same(read.input, row.input.identity))), "recovery_unknown_readback_required");
|
|
148
|
+
}
|
|
149
|
+
for (const row of rows) {
|
|
150
|
+
requireFact(row.input.identity.providerRef === caps.providerRef, "selected_provider");
|
|
151
|
+
const before = subject.readWriteCount();
|
|
152
|
+
const invoke = operation === "issue" ? () => provider.issue(row.input as Parameters<Provider["issue"]>[0]) : () => provider.correct(row.input as Parameters<Provider["correct"]>[0]);
|
|
153
|
+
const result = await invoke(); documentResult(result, row.input.identity, "write"); safe(result, subject);
|
|
154
|
+
if (unknownRecovery.includes(row)) requireFact(result.kind === row.expected.kind && subject.readWriteCount() === before, "recovery_unknown_no_write");
|
|
155
|
+
if (row.input.recoveryLookupRequired === true) {
|
|
156
|
+
if (!caps.operations.recovery.supported) requireFact(result.kind !== "issued" && subject.readWriteCount() === before, "recovery_create_without_absence");
|
|
157
|
+
if (subject.readWriteCount() > before || (result.kind === "issued" && result.replayed === false)) {
|
|
158
|
+
requireFact(caps.operations.recovery.supported && caps.operations.readback.supported && caps.operations.exactAbsence.supported
|
|
159
|
+
&& fixtures.readback.some(read => same(read.input, row.input.identity) && read.expected.kind === "absent")
|
|
160
|
+
&& !fixtures.readback.some(read => same(read.input, row.input.identity) && (read.expected.kind === "unknown" || read.expected.kind === "unsupported")), "recovery_create_without_absence");
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
requireFact(same(result, row.expected), "write_fixture");
|
|
164
|
+
if (!caps.operations[operation].supported) requireFact(result.kind === "unsupported", "declared_unsupported");
|
|
165
|
+
if (operation === "correction" && caps.operations.correction.supported) {
|
|
166
|
+
const request = row.input as Parameters<Provider["correct"]>[0];
|
|
167
|
+
requireFact(request.identity.correctionRef.length > 0 && request.identity.parentProviderDocumentRef.length > 0
|
|
168
|
+
&& request.before.totalGrossMinor > 0 && Object.values(request.after).every(value => value === 0), "correction_before_after");
|
|
169
|
+
const parent = fixtures.correction.find(fixture => fixture.expected.kind === "issued" && fixture.input.identity.correctionRef === request.identity.correctionRef);
|
|
170
|
+
if (parent && parent.input.identity.parentProviderDocumentRef !== request.identity.parentProviderDocumentRef) requireFact((result.kind === "refused" || result.kind === "unknown") && subject.readWriteCount() === before, "parent_mismatch_no_write");
|
|
171
|
+
}
|
|
172
|
+
if (result.kind === "unsupported" || result.kind === "refused") requireFact(subject.readWriteCount() === before, "refused_write");
|
|
173
|
+
if (row.replay) {
|
|
174
|
+
requireFact(caps.operations.recovery.supported && result.kind === "issued", "replay_requires_issued");
|
|
175
|
+
const count = subject.readWriteCount();
|
|
176
|
+
const recovered = operation === "issue" ? await provider.issue({ ...row.input as Parameters<Provider["issue"]>[0], recoveryLookupRequired: true }) : await provider.correct({ ...row.input as Parameters<Provider["correct"]>[0], recoveryLookupRequired: true });
|
|
177
|
+
documentResult(recovered, row.input.identity, "write"); safe(recovered, subject);
|
|
178
|
+
requireFact(recovered.kind === "issued" && recovered.replayed === true && recovered.providerDocumentRef === result.providerDocumentRef && recovered.providerDocumentNumber === result.providerDocumentNumber, "recovery_identity");
|
|
179
|
+
requireFact(subject.readWriteCount() === count, "recovery_no_second_write");
|
|
180
|
+
}
|
|
181
|
+
cases.push(operation + ":" + result.kind + (row.replay ? ":recovery" : ""));
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (caps.operations.correction.supported) {
|
|
185
|
+
const issued = fixtures.correction.find(row => row.expected.kind === "issued");
|
|
186
|
+
requireFact(issued && fixtures.correction.some(row => row.input.recoveryLookupRequired
|
|
187
|
+
&& row.input.identity.correctionRef === issued.input.identity.correctionRef
|
|
188
|
+
&& row.input.identity.providerCreateIdentity === issued.input.identity.providerCreateIdentity
|
|
189
|
+
&& row.input.identity.parentProviderDocumentRef !== issued.input.identity.parentProviderDocumentRef
|
|
190
|
+
&& (row.expected.kind === "refused" || row.expected.kind === "unknown")), "correction_parent_fixture_required");
|
|
191
|
+
}
|
|
192
|
+
requireFact(fixtures.readback.length > 0 && fixtures.void.length > 0, "read_void_fixture_required");
|
|
193
|
+
if (caps.operations.readback.supported) requireFact(fixtures.readback.some(row => row.expected.kind === "unknown") && fixtures.readback.some(row => row.expected.kind === "found"), "found_unknown_fixtures_required");
|
|
194
|
+
if (caps.operations.exactAbsence.supported) requireFact(fixtures.readback.some(row => row.expected.kind === "absent"), "absence_fixture_required");
|
|
195
|
+
for (const row of fixtures.readback) {
|
|
196
|
+
const before = subject.readWriteCount(), result = await provider.read(row.input);
|
|
197
|
+
documentResult(result, row.input, "read"); safe(result, subject); requireFact(same(result, row.expected), "read_fixture");
|
|
198
|
+
requireFact(subject.readWriteCount() === before, "read_no_write");
|
|
199
|
+
if (!caps.operations.readback.supported) requireFact(result.kind === "unsupported", "declared_unsupported");
|
|
200
|
+
if (result.kind === "absent") requireFact(caps.operations.exactAbsence.supported, "absence_capability");
|
|
201
|
+
cases.push("read:" + result.kind);
|
|
202
|
+
}
|
|
203
|
+
requireFact(caps.operations.remoteVoid.supported === false && caps.operations.remoteVoid.reason === "not_implemented", "remote_void_cut");
|
|
204
|
+
for (const row of fixtures.void) {
|
|
205
|
+
const before = subject.readWriteCount(), result = await provider.void(row.input);
|
|
206
|
+
documentResult(result, row.input, "void"); safe(result, subject); requireFact(same(result, row.expected), "void_fixture");
|
|
207
|
+
requireFact(subject.readWriteCount() === before, "void_refused_write");
|
|
208
|
+
cases.push("void:" + result.kind);
|
|
209
|
+
}
|
|
210
|
+
const settlementReceipts = new Map<string, string>();
|
|
211
|
+
const settlementRows = fixtures.settlement ?? [];
|
|
212
|
+
const distinctPayoutRows = settlementRows.filter(row => row.expected.kind === "recorded" && settlementRows.some(other => other.expected.kind === "recorded"
|
|
213
|
+
&& row.input.itemRef !== other.input.itemRef && row.input.providerRef === other.input.providerRef
|
|
214
|
+
&& row.input.batchRef === other.input.batchRef && row.input.providerPaymentRef === other.input.providerPaymentRef));
|
|
215
|
+
if (caps.operations.settlement.supported) requireFact(distinctPayoutRows.length >= 2, "distinct_payout_fixtures_required");
|
|
216
|
+
// Optional host-owned capabilities require their own fixtures when declared.
|
|
217
|
+
for (const operation of ["artifact", "clearance", "settlement", "companyLookup"] as const) {
|
|
218
|
+
const rows = fixtures[operation] ?? [];
|
|
219
|
+
if (caps.operations[operation].supported) 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");
|
|
220
|
+
for (const row of rows) {
|
|
221
|
+
requireFact(subject.host, "host_required"); const before = subject.readWriteCount();
|
|
222
|
+
const result = operation === "artifact" ? await subject.host.artifacts.read(row.input as Parameters<InvoiceHostPorts["artifacts"]["read"]>[0])
|
|
223
|
+
: operation === "clearance" ? await subject.host.clearance.observe(row.input as Parameters<InvoiceHostPorts["clearance"]["observe"]>[0])
|
|
224
|
+
: operation === "settlement" ? await subject.host.settlement.record(row.input as Parameters<InvoiceHostPorts["settlement"]["record"]>[0])
|
|
225
|
+
: await subject.host.registry.lookup(row.input as Parameters<InvoiceHostPorts["registry"]["lookup"]>[0]);
|
|
226
|
+
hostResult(operation, row.input, result);
|
|
227
|
+
if (operation === "settlement" && distinctPayoutRows.some(fixture => fixture === row)) requireFact(result.kind === "recorded", "distinct_payout_items");
|
|
228
|
+
if (operation === "settlement" && "kind" in result && result.kind === "recorded") {
|
|
229
|
+
const input = row.input as Parameters<InvoiceHostPorts["settlement"]["record"]>[0];
|
|
230
|
+
const item = JSON.stringify([input.providerRef, input.batchRef, input.itemRef]);
|
|
231
|
+
requireFact(!settlementReceipts.has(result.settlementItemRef) || settlementReceipts.get(result.settlementItemRef) === item, "distinct_payout_items");
|
|
232
|
+
settlementReceipts.set(result.settlementItemRef, item);
|
|
233
|
+
}
|
|
234
|
+
safe(result, subject); requireFact(same(result, row.expected), "host_fixture");
|
|
235
|
+
if (result.kind === "unsupported") requireFact(subject.readWriteCount() === before, "host_unsupported_write");
|
|
236
|
+
if (operation !== "settlement") requireFact(subject.readWriteCount() === before, "host_read_no_write");
|
|
237
|
+
if (!caps.operations[operation].supported) requireFact((result.kind === "unsupported" || ("observation" in result && result.observation.state === "unsupported")) && subject.readWriteCount() === before, "host_unsupported_before_write");
|
|
238
|
+
cases.push(operation);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
const row = { subjectId: subject.subjectId, providerRef: caps.providerRef, cases };
|
|
242
|
+
safe(row, subject); evidence.push(row);
|
|
243
|
+
}
|
|
244
|
+
return { subjects: evidence };
|
|
245
|
+
} catch (error) {
|
|
246
|
+
if (error instanceof ContractFailure) throw error;
|
|
247
|
+
throw new ContractFailure("INVOICE_CONFORMANCE:adapter_threw");
|
|
248
|
+
}
|
|
249
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
const MAX_INVOICE_PDF_BYTES = 25 * 1024 * 1024;
|
|
2
|
+
|
|
3
|
+
/** Validate transient bytes; this is neither storage proof nor delivery proof. */
|
|
4
|
+
export function assertInvoicePdf(pdf: { content: Uint8Array; contentType: string }): void {
|
|
5
|
+
const contentType = pdf.contentType.split(";", 1)[0]?.trim().toLowerCase();
|
|
6
|
+
if (contentType !== "application/pdf") throw new Error("invoice_pdf_content_type_invalid");
|
|
7
|
+
if (!pdf.content || pdf.content.byteLength === 0) throw new Error("invoice_pdf_empty");
|
|
8
|
+
if (pdf.content.byteLength > MAX_INVOICE_PDF_BYTES) throw new Error("invoice_pdf_too_large");
|
|
9
|
+
if (String.fromCharCode(...pdf.content.subarray(0, 5)) !== "%PDF-") {
|
|
10
|
+
throw new Error("invoice_pdf_signature_invalid");
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** The host supplies its existing fallback caption. No locale/content default. */
|
|
15
|
+
export function invoicePdfFilename(reference: string, fallback = "invoice"): string {
|
|
16
|
+
const safe = (value: string) => value.normalize("NFKD")
|
|
17
|
+
.replace(/[^A-Za-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 100);
|
|
18
|
+
return `${safe(reference) || safe(fallback) || "invoice"}.pdf`;
|
|
19
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { InvoiceServicesOptions } from "./contracts/host.js";
|
|
2
|
+
import type { InvoiceUnsupported } from "./contracts/provider.js";
|
|
3
|
+
import { createIndependentInvoiceDutyBinding } from "./continuity.js";
|
|
4
|
+
import { runNativeInvoiceIssueJob } from "./postgresInvoiceStore.js";
|
|
5
|
+
import { runInvoiceDeliveryJob } from "./delivery.js";
|
|
6
|
+
import { syncAcceptedInvoiceDocuments } from "./documentSync.js";
|
|
7
|
+
import { syncInvoicePayoutSettlements } from "./settlementSync.js";
|
|
8
|
+
import { createInvoiceReadModel } from "./readModel.js";
|
|
9
|
+
import { readInvoiceCapabilities } from "./capabilities.js";
|
|
10
|
+
|
|
11
|
+
type Operation = "issue" | "correction" | "delivery" | "documentSync" | "settlementSync" | "collectDuties" | "history";
|
|
12
|
+
type Ports = Pick<InvoiceServicesOptions, "host"> & Required<{
|
|
13
|
+
provider: NonNullable<InvoiceServicesOptions["provider"]>;
|
|
14
|
+
store: NonNullable<InvoiceServicesOptions["store"]>;
|
|
15
|
+
capability: NonNullable<InvoiceServicesOptions["capability"]>;
|
|
16
|
+
}>;
|
|
17
|
+
type Refusal = InvoiceUnsupported | { readonly kind: "refused"; readonly code: "disabled" | "selection_required"; readonly retryable: false; readonly effectMayHaveSucceeded: false };
|
|
18
|
+
/** Host factories bind existing workers/readers, never another executor or cron. */
|
|
19
|
+
export interface InvoiceServices<Result = unknown> {
|
|
20
|
+
readiness(): Promise<{ readonly kind: "ready" } | Refusal>;
|
|
21
|
+
run(operation: Operation): Promise<Result | Refusal>;
|
|
22
|
+
}
|
|
23
|
+
/** Explicit selection and host readiness precede factory construction and claim.
|
|
24
|
+
* Native jobs use the installed module helpers; transport-specific host facades
|
|
25
|
+
* may supply factories. Auth, invocation scope and leases stay host-owned.
|
|
26
|
+
*/
|
|
27
|
+
export function createInvoiceServices<Result = unknown>(options: InvoiceServicesOptions & {
|
|
28
|
+
readonly factories?: Partial<Record<Operation, (ports: Ports) => () => Promise<Result>>>;
|
|
29
|
+
readonly jobs?: {
|
|
30
|
+
readonly issue?: Omit<Parameters<typeof runNativeInvoiceIssueJob>[0], "store" | "provider" | "money" | "providerRef">;
|
|
31
|
+
readonly delivery?: { readonly claimOptions: Parameters<NonNullable<InvoiceServicesOptions["store"]>["claimDeliveries"]>[0]; filename(claim: Exclude<Awaited<ReturnType<NonNullable<InvoiceServicesOptions["store"]>["claimDeliveries"]>>, InvoiceUnsupported>[number]): string };
|
|
32
|
+
readonly documentSync?: Parameters<typeof syncAcceptedInvoiceDocuments>[0];
|
|
33
|
+
readonly settlementSync?: Omit<Parameters<typeof syncInvoicePayoutSettlements>[0], "settlement">;
|
|
34
|
+
readonly history?: Parameters<ReturnType<typeof createInvoiceReadModel>["history"]>[0];
|
|
35
|
+
readonly collectDuties?: { readonly limit?: number };
|
|
36
|
+
};
|
|
37
|
+
}): InvoiceServices<Result> {
|
|
38
|
+
const { enabled, selection, capability, provider, store, host, readiness: observe } = options;
|
|
39
|
+
const factories = { ...options.factories };
|
|
40
|
+
const unsupported = { kind: "unsupported", code: "host_binding_required", retryable: false, effectMayHaveSucceeded: false } as const;
|
|
41
|
+
const readiness: InvoiceServices<Result>["readiness"] = async () => {
|
|
42
|
+
const selected = readInvoiceCapabilities({ enabled, selection, capability });
|
|
43
|
+
if (selected.kind !== "ready") return selected;
|
|
44
|
+
if (!provider || !store) return unsupported;
|
|
45
|
+
return observe();
|
|
46
|
+
};
|
|
47
|
+
return {
|
|
48
|
+
readiness,
|
|
49
|
+
async run(operation) {
|
|
50
|
+
const state = await readiness();
|
|
51
|
+
if (state.kind !== "ready") return state;
|
|
52
|
+
const factory = factories[operation];
|
|
53
|
+
const capabilityKey = operation === "documentSync" ? "readback"
|
|
54
|
+
: operation === "settlementSync" ? "settlement" : operation;
|
|
55
|
+
if ((capabilityKey === "issue" || capabilityKey === "correction" || capabilityKey === "readback" || capabilityKey === "settlement")
|
|
56
|
+
&& !capability!.operations[capabilityKey].supported) return unsupported;
|
|
57
|
+
if (factory) return factory({ provider: provider!, store: store!, capability: capability!, host })();
|
|
58
|
+
const jobs = options.jobs;
|
|
59
|
+
// Inputs are explicit host selections. No operation acquires a host run lease.
|
|
60
|
+
if (operation === "collectDuties" && jobs?.collectDuties) return createIndependentInvoiceDutyBinding({ ...jobs.collectDuties, store: store! }).collect() as Promise<Result>;
|
|
61
|
+
// The native claim batch includes issue and correction/readback duties.
|
|
62
|
+
if (operation === "issue" && jobs?.issue && (!capability!.operations.correction.supported || !capability!.operations.readback.supported)) return unsupported;
|
|
63
|
+
if (operation === "issue" && jobs?.issue) return runNativeInvoiceIssueJob({ ...jobs.issue, store: store!, provider: provider!, money: host.money, providerRef: selection!.providerRef }) as Promise<Result>;
|
|
64
|
+
if (operation === "delivery" && jobs?.delivery) return runInvoiceDeliveryJob({
|
|
65
|
+
enabled: true, providerEnabled: true, deliveryEnabled: true, store: store!, ...jobs.delivery,
|
|
66
|
+
download: async claim => {
|
|
67
|
+
const artifact = await host.artifacts.read({ identity: claim.identity, kind: "pdf" });
|
|
68
|
+
if (artifact.kind !== "found") throw new Error("invoicing_artifact_unavailable");
|
|
69
|
+
const result = await host.artifacts.download({ identity: claim.identity, artifactRef: artifact.artifact.ref });
|
|
70
|
+
if (result.kind !== "downloaded") throw new Error("invoicing_artifact_unavailable");
|
|
71
|
+
return result;
|
|
72
|
+
}, send: input => host.delivery.send(input),
|
|
73
|
+
}) as Promise<Result>;
|
|
74
|
+
if (operation === "documentSync" && jobs?.documentSync) return syncAcceptedInvoiceDocuments(jobs.documentSync) as Promise<Result>;
|
|
75
|
+
if (operation === "settlementSync" && jobs?.settlementSync) return syncInvoicePayoutSettlements({ ...jobs.settlementSync, settlement: host.settlement }) as Promise<Result>;
|
|
76
|
+
if (operation === "history" && jobs?.history) return createInvoiceReadModel({ accountHistory: host.accountHistory }).history(jobs.history) as Promise<Result>;
|
|
77
|
+
return unsupported;
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { InvoiceCapabilities, InvoiceCapabilitySelection } from "./contracts/capabilities.js";
|
|
2
|
+
/** @consumer Host readiness / installed cold consumer. Default off and explicit
|
|
3
|
+
* provider selection precede capability use; construction never selects a provider.
|
|
4
|
+
*/
|
|
5
|
+
export function readInvoiceCapabilities(input: { readonly enabled?: boolean; readonly selection: { readonly providerRef: string } | null; readonly capability: InvoiceCapabilities | null }): InvoiceCapabilitySelection {
|
|
6
|
+
if (input.enabled !== true) return { kind: "refused", code: "disabled", retryable: false, effectMayHaveSucceeded: false };
|
|
7
|
+
if (!input.selection || !input.capability || input.selection.providerRef !== input.capability.providerRef) return { kind: "refused", code: "selection_required", retryable: false, effectMayHaveSucceeded: false };
|
|
8
|
+
return { kind: "ready", capability: input.capability };
|
|
9
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
type ChannelInvoiceAction = "issue" | "channel_issues" | "suppress";
|
|
2
|
+
|
|
3
|
+
export type InvoicePolicyLookup =
|
|
4
|
+
| { by: "order"; orderUuid: string }
|
|
5
|
+
| { by: "fulfillmentOrder"; fulfillmentOrderId: string };
|
|
6
|
+
|
|
7
|
+
/** Reads the resolved sales-source invoice action for either producer key.
|
|
8
|
+
* Read failures propagate to the caller.
|
|
9
|
+
*/
|
|
10
|
+
export interface OrderInvoicePolicyReader {
|
|
11
|
+
readOrderInvoicePolicy(lookup: InvoicePolicyLookup): Promise<ChannelInvoiceAction>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ChannelInvoiceDecision {
|
|
15
|
+
issue: boolean;
|
|
16
|
+
action: ChannelInvoiceAction;
|
|
17
|
+
detail: Record<string, unknown>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const ISSUE: ChannelInvoiceDecision = { issue: true, action: "issue", detail: {} };
|
|
21
|
+
|
|
22
|
+
function refusal(action: ChannelInvoiceAction): ChannelInvoiceDecision {
|
|
23
|
+
return {
|
|
24
|
+
issue: false,
|
|
25
|
+
action,
|
|
26
|
+
detail: action === "channel_issues"
|
|
27
|
+
// The far side mints the document. Stamped with the same key an order
|
|
28
|
+
// metadata write would have used, so a later wave that gains a metadata
|
|
29
|
+
// path moves the value rather than renaming it.
|
|
30
|
+
? { invoicePolicy: action, invoiceIssuedBy: "channel", accountingInvoiceSkipped: action }
|
|
31
|
+
// The operator declared that no sales document is produced at all.
|
|
32
|
+
: { invoicePolicy: action, accountingInvoiceSkipped: action },
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Whether this deployment should request the sales document for an order.
|
|
38
|
+
*
|
|
39
|
+
* An ABSENT reader answers `issue`. Composition roots that predate this wave —
|
|
40
|
+
* and every test that builds a producer without one — keep today's behaviour
|
|
41
|
+
* exactly, which is what makes the storefront path provably unchanged.
|
|
42
|
+
*/
|
|
43
|
+
export async function decideChannelInvoice(
|
|
44
|
+
reader: OrderInvoicePolicyReader | undefined,
|
|
45
|
+
lookup: InvoicePolicyLookup,
|
|
46
|
+
): Promise<ChannelInvoiceDecision> {
|
|
47
|
+
if (!reader) return ISSUE;
|
|
48
|
+
const action = await reader.readOrderInvoicePolicy(lookup);
|
|
49
|
+
return action === "issue" ? ISSUE : refusal(action);
|
|
50
|
+
}
|