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 @@ + +Payment dispute evidence guard demo +Reviewer artifact showing release, review, and hold decisions for synthetic payment dispute packets. + +Payment Dispute Evidence Guard +Overall decision: HOLD_RESPONSE | Net amount at risk: $5800.00 + + + Release 1 + + Review 1 + + Hold 2 + + + + + DSP-1001 - INV-2026-0418 + chdp_91a7_release | net risk $1200.00 | findings 0 + RELEASE_RESPONSE + + + + + DSP-1002 - INV-2026-0442 + chdp_91a7_hold | net risk $2600.00 | findings 5 + HOLD_RESPONSE + + + + + DSP-1003 - INV-2026-0461 + chdp_91a7_credit | net risk $0.00 | findings 1 + REVIEW_BEFORE_RESPONSE + + + + + DSP-1004 - INV-2026-0490 + chdp_91a7_hold | net risk $2000.00 | findings 4 + HOLD_RESPONSE + + 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 ` +Payment dispute evidence guard demo +Reviewer artifact showing release, review, and hold decisions for synthetic payment dispute packets. + +Payment Dispute Evidence Guard +Overall decision: ${escapeXml(report.summary.overallDecision)} | Net amount at risk: ${escapeXml(cents(report.summary.totalNetAmountAtRiskCents))} + + + Release ${report.summary.decisions.RELEASE_RESPONSE} + + Review ${report.summary.decisions.REVIEW_BEFORE_RESPONSE} + + Hold ${report.summary.decisions.HOLD_RESPONSE} + +${cards} + +`; +} + +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); + }); +});