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 @@
+
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 `
+`;
+}
+
+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");