diff --git a/fx-settlement-invoice-guard/README.md b/fx-settlement-invoice-guard/README.md new file mode 100644 index 00000000..aaa86891 --- /dev/null +++ b/fx-settlement-invoice-guard/README.md @@ -0,0 +1,43 @@ +# FX Settlement Invoice Guard + +Self-contained guard for SCIBASE issue #20, focused on revenue infrastructure. + +The module validates synthetic multi-currency invoice packets before invoice release or revenue close. It catches stale FX snapshots, unsupported currency minor units, settlement mismatches, rounding drift, missing processor references, credit-note offsets, and missing finance approval for material FX variance. + +## Scope + +- invoice currency, account currency, and settlement currency consistency +- FX snapshot age and source metadata +- currency minor-unit rounding +- expected settlement amount vs processor settlement amount +- credit note and refund offset handling +- material FX variance approval requirements +- deterministic release/hold/block decisions + +## Decisions + +- `RELEASE_INVOICE` +- `REVIEW_BEFORE_RELEASE` +- `HOLD_INVOICE` + +## Local Validation + +```bash +npm test +npm run demo +node --check src/index.js +node --check scripts/demo.js +node --check test/fxSettlementInvoiceGuard.test.js +git diff --check +``` + +See `REQUIREMENT_MAP.md` for the issue-to-evidence map and explicit non-scope boundaries. + +## Boundaries + +- synthetic invoice packets only +- no payment processor calls +- no live FX market data +- no bank details +- no credentials +- no private customer data diff --git a/fx-settlement-invoice-guard/REQUIREMENT_MAP.md b/fx-settlement-invoice-guard/REQUIREMENT_MAP.md new file mode 100644 index 00000000..80ae1e78 --- /dev/null +++ b/fx-settlement-invoice-guard/REQUIREMENT_MAP.md @@ -0,0 +1,26 @@ +# Requirement Map + +This module is scoped to invoice-release currency rounding controls for issue #20 Revenue Infrastructure. It is intentionally narrower than broader FX settlement reconciliation work. + +| Issue #20 requirement area | Covered by this module | Evidence | +| --- | --- | --- | +| Tiered subscription and institutional invoices | Validates synthetic invoice packets before release or revenue close | `examples/fx-settlement-packets.json` | +| Usage-based AI compute and top-ups | Includes compute bundle and top-up line items in multi-currency invoices | `examples/fx-settlement-packets.json` | +| Secure payment integrations | Requires processor settlement reference before revenue close | `src/index.js` | +| Monthly/annual cycles and credits | Applies credit notes and requires credit approval evidence | `src/index.js` | +| Transparent usage and finance controls | Emits deterministic release/review/hold findings with remediation | `src/index.js`, `artifacts/fx-settlement-report.md` | +| Reviewer demo evidence | Generates JSON, Markdown, SVG, and MP4 artifacts | `scripts/demo.js`, `artifacts/` | + +## Explicit Non-Scope + +The guard does not implement: + +- provider fee caps +- remittance reference deduplication +- cross-border tax evidence +- booking ledger reconciliation +- payment processor API integration +- live FX market lookup +- bank account or customer payment data handling + +Those are separate revenue infrastructure slices. This PR focuses only on whether a synthetic invoice can be released when currency minor units, FX quote freshness, processor settlement delta tolerance, credit offsets, and material variance approvals are coherent. diff --git a/fx-settlement-invoice-guard/artifacts/fx-settlement-demo.mp4 b/fx-settlement-invoice-guard/artifacts/fx-settlement-demo.mp4 new file mode 100644 index 00000000..f89cff17 Binary files /dev/null and b/fx-settlement-invoice-guard/artifacts/fx-settlement-demo.mp4 differ diff --git a/fx-settlement-invoice-guard/artifacts/fx-settlement-report.md b/fx-settlement-invoice-guard/artifacts/fx-settlement-report.md new file mode 100644 index 00000000..d9c549b4 --- /dev/null +++ b/fx-settlement-invoice-guard/artifacts/fx-settlement-report.md @@ -0,0 +1,43 @@ +# FX Settlement Invoice Guard Report + +Summary: blocks=4, reviews=5, passes=1 + +## inv:lab-pro-eur-042 + +- Account: acct:eu-lab-17 +- Currency: EUR -> USD +- Expected settlement: 2808 +- Decision: RELEASE_INVOICE + +| Severity | Code | Message | Remediation | +| --- | --- | --- | --- | +| pass | fx_settlement_ready | Invoice FX settlement, rounding, credits, and processor references are release-ready. | No remediation required. | + +## inv:consortium-gbp-105 + +- Account: acct:uk-consortium-5 +- Currency: GBP -> USD +- Expected settlement: 1905 +- Decision: REVIEW_BEFORE_RELEASE + +| Severity | Code | Message | Remediation | +| --- | --- | --- | --- | +| review | fx_snapshot_stale | FX snapshot is older than the allowed release window. | Refresh FX snapshot or attach finance approval for stale-rate usage. | +| review | fx_quote_before_invoice_window | FX quote predates invoice issue time beyond the allowed window. | Use an invoice-time quote or document policy approval. | +| review | fx_settlement_variance | Processor settlement differs from expected settlement by -0.02 USD. | Route to finance review or attach rounding-policy evidence. | + +## inv:jp-enterprise-219 + +- Account: acct:jp-enterprise-3 +- Currency: JPY -> USD +- Expected settlement: 6208 +- Decision: HOLD_INVOICE + +| Severity | Code | Message | Remediation | +| --- | --- | --- | --- | +| block | minor_unit_rounding_invalid | grossInvoice 1020000.55 has too many decimals for JPY. | Round grossInvoice to 0 minor units before invoice release. | +| block | minor_unit_rounding_invalid | netInvoice 970000.55 has too many decimals for JPY. | Round netInvoice to 0 minor units before invoice release. | +| review | fx_snapshot_stale | FX snapshot is older than the allowed release window. | Refresh FX snapshot or attach finance approval for stale-rate usage. | +| block | processor_reference_missing | Processor settlement reference is missing. | Attach payment processor settlement id before revenue close. | +| block | material_fx_settlement_variance | Processor settlement differs from expected settlement by -708 USD. | Attach finance approval and block release until variance is resolved. | +| review | credit_offset_approval_missing | Credit notes are applied without a credit approval id. | Attach approved credit memo before invoice release. | diff --git a/fx-settlement-invoice-guard/artifacts/fx-settlement-results.json b/fx-settlement-invoice-guard/artifacts/fx-settlement-results.json new file mode 100644 index 00000000..0c59379d --- /dev/null +++ b/fx-settlement-invoice-guard/artifacts/fx-settlement-results.json @@ -0,0 +1,110 @@ +{ + "generatedAt": "2026-06-13T16:00:38.029Z", + "summary": { + "blocks": 4, + "reviews": 5, + "passes": 1 + }, + "results": [ + { + "invoiceId": "inv:lab-pro-eur-042", + "accountId": "acct:eu-lab-17", + "invoiceCurrency": "EUR", + "settlementCurrency": "USD", + "expectedSettlement": 2808, + "decision": "RELEASE_INVOICE", + "findings": [ + { + "severity": "pass", + "code": "fx_settlement_ready", + "message": "Invoice FX settlement, rounding, credits, and processor references are release-ready.", + "remediation": "No remediation required.", + "ref": "inv:lab-pro-eur-042" + } + ] + }, + { + "invoiceId": "inv:consortium-gbp-105", + "accountId": "acct:uk-consortium-5", + "invoiceCurrency": "GBP", + "settlementCurrency": "USD", + "expectedSettlement": 1905, + "decision": "REVIEW_BEFORE_RELEASE", + "findings": [ + { + "severity": "review", + "code": "fx_snapshot_stale", + "message": "FX snapshot is older than the allowed release window.", + "remediation": "Refresh FX snapshot or attach finance approval for stale-rate usage.", + "ref": "inv:consortium-gbp-105" + }, + { + "severity": "review", + "code": "fx_quote_before_invoice_window", + "message": "FX quote predates invoice issue time beyond the allowed window.", + "remediation": "Use an invoice-time quote or document policy approval.", + "ref": "inv:consortium-gbp-105" + }, + { + "severity": "review", + "code": "fx_settlement_variance", + "message": "Processor settlement differs from expected settlement by -0.02 USD.", + "remediation": "Route to finance review or attach rounding-policy evidence.", + "ref": "inv:consortium-gbp-105" + } + ] + }, + { + "invoiceId": "inv:jp-enterprise-219", + "accountId": "acct:jp-enterprise-3", + "invoiceCurrency": "JPY", + "settlementCurrency": "USD", + "expectedSettlement": 6208, + "decision": "HOLD_INVOICE", + "findings": [ + { + "severity": "block", + "code": "minor_unit_rounding_invalid", + "message": "grossInvoice 1020000.55 has too many decimals for JPY.", + "remediation": "Round grossInvoice to 0 minor units before invoice release.", + "ref": "grossInvoice" + }, + { + "severity": "block", + "code": "minor_unit_rounding_invalid", + "message": "netInvoice 970000.55 has too many decimals for JPY.", + "remediation": "Round netInvoice to 0 minor units before invoice release.", + "ref": "netInvoice" + }, + { + "severity": "review", + "code": "fx_snapshot_stale", + "message": "FX snapshot is older than the allowed release window.", + "remediation": "Refresh FX snapshot or attach finance approval for stale-rate usage.", + "ref": "inv:jp-enterprise-219" + }, + { + "severity": "block", + "code": "processor_reference_missing", + "message": "Processor settlement reference is missing.", + "remediation": "Attach payment processor settlement id before revenue close.", + "ref": "inv:jp-enterprise-219" + }, + { + "severity": "block", + "code": "material_fx_settlement_variance", + "message": "Processor settlement differs from expected settlement by -708 USD.", + "remediation": "Attach finance approval and block release until variance is resolved.", + "ref": "inv:jp-enterprise-219" + }, + { + "severity": "review", + "code": "credit_offset_approval_missing", + "message": "Credit notes are applied without a credit approval id.", + "remediation": "Attach approved credit memo before invoice release.", + "ref": "inv:jp-enterprise-219" + } + ] + } + ] +} \ No newline at end of file diff --git a/fx-settlement-invoice-guard/artifacts/fx-settlement-summary.svg b/fx-settlement-invoice-guard/artifacts/fx-settlement-summary.svg new file mode 100644 index 00000000..5b216b04 --- /dev/null +++ b/fx-settlement-invoice-guard/artifacts/fx-settlement-summary.svg @@ -0,0 +1,20 @@ + + + + FX Settlement Invoice Guard + blocks=4 reviews=5 passes=1 + inv:lab-pro-eur-042 + + RELEASE_INVOICE + inv:consortium-gbp-105 + + REVIEW_BEFORE_RELEASE + inv:jp-enterprise-219 + + HOLD_INVOICE + diff --git a/fx-settlement-invoice-guard/examples/fx-settlement-packets.json b/fx-settlement-invoice-guard/examples/fx-settlement-packets.json new file mode 100644 index 00000000..68c0a70e --- /dev/null +++ b/fx-settlement-invoice-guard/examples/fx-settlement-packets.json @@ -0,0 +1,76 @@ +[ + { + "invoiceId": "inv:lab-pro-eur-042", + "accountId": "acct:eu-lab-17", + "invoiceCurrency": "EUR", + "settlementCurrency": "USD", + "accountCurrency": "EUR", + "invoiceIssuedAt": "2026-06-13T12:00:00Z", + "releaseAt": "2026-06-13T14:00:00Z", + "lineItems": [ + { "description": "Lab Pro annual subscription", "amount": 2400 }, + { "description": "AI compute top-up", "amount": 300 } + ], + "creditNotes": [ + { "creditNoteId": "cred:trial-conversion-9", "amount": 100 } + ], + "creditApprovalId": "approval:credit-881", + "fxSnapshot": { + "source": "treasury-policy-table", + "rateId": "fx:eur-usd:2026-06-13T13", + "rate": 1.08, + "quotedAt": "2026-06-13T13:00:00Z" + }, + "processorReference": "stripe:settle:ok-4242", + "processorSettlementAmount": 2808, + "materialVarianceThreshold": 25 + }, + { + "invoiceId": "inv:consortium-gbp-105", + "accountId": "acct:uk-consortium-5", + "invoiceCurrency": "GBP", + "settlementCurrency": "USD", + "accountCurrency": "GBP", + "invoiceIssuedAt": "2026-06-13T09:30:00Z", + "releaseAt": "2026-06-14T12:00:00Z", + "lineItems": [ + { "description": "Consortium analytics API", "amount": 1250 }, + { "description": "Priority support", "amount": 250 } + ], + "creditNotes": [], + "fxSnapshot": { + "source": "treasury-policy-table", + "rateId": "fx:gbp-usd:2026-06-12T08", + "rate": 1.27, + "quotedAt": "2026-06-12T08:00:00Z" + }, + "processorReference": "stripe:settle:review-105", + "processorSettlementAmount": 1904.98, + "materialVarianceThreshold": 25 + }, + { + "invoiceId": "inv:jp-enterprise-219", + "accountId": "acct:jp-enterprise-3", + "invoiceCurrency": "JPY", + "settlementCurrency": "USD", + "accountCurrency": "JPY", + "invoiceIssuedAt": "2026-06-13T05:00:00Z", + "releaseAt": "2026-06-13T08:00:00Z", + "lineItems": [ + { "description": "Institutional license", "amount": 900000 }, + { "description": "Compute bundle", "amount": 120000.55 } + ], + "creditNotes": [ + { "creditNoteId": "cred:service-credit-44", "amount": 50000 } + ], + "fxSnapshot": { + "source": "treasury-policy-table", + "rateId": "fx:jpy-usd:2026-06-13T06", + "rate": 0.0064, + "quotedAt": "2026-06-13T06:00:00Z" + }, + "processorReference": "", + "processorSettlementAmount": 5500, + "materialVarianceThreshold": 25 + } +] diff --git a/fx-settlement-invoice-guard/package.json b/fx-settlement-invoice-guard/package.json new file mode 100644 index 00000000..01b59df8 --- /dev/null +++ b/fx-settlement-invoice-guard/package.json @@ -0,0 +1,11 @@ +{ + "name": "fx-settlement-invoice-guard", + "version": "1.0.0", + "private": true, + "description": "Synthetic multi-currency invoice settlement guard for revenue infrastructure.", + "type": "commonjs", + "scripts": { + "test": "node test/fxSettlementInvoiceGuard.test.js", + "demo": "node scripts/demo.js" + } +} diff --git a/fx-settlement-invoice-guard/scripts/demo.js b/fx-settlement-invoice-guard/scripts/demo.js new file mode 100644 index 00000000..8d812341 --- /dev/null +++ b/fx-settlement-invoice-guard/scripts/demo.js @@ -0,0 +1,112 @@ +"use strict"; + +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { spawnSync } = require("node:child_process"); +const packets = require("../examples/fx-settlement-packets.json"); +const { evaluateFxSettlementBatch, summarize } = require("../src"); + +const root = path.join(__dirname, ".."); +const artifactDir = path.join(root, "artifacts"); +fs.mkdirSync(artifactDir, { recursive: true }); + +const results = evaluateFxSettlementBatch(packets, { asOf: "2026-06-14T12:00:00Z" }); +const summary = summarize(results); + +fs.writeFileSync( + path.join(artifactDir, "fx-settlement-results.json"), + JSON.stringify({ generatedAt: new Date().toISOString(), summary, results }, null, 2) +); + +function markdownReport() { + const lines = [ + "# FX Settlement Invoice Guard Report", + "", + `Summary: blocks=${summary.blocks}, reviews=${summary.reviews}, passes=${summary.passes}`, + "" + ]; + for (const result of results) { + lines.push(`## ${result.invoiceId}`, ""); + lines.push(`- Account: ${result.accountId}`); + lines.push(`- Currency: ${result.invoiceCurrency} -> ${result.settlementCurrency}`); + lines.push(`- Expected settlement: ${result.expectedSettlement}`); + lines.push(`- Decision: ${result.decision}`, ""); + lines.push("| Severity | Code | Message | Remediation |"); + lines.push("| --- | --- | --- | --- |"); + for (const finding of result.findings) { + lines.push(`| ${finding.severity} | ${finding.code} | ${finding.message} | ${finding.remediation} |`); + } + lines.push(""); + } + return lines.join("\n").trimEnd() + "\n"; +} + +fs.writeFileSync(path.join(artifactDir, "fx-settlement-report.md"), markdownReport()); + +function svg() { + const rows = results.map((result, index) => { + const y = 116 + index * 72; + const color = result.decision === "HOLD_INVOICE" ? "#b91c1c" : result.decision === "REVIEW_BEFORE_RELEASE" ? "#b45309" : "#15803d"; + return [ + ` ${result.invoiceId}`, + ` `, + ` ${result.decision}` + ].join("\n"); + }).join("\n"); + return ` + + + FX Settlement Invoice Guard + blocks=${summary.blocks} reviews=${summary.reviews} passes=${summary.passes} +${rows} + +`; +} + +fs.writeFileSync(path.join(artifactDir, "fx-settlement-summary.svg"), svg()); + +function writePpmFrame(file) { + const width = 640; + const height = 360; + const pixels = []; + for (let y = 0; y < height; y += 1) { + for (let x = 0; x < width; x += 1) { + let color = [248, 250, 252]; + if (x > 70 && x < 570 && y > 80 && y < 128) color = [21, 128, 61]; + if (x > 70 && x < 570 && y > 156 && y < 204) color = [180, 83, 9]; + if (x > 70 && x < 570 && y > 232 && y < 280) color = [185, 28, 28]; + pixels.push(Buffer.from(color)); + } + } + fs.writeFileSync(file, Buffer.concat([Buffer.from(`P6\n${width} ${height}\n255\n`), ...pixels])); +} + +function renderVideo() { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "fx-settlement-")); + const frame = path.join(tempDir, "frame.ppm"); + const output = path.join(artifactDir, "fx-settlement-demo.mp4"); + writePpmFrame(frame); + const result = spawnSync("ffmpeg", [ + "-y", + "-loop", "1", + "-i", frame, + "-t", "4", + "-vf", "scale=640:360,format=yuv420p", + output + ], { stdio: "ignore" }); + if (result.status !== 0) { + fs.writeFileSync(path.join(artifactDir, "fx-settlement-demo.txt"), "ffmpeg unavailable; SVG summary is the visual demo artifact.\n"); + } +} + +renderVideo(); + +console.log("FX settlement invoice guard demo generated"); +console.log(`- decisions: ${results.map((result) => `${result.invoiceId}:${result.decision}`).join(", ")}`); +console.log(`- summary: ${JSON.stringify(summary)}`); diff --git a/fx-settlement-invoice-guard/src/index.js b/fx-settlement-invoice-guard/src/index.js new file mode 100644 index 00000000..46f9f4ec --- /dev/null +++ b/fx-settlement-invoice-guard/src/index.js @@ -0,0 +1,264 @@ +"use strict"; + +const RELEASE = "RELEASE_INVOICE"; +const REVIEW = "REVIEW_BEFORE_RELEASE"; +const HOLD = "HOLD_INVOICE"; + +const MINOR_UNITS = { + USD: 2, + EUR: 2, + GBP: 2, + JPY: 0, + KRW: 0, + CHF: 2, + CAD: 2, + AUD: 2, + SGD: 2 +}; + +function normalizeList(value) { + return Array.isArray(value) ? value : []; +} + +function addFinding(findings, severity, code, message, remediation, ref) { + findings.push({ severity, code, message, remediation, ref }); +} + +function asDate(value, field, findings) { + if (!value) { + addFinding(findings, "block", `${field}_missing`, `${field} is missing.`, `Attach ${field} evidence.`, field); + return null; + } + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + addFinding(findings, "block", `${field}_invalid`, `${field} is invalid: ${value}.`, `Normalize ${field} to ISO-8601.`, field); + return null; + } + return date; +} + +function hoursBetween(older, newer) { + return (newer.getTime() - older.getTime()) / (60 * 60 * 1000); +} + +function roundCurrency(amount, currency) { + const decimals = MINOR_UNITS[currency]; + if (decimals === undefined) return null; + const factor = 10 ** decimals; + return Math.round((amount + Number.EPSILON) * factor) / factor; +} + +function sumLines(lines) { + return normalizeList(lines).reduce((sum, line) => sum + Number(line.amount || 0), 0); +} + +function validateCurrency(currency, field, findings) { + if (!currency || !MINOR_UNITS.hasOwnProperty(currency)) { + addFinding( + findings, + "block", + `${field}_unsupported`, + `${field} is unsupported or missing: ${currency || "missing"}.`, + "Use a supported ISO currency and configured minor unit policy.", + field + ); + return false; + } + return true; +} + +function validateMinorUnits(amount, currency, label, findings) { + const rounded = roundCurrency(amount, currency); + if (rounded === null) return; + if (Math.abs(rounded - amount) > 0.000001) { + addFinding( + findings, + "block", + "minor_unit_rounding_invalid", + `${label} ${amount} has too many decimals for ${currency}.`, + `Round ${label} to ${MINOR_UNITS[currency]} minor units before invoice release.`, + label + ); + } +} + +function evaluateFxSettlementPacket(packet, options = {}) { + if (!packet || typeof packet !== "object") { + throw new TypeError("packet must be an object"); + } + const findings = []; + const asOf = asDate(options.asOf || packet.releaseAt || "2026-06-14T00:00:00Z", "asOf", findings); + const invoiceIssuedAt = asDate(packet.invoiceIssuedAt, "invoiceIssuedAt", findings); + const fxQuotedAt = asDate(packet.fxSnapshot && packet.fxSnapshot.quotedAt, "fxQuotedAt", findings); + + if (!packet.invoiceId || !packet.accountId) { + addFinding( + findings, + "block", + "invoice_identity_missing", + "Invoice id or account id is missing.", + "Attach invoiceId and accountId before revenue release.", + packet.invoiceId || "invoice" + ); + } + + const invoiceCurrencyOk = validateCurrency(packet.invoiceCurrency, "invoiceCurrency", findings); + const settlementCurrencyOk = validateCurrency(packet.settlementCurrency, "settlementCurrency", findings); + validateCurrency(packet.accountCurrency, "accountCurrency", findings); + + const grossInvoice = sumLines(packet.lineItems); + const credits = sumLines(packet.creditNotes); + const netInvoice = grossInvoice - credits; + + if (invoiceCurrencyOk) { + validateMinorUnits(grossInvoice, packet.invoiceCurrency, "grossInvoice", findings); + validateMinorUnits(credits, packet.invoiceCurrency, "creditNotes", findings); + validateMinorUnits(netInvoice, packet.invoiceCurrency, "netInvoice", findings); + } + + if (netInvoice < 0) { + addFinding( + findings, + "block", + "negative_net_invoice", + `Net invoice is negative after credits: ${netInvoice}.`, + "Hold invoice and issue a refund/credit memo workflow instead.", + packet.invoiceId + ); + } + + const fx = packet.fxSnapshot || {}; + if (!fx.source || !fx.rateId || typeof fx.rate !== "number" || fx.rate <= 0) { + addFinding( + findings, + "block", + "fx_snapshot_incomplete", + "FX snapshot is missing source, rate id, or positive rate.", + "Attach immutable FX source, rate id, quotedAt, and positive rate.", + packet.invoiceId + ); + } + + if (asOf && fxQuotedAt && hoursBetween(fxQuotedAt, asOf) > (options.maxFxAgeHours || 24)) { + addFinding( + findings, + "review", + "fx_snapshot_stale", + "FX snapshot is older than the allowed release window.", + "Refresh FX snapshot or attach finance approval for stale-rate usage.", + packet.invoiceId + ); + } + + if (invoiceIssuedAt && fxQuotedAt && fxQuotedAt < invoiceIssuedAt && hoursBetween(fxQuotedAt, invoiceIssuedAt) > 2) { + addFinding( + findings, + "review", + "fx_quote_before_invoice_window", + "FX quote predates invoice issue time beyond the allowed window.", + "Use an invoice-time quote or document policy approval.", + packet.invoiceId + ); + } + + let expectedSettlement = null; + if (settlementCurrencyOk && typeof fx.rate === "number" && fx.rate > 0) { + expectedSettlement = roundCurrency(netInvoice * fx.rate, packet.settlementCurrency); + validateMinorUnits(packet.processorSettlementAmount, packet.settlementCurrency, "processorSettlementAmount", findings); + } + + if (!packet.processorReference) { + addFinding( + findings, + "block", + "processor_reference_missing", + "Processor settlement reference is missing.", + "Attach payment processor settlement id before revenue close.", + packet.invoiceId + ); + } + + if (expectedSettlement !== null) { + const delta = roundCurrency(Number(packet.processorSettlementAmount || 0) - expectedSettlement, packet.settlementCurrency); + const absoluteDelta = Math.abs(delta); + const tolerance = options.settlementTolerance ?? (MINOR_UNITS[packet.settlementCurrency] === 0 ? 1 : 0.01); + + if (absoluteDelta > tolerance) { + const material = absoluteDelta >= (packet.materialVarianceThreshold || 25); + addFinding( + findings, + material ? "block" : "review", + material ? "material_fx_settlement_variance" : "fx_settlement_variance", + `Processor settlement differs from expected settlement by ${delta} ${packet.settlementCurrency}.`, + material + ? "Attach finance approval and block release until variance is resolved." + : "Route to finance review or attach rounding-policy evidence.", + packet.invoiceId + ); + } + } + + if (normalizeList(packet.creditNotes).length > 0 && !packet.creditApprovalId) { + addFinding( + findings, + "review", + "credit_offset_approval_missing", + "Credit notes are applied without a credit approval id.", + "Attach approved credit memo before invoice release.", + packet.invoiceId + ); + } + + if (findings.length === 0) { + addFinding( + findings, + "pass", + "fx_settlement_ready", + "Invoice FX settlement, rounding, credits, and processor references are release-ready.", + "No remediation required.", + packet.invoiceId + ); + } + + return { + invoiceId: packet.invoiceId, + accountId: packet.accountId, + invoiceCurrency: packet.invoiceCurrency, + settlementCurrency: packet.settlementCurrency, + expectedSettlement, + decision: decide(findings), + findings + }; +} + +function decide(findings) { + if (findings.some((finding) => finding.severity === "block")) return HOLD; + if (findings.some((finding) => finding.severity === "review")) return REVIEW; + return RELEASE; +} + +function evaluateFxSettlementBatch(packets, options = {}) { + return normalizeList(packets).map((packet) => evaluateFxSettlementPacket(packet, options)); +} + +function summarize(results) { + const summary = { blocks: 0, reviews: 0, passes: 0 }; + for (const result of normalizeList(results)) { + for (const finding of result.findings) { + if (finding.severity === "block") summary.blocks += 1; + if (finding.severity === "review") summary.reviews += 1; + if (finding.severity === "pass") summary.passes += 1; + } + } + return summary; +} + +module.exports = { + RELEASE, + REVIEW, + HOLD, + evaluateFxSettlementPacket, + evaluateFxSettlementBatch, + summarize, + roundCurrency +}; diff --git a/fx-settlement-invoice-guard/test/fxSettlementInvoiceGuard.test.js b/fx-settlement-invoice-guard/test/fxSettlementInvoiceGuard.test.js new file mode 100644 index 00000000..a6336a02 --- /dev/null +++ b/fx-settlement-invoice-guard/test/fxSettlementInvoiceGuard.test.js @@ -0,0 +1,63 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const packets = require("../examples/fx-settlement-packets.json"); +const { + RELEASE, + REVIEW, + HOLD, + evaluateFxSettlementPacket, + evaluateFxSettlementBatch, + summarize, + roundCurrency +} = require("../src"); + +assert.equal(roundCurrency(12.345, "USD"), 12.35); +assert.equal(roundCurrency(123.6, "JPY"), 124); + +const results = evaluateFxSettlementBatch(packets, { asOf: "2026-06-14T12:00:00Z" }); + +assert.equal(results[0].decision, RELEASE); +assert.equal(results[0].expectedSettlement, 2808); +assert.deepEqual(results[0].findings.map((finding) => finding.code), ["fx_settlement_ready"]); + +assert.equal(results[1].decision, REVIEW); +assert(results[1].findings.some((finding) => finding.code === "fx_snapshot_stale")); +assert(results[1].findings.some((finding) => finding.code === "fx_quote_before_invoice_window")); +assert(results[1].findings.some((finding) => finding.code === "fx_settlement_variance")); + +assert.equal(results[2].decision, HOLD); +assert(results[2].findings.some((finding) => finding.code === "minor_unit_rounding_invalid")); +assert(results[2].findings.some((finding) => finding.code === "processor_reference_missing")); +assert(results[2].findings.some((finding) => finding.code === "material_fx_settlement_variance")); +assert(results[2].findings.some((finding) => finding.code === "credit_offset_approval_missing")); + +const unsupported = evaluateFxSettlementPacket({ + invoiceId: "inv:unsupported", + accountId: "acct:test", + invoiceCurrency: "BTC", + settlementCurrency: "USD", + accountCurrency: "BTC", + invoiceIssuedAt: "2026-06-13T00:00:00Z", + releaseAt: "2026-06-13T01:00:00Z", + lineItems: [{ description: "Unsupported currency invoice", amount: 1 }], + fxSnapshot: { + source: "test", + rateId: "fx:test", + rate: 100, + quotedAt: "2026-06-13T00:30:00Z" + }, + processorReference: "processor:test", + processorSettlementAmount: 100 +}); + +assert.equal(unsupported.decision, HOLD); +assert(unsupported.findings.some((finding) => finding.code === "invoiceCurrency_unsupported")); +assert(unsupported.findings.some((finding) => finding.code === "accountCurrency_unsupported")); + +const summary = summarize(results); +assert.equal(summary.blocks, 4); +assert.equal(summary.reviews, 5); +assert.equal(summary.passes, 1); + +console.log("fx settlement invoice guard tests passed");