diff --git a/payment-dispute-evidence-guard/README.md b/payment-dispute-evidence-guard/README.md
new file mode 100644
index 00000000..52f0a61f
--- /dev/null
+++ b/payment-dispute-evidence-guard/README.md
@@ -0,0 +1,53 @@
+# Payment Dispute Evidence Guard
+
+This module adds a dependency-free revenue-control slice for issue #20. It evaluates whether synthetic processor dispute packets are ready to leave finance before revenue is recognized, released, or defended through a payment processor response.
+
+The guard is intentionally offline: it uses no credentials, no payment processor API, no bank data, no real customer records, and no external network calls.
+
+## What It Checks
+
+- Processor response deadline and urgent response windows.
+- Required evidence completeness: contract, invoice, delivery proof, usage logs, terms acceptance, customer communication, and redacted receipt.
+- Prior refunds and credit memos that already offset a disputed amount.
+- Duplicate processor case IDs across exported response packets.
+- Customer notification timing after dispute receipt.
+- Sensitive receipt leakage such as full payment card numbers.
+- Finance-ready decisions: `RELEASE_RESPONSE`, `REVIEW_BEFORE_RESPONSE`, and `HOLD_RESPONSE`.
+
+## Requirement Map
+
+| Issue #20 revenue requirement | Coverage in this module |
+| --- | --- |
+| Secure payment integrations | Validates dispute packets before processor response export without calling live processors. |
+| Billing engine controls | Compares dispute amounts, recognized revenue, refunds, and credit memos before release. |
+| Institutional invoicing | Links account, invoice, and customer notice evidence for finance review. |
+| Audit-ready operations | Emits JSON and Markdown reviewer packets with findings and remediation steps. |
+| Predictable recurring revenue | Holds risky dispute packets before duplicate recovery, missed windows, or unredacted evidence distort revenue. |
+
+## Files
+
+- `src/disputeGuard.mjs` - deterministic decision engine.
+- `data/dispute-packets.json` - synthetic reviewer packets.
+- `test/disputeGuard.test.mjs` - Node test coverage.
+- `scripts/run-demo.mjs` - generates reviewer artifacts.
+- `scripts/render-demo-video.mjs` - renders the SVG demo into a short WebM review video.
+- `artifacts/` - generated JSON, Markdown, and SVG outputs after running the demo.
+
+## Validation
+
+```bash
+npm test
+npm run demo
+npm run demo:video
+```
+
+The demo writes:
+
+- `artifacts/dispute-review-report.json`
+- `artifacts/dispute-review-report.md`
+- `artifacts/dispute-review-demo.svg`
+- `artifacts/dispute-review-demo.webm`
+
+## Review Notes
+
+This is a distinct slice from subscription pricing, usage metering, grant milestones, FX settlement, tax/VAT, event sponsorship, and cost-center allocation work. It focuses only on payment-processor dispute evidence readiness and revenue-release risk.
diff --git a/payment-dispute-evidence-guard/artifacts/dispute-review-demo.svg b/payment-dispute-evidence-guard/artifacts/dispute-review-demo.svg
new file mode 100644
index 00000000..0679be24
--- /dev/null
+++ b/payment-dispute-evidence-guard/artifacts/dispute-review-demo.svg
@@ -0,0 +1,43 @@
+
diff --git a/payment-dispute-evidence-guard/artifacts/dispute-review-demo.webm b/payment-dispute-evidence-guard/artifacts/dispute-review-demo.webm
new file mode 100644
index 00000000..601a8091
Binary files /dev/null and b/payment-dispute-evidence-guard/artifacts/dispute-review-demo.webm differ
diff --git a/payment-dispute-evidence-guard/artifacts/dispute-review-report.json b/payment-dispute-evidence-guard/artifacts/dispute-review-report.json
new file mode 100644
index 00000000..e6558030
--- /dev/null
+++ b/payment-dispute-evidence-guard/artifacts/dispute-review-report.json
@@ -0,0 +1,127 @@
+{
+ "generatedAt": "2026-06-27T08:00:00Z",
+ "summary": {
+ "totalPackets": 4,
+ "totalNetAmountAtRiskCents": 580000,
+ "decisions": {
+ "RELEASE_RESPONSE": 1,
+ "REVIEW_BEFORE_RESPONSE": 1,
+ "HOLD_RESPONSE": 2
+ },
+ "findings": 10,
+ "overallDecision": "HOLD_RESPONSE"
+ },
+ "results": [
+ {
+ "id": "DSP-1001",
+ "accountId": "lab-north-42",
+ "invoiceId": "INV-2026-0418",
+ "processorCaseId": "chdp_91a7_release",
+ "netAmountAtRiskCents": 120000,
+ "hoursUntilDue": 74,
+ "findingCount": 0,
+ "highestSeverity": "info",
+ "decision": "RELEASE_RESPONSE",
+ "findings": []
+ },
+ {
+ "id": "DSP-1002",
+ "accountId": "quantum-cohort-8",
+ "invoiceId": "INV-2026-0442",
+ "processorCaseId": "chdp_91a7_hold",
+ "netAmountAtRiskCents": 260000,
+ "hoursUntilDue": -41,
+ "findingCount": 5,
+ "highestSeverity": "critical",
+ "decision": "HOLD_RESPONSE",
+ "findings": [
+ {
+ "severity": "critical",
+ "code": "RESPONSE_WINDOW_EXPIRED",
+ "message": "The processor response deadline has already passed.",
+ "remediation": "Hold revenue release and escalate to finance counsel before submitting a late dispute packet."
+ },
+ {
+ "severity": "critical",
+ "code": "MISSING_DISPUTE_EVIDENCE",
+ "message": "Missing dispute evidence: deliveryProof, usageLogs, customerCommunication, redactedReceipt.",
+ "remediation": "Attach the missing evidence before any dispute response leaves the revenue workflow."
+ },
+ {
+ "severity": "critical",
+ "code": "SENSITIVE_RECEIPT_DATA",
+ "message": "Receipt evidence appears to contain a full payment card number.",
+ "remediation": "Redact card, bank, and token data before exporting the response packet."
+ },
+ {
+ "severity": "warning",
+ "code": "CUSTOMER_NOTICE_MISSING",
+ "message": "No customer notification timestamp is recorded for this dispute.",
+ "remediation": "Notify the billing contact or document why notice is blocked before release."
+ },
+ {
+ "severity": "critical",
+ "code": "DUPLICATE_PROCESSOR_CASE",
+ "message": "Processor case chdp_91a7_hold appears in more than one packet.",
+ "remediation": "Merge or split the evidence packets before any response is submitted."
+ }
+ ]
+ },
+ {
+ "id": "DSP-1003",
+ "accountId": "bio-modeling-lab",
+ "invoiceId": "INV-2026-0461",
+ "processorCaseId": "chdp_91a7_credit",
+ "netAmountAtRiskCents": 0,
+ "hoursUntilDue": 124,
+ "findingCount": 1,
+ "highestSeverity": "warning",
+ "decision": "REVIEW_BEFORE_RESPONSE",
+ "findings": [
+ {
+ "severity": "warning",
+ "code": "DISPUTE_ALREADY_OFFSET",
+ "message": "Prior refunds or credit memos fully offset the disputed amount.",
+ "remediation": "Avoid duplicate recovery. Link the refund and credit memo evidence before responding."
+ }
+ ]
+ },
+ {
+ "id": "DSP-1004",
+ "accountId": "materials-ai-consortium",
+ "invoiceId": "INV-2026-0490",
+ "processorCaseId": "chdp_91a7_hold",
+ "netAmountAtRiskCents": 200000,
+ "hoursUntilDue": 34,
+ "findingCount": 4,
+ "highestSeverity": "critical",
+ "decision": "HOLD_RESPONSE",
+ "findings": [
+ {
+ "severity": "warning",
+ "code": "RESPONSE_WINDOW_URGENT",
+ "message": "The processor response deadline is within 34 hours.",
+ "remediation": "Route to a reviewer before release and confirm the packet can be submitted before cutoff."
+ },
+ {
+ "severity": "warning",
+ "code": "CUSTOMER_NOTICE_LATE",
+ "message": "Customer notice was recorded after 73 hours.",
+ "remediation": "Have finance confirm late notice is acceptable for this account and region."
+ },
+ {
+ "severity": "warning",
+ "code": "RECOGNIZED_REVENUE_EXCEEDS_RISK",
+ "message": "Recognized revenue is higher than the current net disputed amount.",
+ "remediation": "Confirm whether deferred revenue or a reserve adjustment is needed before release."
+ },
+ {
+ "severity": "critical",
+ "code": "DUPLICATE_PROCESSOR_CASE",
+ "message": "Processor case chdp_91a7_hold appears in more than one packet.",
+ "remediation": "Merge or split the evidence packets before any response is submitted."
+ }
+ ]
+ }
+ ]
+}
diff --git a/payment-dispute-evidence-guard/artifacts/dispute-review-report.md b/payment-dispute-evidence-guard/artifacts/dispute-review-report.md
new file mode 100644
index 00000000..01bea1ed
--- /dev/null
+++ b/payment-dispute-evidence-guard/artifacts/dispute-review-report.md
@@ -0,0 +1,31 @@
+# Payment Dispute Evidence Guard Demo
+
+Generated at: 2026-06-27T08:00:00Z
+
+## Summary
+
+- Total packets: 4
+- Net amount at risk: $5800.00
+- Overall decision: HOLD_RESPONSE
+- Release: 1
+- Review before response: 1
+- Hold response: 2
+- Findings: 10
+
+## Packet Decisions
+
+| Packet | Invoice | Processor case | Net risk | Decision | Findings |
+| --- | --- | --- | ---: | --- | --- |
+| DSP-1001 | INV-2026-0418 | chdp_91a7_release | $1200.00 | RELEASE_RESPONSE | none |
+| DSP-1002 | INV-2026-0442 | chdp_91a7_hold | $2600.00 | HOLD_RESPONSE | RESPONSE_WINDOW_EXPIRED, MISSING_DISPUTE_EVIDENCE, SENSITIVE_RECEIPT_DATA, CUSTOMER_NOTICE_MISSING, DUPLICATE_PROCESSOR_CASE |
+| DSP-1003 | INV-2026-0461 | chdp_91a7_credit | $0.00 | REVIEW_BEFORE_RESPONSE | DISPUTE_ALREADY_OFFSET |
+| DSP-1004 | INV-2026-0490 | chdp_91a7_hold | $2000.00 | HOLD_RESPONSE | RESPONSE_WINDOW_URGENT, CUSTOMER_NOTICE_LATE, RECOGNIZED_REVENUE_EXCEEDS_RISK, DUPLICATE_PROCESSOR_CASE |
+
+## Controls Covered
+
+- Processor response deadline and urgency checks.
+- Required evidence completeness for contract, invoice, delivery, usage, terms, communication, and redacted receipt.
+- Prior refund and credit memo offsets to prevent duplicate recovery.
+- Duplicate processor case detection across exported packets.
+- Customer notice timing before response release.
+- Sensitive receipt data redaction before finance exports.
diff --git a/payment-dispute-evidence-guard/data/dispute-packets.json b/payment-dispute-evidence-guard/data/dispute-packets.json
new file mode 100644
index 00000000..d5baf11d
--- /dev/null
+++ b/payment-dispute-evidence-guard/data/dispute-packets.json
@@ -0,0 +1,102 @@
+[
+ {
+ "id": "DSP-1001",
+ "accountId": "lab-north-42",
+ "invoiceId": "INV-2026-0418",
+ "processorCaseId": "chdp_91a7_release",
+ "processorStatus": "needs_response",
+ "invoiceAmountCents": 480000,
+ "disputedAmountCents": 120000,
+ "revenueRecognizedCents": 120000,
+ "priorRefundCents": 0,
+ "creditMemoCents": 0,
+ "disputeReceivedAt": "2026-06-23T10:00:00Z",
+ "responseDueAt": "2026-06-30T10:00:00Z",
+ "customerNotifiedAt": "2026-06-24T09:00:00Z",
+ "evidence": {
+ "contract": true,
+ "invoice": true,
+ "deliveryProof": true,
+ "usageLogs": true,
+ "termsAcceptance": true,
+ "customerCommunication": true,
+ "redactedReceipt": true
+ },
+ "receiptText": "Receipt INV-2026-0418 paid by card ending 4242. Sponsor contact: billing@example.edu."
+ },
+ {
+ "id": "DSP-1002",
+ "accountId": "quantum-cohort-8",
+ "invoiceId": "INV-2026-0442",
+ "processorCaseId": "chdp_91a7_hold",
+ "processorStatus": "needs_response",
+ "invoiceAmountCents": 740000,
+ "disputedAmountCents": 260000,
+ "revenueRecognizedCents": 260000,
+ "priorRefundCents": 0,
+ "creditMemoCents": 0,
+ "disputeReceivedAt": "2026-06-19T15:00:00Z",
+ "responseDueAt": "2026-06-25T15:00:00Z",
+ "customerNotifiedAt": null,
+ "evidence": {
+ "contract": true,
+ "invoice": true,
+ "deliveryProof": false,
+ "usageLogs": false,
+ "termsAcceptance": true,
+ "customerCommunication": false,
+ "redactedReceipt": false
+ },
+ "receiptText": "Raw receipt contains card 4111111111111111 and should be redacted before any dispute packet leaves finance."
+ },
+ {
+ "id": "DSP-1003",
+ "accountId": "bio-modeling-lab",
+ "invoiceId": "INV-2026-0461",
+ "processorCaseId": "chdp_91a7_credit",
+ "processorStatus": "needs_response",
+ "invoiceAmountCents": 300000,
+ "disputedAmountCents": 90000,
+ "revenueRecognizedCents": 90000,
+ "priorRefundCents": 50000,
+ "creditMemoCents": 50000,
+ "disputeReceivedAt": "2026-06-24T12:00:00Z",
+ "responseDueAt": "2026-07-02T12:00:00Z",
+ "customerNotifiedAt": "2026-06-26T12:00:00Z",
+ "evidence": {
+ "contract": true,
+ "invoice": true,
+ "deliveryProof": true,
+ "usageLogs": true,
+ "termsAcceptance": true,
+ "customerCommunication": true,
+ "redactedReceipt": true
+ },
+ "receiptText": "Credit memo CM-2026-0091 and refund RF-2026-0024 already offset this disputed amount."
+ },
+ {
+ "id": "DSP-1004",
+ "accountId": "materials-ai-consortium",
+ "invoiceId": "INV-2026-0490",
+ "processorCaseId": "chdp_91a7_hold",
+ "processorStatus": "needs_response",
+ "invoiceAmountCents": 620000,
+ "disputedAmountCents": 200000,
+ "revenueRecognizedCents": 250000,
+ "priorRefundCents": 0,
+ "creditMemoCents": 0,
+ "disputeReceivedAt": "2026-06-24T18:00:00Z",
+ "responseDueAt": "2026-06-28T18:00:00Z",
+ "customerNotifiedAt": "2026-06-27T18:30:00Z",
+ "evidence": {
+ "contract": true,
+ "invoice": true,
+ "deliveryProof": true,
+ "usageLogs": true,
+ "termsAcceptance": true,
+ "customerCommunication": true,
+ "redactedReceipt": true
+ },
+ "receiptText": "Dispute packet references sanitized invoice and usage evidence only."
+ }
+]
diff --git a/payment-dispute-evidence-guard/package.json b/payment-dispute-evidence-guard/package.json
new file mode 100644
index 00000000..5a9603c4
--- /dev/null
+++ b/payment-dispute-evidence-guard/package.json
@@ -0,0 +1,11 @@
+{
+ "name": "payment-dispute-evidence-guard",
+ "version": "1.0.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "demo": "node scripts/run-demo.mjs",
+ "demo:video": "node scripts/render-demo-video.mjs",
+ "test": "node --test test/*.test.mjs"
+ }
+}
diff --git a/payment-dispute-evidence-guard/scripts/render-demo-video.mjs b/payment-dispute-evidence-guard/scripts/render-demo-video.mjs
new file mode 100644
index 00000000..7c788386
--- /dev/null
+++ b/payment-dispute-evidence-guard/scripts/render-demo-video.mjs
@@ -0,0 +1,68 @@
+import { access, mkdir } from 'node:fs/promises';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { spawn } from 'node:child_process';
+
+const here = dirname(fileURLToPath(import.meta.url));
+const root = join(here, '..');
+const artifactDir = join(root, 'artifacts');
+const svgPath = join(artifactDir, 'dispute-review-demo.svg');
+const pngPath = join(artifactDir, 'dispute-review-demo.png');
+const videoPath = join(artifactDir, 'dispute-review-demo.webm');
+const fontCandidates = [
+ process.env.DEMO_FONT,
+ '/System/Library/Fonts/Supplemental/Arial.ttf',
+ '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',
+ '/usr/share/fonts/truetype/liberation2/LiberationSans-Regular.ttf',
+ '/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc',
+].filter(Boolean);
+
+await mkdir(artifactDir, { recursive: true });
+await access(svgPath).catch(() => {
+ throw new Error('Run `npm run demo` before `npm run demo:video`.');
+});
+
+const fontArgs = await findFont();
+await run('magick', [...fontArgs, svgPath, pngPath]);
+await run('ffmpeg', [
+ '-y',
+ '-loop',
+ '1',
+ '-t',
+ '5',
+ '-i',
+ pngPath,
+ '-c:v',
+ 'libvpx-vp9',
+ '-pix_fmt',
+ 'yuv420p',
+ videoPath,
+]);
+
+console.log(`Generated demo video: ${videoPath}`);
+
+function run(command, args) {
+ return new Promise((resolve, reject) => {
+ const child = spawn(command, args, { stdio: 'inherit' });
+ child.on('error', reject);
+ child.on('close', (code) => {
+ if (code === 0) {
+ resolve();
+ } else {
+ reject(new Error(`${command} exited with code ${code}`));
+ }
+ });
+ });
+}
+
+async function findFont() {
+ for (const candidate of fontCandidates) {
+ try {
+ await access(candidate);
+ return ['-font', candidate];
+ } catch {
+ // Try the next platform-specific font path.
+ }
+ }
+ return [];
+}
diff --git a/payment-dispute-evidence-guard/scripts/run-demo.mjs b/payment-dispute-evidence-guard/scripts/run-demo.mjs
new file mode 100644
index 00000000..07e40519
--- /dev/null
+++ b/payment-dispute-evidence-guard/scripts/run-demo.mjs
@@ -0,0 +1,118 @@
+import { mkdir, readFile, writeFile } from 'node:fs/promises';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { evaluateDisputeBatch } from '../src/disputeGuard.mjs';
+
+const here = dirname(fileURLToPath(import.meta.url));
+const root = join(here, '..');
+const packets = JSON.parse(await readFile(join(root, 'data', 'dispute-packets.json'), 'utf8'));
+const report = evaluateDisputeBatch(packets, { now: '2026-06-27T08:00:00Z' });
+const artifactDir = join(root, 'artifacts');
+
+await mkdir(artifactDir, { recursive: true });
+await writeFile(
+ join(artifactDir, 'dispute-review-report.json'),
+ `${JSON.stringify(report, null, 2)}\n`,
+);
+await writeFile(join(artifactDir, 'dispute-review-report.md'), renderMarkdown(report));
+await writeFile(join(artifactDir, 'dispute-review-demo.svg'), renderSvg(report));
+
+console.log(`Generated ${report.summary.totalPackets} dispute packet decisions.`);
+console.log(`Overall decision: ${report.summary.overallDecision}`);
+console.log(`Artifacts: ${artifactDir}`);
+
+function renderMarkdown(report) {
+ const rows = report.results.map((result) => {
+ return [
+ result.id,
+ result.invoiceId,
+ result.processorCaseId,
+ cents(result.netAmountAtRiskCents),
+ result.decision,
+ result.findings.map((finding) => finding.code).join(', ') || 'none',
+ ];
+ });
+
+ return `# Payment Dispute Evidence Guard Demo
+
+Generated at: ${report.generatedAt}
+
+## Summary
+
+- Total packets: ${report.summary.totalPackets}
+- Net amount at risk: ${cents(report.summary.totalNetAmountAtRiskCents)}
+- Overall decision: ${report.summary.overallDecision}
+- Release: ${report.summary.decisions.RELEASE_RESPONSE}
+- Review before response: ${report.summary.decisions.REVIEW_BEFORE_RESPONSE}
+- Hold response: ${report.summary.decisions.HOLD_RESPONSE}
+- Findings: ${report.summary.findings}
+
+## Packet Decisions
+
+| Packet | Invoice | Processor case | Net risk | Decision | Findings |
+| --- | --- | --- | ---: | --- | --- |
+${rows.map((row) => `| ${row.join(' | ')} |`).join('\n')}
+
+## Controls Covered
+
+- Processor response deadline and urgency checks.
+- Required evidence completeness for contract, invoice, delivery, usage, terms, communication, and redacted receipt.
+- Prior refund and credit memo offsets to prevent duplicate recovery.
+- Duplicate processor case detection across exported packets.
+- Customer notice timing before response release.
+- Sensitive receipt data redaction before finance exports.
+`;
+}
+
+function renderSvg(report) {
+ const width = 960;
+ const height = 520;
+ const cardHeight = 72;
+ const startY = 150;
+ const colors = {
+ RELEASE_RESPONSE: '#1f8a5b',
+ REVIEW_BEFORE_RESPONSE: '#b7791f',
+ HOLD_RESPONSE: '#b42318',
+ };
+ const cards = report.results.map((result, index) => {
+ const y = startY + index * (cardHeight + 16);
+ const color = colors[result.decision];
+ return `
+
+
+ ${escapeXml(result.id)} - ${escapeXml(result.invoiceId)}
+ ${escapeXml(result.processorCaseId)} | net risk ${escapeXml(cents(result.netAmountAtRiskCents))} | findings ${result.findingCount}
+ ${escapeXml(result.decision)}
+`;
+ }).join('\n');
+
+ return `
+`;
+}
+
+function cents(value) {
+ return `$${(value / 100).toFixed(2)}`;
+}
+
+function escapeXml(value) {
+ return String(value)
+ .replaceAll('&', '&')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>')
+ .replaceAll('"', '"');
+}
diff --git a/payment-dispute-evidence-guard/src/disputeGuard.mjs b/payment-dispute-evidence-guard/src/disputeGuard.mjs
new file mode 100644
index 00000000..d2121736
--- /dev/null
+++ b/payment-dispute-evidence-guard/src/disputeGuard.mjs
@@ -0,0 +1,232 @@
+const REQUIRED_EVIDENCE = [
+ 'contract',
+ 'invoice',
+ 'deliveryProof',
+ 'usageLogs',
+ 'termsAcceptance',
+ 'customerCommunication',
+ 'redactedReceipt',
+];
+
+const DECISION_RANK = {
+ RELEASE_RESPONSE: 0,
+ REVIEW_BEFORE_RESPONSE: 1,
+ HOLD_RESPONSE: 2,
+};
+
+const SEVERITY_RANK = {
+ info: 0,
+ warning: 1,
+ critical: 2,
+};
+
+const DEFAULT_NOW = '2026-06-27T08:00:00Z';
+const HOUR_MS = 60 * 60 * 1000;
+const RESPONSE_URGENCY_HOURS = 48;
+const CUSTOMER_NOTICE_HOURS = 72;
+
+function parseDate(value, fieldName) {
+ if (!value) {
+ throw new Error(`Missing required date field: ${fieldName}`);
+ }
+
+ const parsed = new Date(value);
+ if (Number.isNaN(parsed.getTime())) {
+ throw new Error(`Invalid date field ${fieldName}: ${value}`);
+ }
+
+ return parsed;
+}
+
+function money(cents) {
+ return Number.isFinite(cents) ? cents : 0;
+}
+
+function hasFullCardNumber(text = '') {
+ const compact = String(text).replace(/[ -]/g, '');
+ return /\d{13,19}/.test(compact);
+}
+
+function addFinding(findings, severity, code, message, remediation) {
+ findings.push({ severity, code, message, remediation });
+}
+
+function strongestAction(findings) {
+ if (findings.some((finding) => finding.severity === 'critical')) {
+ return 'HOLD_RESPONSE';
+ }
+
+ if (findings.some((finding) => finding.severity === 'warning')) {
+ return 'REVIEW_BEFORE_RESPONSE';
+ }
+
+ return 'RELEASE_RESPONSE';
+}
+
+function strongestFindingSeverity(findings) {
+ return findings.reduce((highest, finding) => {
+ return SEVERITY_RANK[finding.severity] > SEVERITY_RANK[highest]
+ ? finding.severity
+ : highest;
+ }, 'info');
+}
+
+export function evaluateDisputePacket(packet, options = {}) {
+ const now = parseDate(options.now ?? DEFAULT_NOW, 'now');
+ const findings = [];
+ const responseDueAt = parseDate(packet.responseDueAt, 'responseDueAt');
+ const disputeReceivedAt = parseDate(packet.disputeReceivedAt, 'disputeReceivedAt');
+ const hoursUntilDue = (responseDueAt.getTime() - now.getTime()) / HOUR_MS;
+ const netAmountAtRiskCents = Math.max(
+ 0,
+ money(packet.disputedAmountCents) - money(packet.priorRefundCents) - money(packet.creditMemoCents),
+ );
+
+ if (responseDueAt < now) {
+ addFinding(
+ findings,
+ 'critical',
+ 'RESPONSE_WINDOW_EXPIRED',
+ 'The processor response deadline has already passed.',
+ 'Hold revenue release and escalate to finance counsel before submitting a late dispute packet.',
+ );
+ } else if (hoursUntilDue <= RESPONSE_URGENCY_HOURS) {
+ addFinding(
+ findings,
+ 'warning',
+ 'RESPONSE_WINDOW_URGENT',
+ `The processor response deadline is within ${Math.ceil(hoursUntilDue)} hours.`,
+ 'Route to a reviewer before release and confirm the packet can be submitted before cutoff.',
+ );
+ }
+
+ const missingEvidence = REQUIRED_EVIDENCE.filter((key) => packet.evidence?.[key] !== true);
+ if (missingEvidence.length > 0) {
+ addFinding(
+ findings,
+ 'critical',
+ 'MISSING_DISPUTE_EVIDENCE',
+ `Missing dispute evidence: ${missingEvidence.join(', ')}.`,
+ 'Attach the missing evidence before any dispute response leaves the revenue workflow.',
+ );
+ }
+
+ if (hasFullCardNumber(packet.receiptText)) {
+ addFinding(
+ findings,
+ 'critical',
+ 'SENSITIVE_RECEIPT_DATA',
+ 'Receipt evidence appears to contain a full payment card number.',
+ 'Redact card, bank, and token data before exporting the response packet.',
+ );
+ }
+
+ if (!packet.customerNotifiedAt) {
+ addFinding(
+ findings,
+ 'warning',
+ 'CUSTOMER_NOTICE_MISSING',
+ 'No customer notification timestamp is recorded for this dispute.',
+ 'Notify the billing contact or document why notice is blocked before release.',
+ );
+ } else {
+ const customerNotifiedAt = parseDate(packet.customerNotifiedAt, 'customerNotifiedAt');
+ const noticeDelayHours = (customerNotifiedAt.getTime() - disputeReceivedAt.getTime()) / HOUR_MS;
+ if (noticeDelayHours > CUSTOMER_NOTICE_HOURS) {
+ addFinding(
+ findings,
+ 'warning',
+ 'CUSTOMER_NOTICE_LATE',
+ `Customer notice was recorded after ${Math.round(noticeDelayHours)} hours.`,
+ 'Have finance confirm late notice is acceptable for this account and region.',
+ );
+ }
+ }
+
+ if (netAmountAtRiskCents === 0 && money(packet.disputedAmountCents) > 0) {
+ addFinding(
+ findings,
+ 'warning',
+ 'DISPUTE_ALREADY_OFFSET',
+ 'Prior refunds or credit memos fully offset the disputed amount.',
+ 'Avoid duplicate recovery. Link the refund and credit memo evidence before responding.',
+ );
+ }
+
+ if (money(packet.revenueRecognizedCents) > netAmountAtRiskCents && netAmountAtRiskCents > 0) {
+ addFinding(
+ findings,
+ 'warning',
+ 'RECOGNIZED_REVENUE_EXCEEDS_RISK',
+ 'Recognized revenue is higher than the current net disputed amount.',
+ 'Confirm whether deferred revenue or a reserve adjustment is needed before release.',
+ );
+ }
+
+ return {
+ id: packet.id,
+ accountId: packet.accountId,
+ invoiceId: packet.invoiceId,
+ processorCaseId: packet.processorCaseId,
+ netAmountAtRiskCents,
+ hoursUntilDue: Math.round(hoursUntilDue * 10) / 10,
+ findingCount: findings.length,
+ highestSeverity: strongestFindingSeverity(findings),
+ decision: strongestAction(findings),
+ findings,
+ };
+}
+
+export function evaluateDisputeBatch(packets, options = {}) {
+ const caseCounts = new Map();
+
+ for (const packet of packets) {
+ const caseId = packet.processorCaseId;
+ caseCounts.set(caseId, (caseCounts.get(caseId) ?? 0) + 1);
+ }
+
+ const results = packets.map((packet) => {
+ const result = evaluateDisputePacket(packet, options);
+ if (caseCounts.get(packet.processorCaseId) > 1) {
+ addFinding(
+ result.findings,
+ 'critical',
+ 'DUPLICATE_PROCESSOR_CASE',
+ `Processor case ${packet.processorCaseId} appears in more than one packet.`,
+ 'Merge or split the evidence packets before any response is submitted.',
+ );
+ result.findingCount = result.findings.length;
+ result.highestSeverity = strongestFindingSeverity(result.findings);
+ result.decision = strongestAction(result.findings);
+ }
+ return result;
+ });
+
+ const summary = results.reduce(
+ (acc, result) => {
+ acc.totalPackets += 1;
+ acc.totalNetAmountAtRiskCents += result.netAmountAtRiskCents;
+ acc.decisions[result.decision] += 1;
+ acc.findings += result.findingCount;
+ if (DECISION_RANK[result.decision] > DECISION_RANK[acc.overallDecision]) {
+ acc.overallDecision = result.decision;
+ }
+ return acc;
+ },
+ {
+ totalPackets: 0,
+ totalNetAmountAtRiskCents: 0,
+ decisions: {
+ RELEASE_RESPONSE: 0,
+ REVIEW_BEFORE_RESPONSE: 0,
+ HOLD_RESPONSE: 0,
+ },
+ findings: 0,
+ overallDecision: 'RELEASE_RESPONSE',
+ },
+ );
+
+ return { generatedAt: options.now ?? DEFAULT_NOW, summary, results };
+}
+
+export { REQUIRED_EVIDENCE };
diff --git a/payment-dispute-evidence-guard/test/disputeGuard.test.mjs b/payment-dispute-evidence-guard/test/disputeGuard.test.mjs
new file mode 100644
index 00000000..54d05d37
--- /dev/null
+++ b/payment-dispute-evidence-guard/test/disputeGuard.test.mjs
@@ -0,0 +1,46 @@
+import assert from 'node:assert/strict';
+import { describe, it } from 'node:test';
+import packets from '../data/dispute-packets.json' with { type: 'json' };
+import { evaluateDisputeBatch, evaluateDisputePacket } from '../src/disputeGuard.mjs';
+
+const NOW = '2026-06-27T08:00:00Z';
+
+describe('payment dispute evidence guard', () => {
+ it('releases a complete dispute packet when all response controls are satisfied', () => {
+ const result = evaluateDisputePacket(packets[0], { now: NOW });
+
+ assert.equal(result.decision, 'RELEASE_RESPONSE');
+ assert.equal(result.findingCount, 0);
+ assert.equal(result.netAmountAtRiskCents, 120000);
+ });
+
+ it('holds packets with missing evidence and unredacted receipt data', () => {
+ const result = evaluateDisputePacket(packets[1], { now: NOW });
+ const codes = result.findings.map((finding) => finding.code);
+
+ assert.equal(result.decision, 'HOLD_RESPONSE');
+ assert.ok(codes.includes('RESPONSE_WINDOW_EXPIRED'));
+ assert.ok(codes.includes('MISSING_DISPUTE_EVIDENCE'));
+ assert.ok(codes.includes('SENSITIVE_RECEIPT_DATA'));
+ });
+
+ it('flags disputes that were already offset by refunds and credit memos', () => {
+ const result = evaluateDisputePacket(packets[2], { now: NOW });
+ const codes = result.findings.map((finding) => finding.code);
+
+ assert.equal(result.netAmountAtRiskCents, 0);
+ assert.equal(result.decision, 'REVIEW_BEFORE_RESPONSE');
+ assert.ok(codes.includes('DISPUTE_ALREADY_OFFSET'));
+ });
+
+ it('detects duplicate processor cases across a batch', () => {
+ const report = evaluateDisputeBatch(packets, { now: NOW });
+ const duplicateResults = report.results.filter((result) => {
+ return result.findings.some((finding) => finding.code === 'DUPLICATE_PROCESSOR_CASE');
+ });
+
+ assert.equal(duplicateResults.length, 2);
+ assert.equal(report.summary.overallDecision, 'HOLD_RESPONSE');
+ assert.equal(report.summary.decisions.HOLD_RESPONSE, 2);
+ });
+});