From f8f0b25dff3ae930c66583380bff564c2fa9c9a3 Mon Sep 17 00:00:00 2001 From: admin Date: Sat, 27 Jun 2026 15:33:16 +0800 Subject: [PATCH 1/2] Add payment dispute evidence guard --- payment-dispute-evidence-guard/README.md | 50 ++++ .../artifacts/dispute-review-demo.svg | 43 ++++ .../artifacts/dispute-review-report.json | 127 ++++++++++ .../artifacts/dispute-review-report.md | 31 +++ .../data/dispute-packets.json | 102 ++++++++ payment-dispute-evidence-guard/package.json | 10 + .../scripts/run-demo.mjs | 118 +++++++++ .../src/disputeGuard.mjs | 232 ++++++++++++++++++ .../test/disputeGuard.test.mjs | 46 ++++ 9 files changed, 759 insertions(+) create mode 100644 payment-dispute-evidence-guard/README.md create mode 100644 payment-dispute-evidence-guard/artifacts/dispute-review-demo.svg create mode 100644 payment-dispute-evidence-guard/artifacts/dispute-review-report.json create mode 100644 payment-dispute-evidence-guard/artifacts/dispute-review-report.md create mode 100644 payment-dispute-evidence-guard/data/dispute-packets.json create mode 100644 payment-dispute-evidence-guard/package.json create mode 100644 payment-dispute-evidence-guard/scripts/run-demo.mjs create mode 100644 payment-dispute-evidence-guard/src/disputeGuard.mjs create mode 100644 payment-dispute-evidence-guard/test/disputeGuard.test.mjs diff --git a/payment-dispute-evidence-guard/README.md b/payment-dispute-evidence-guard/README.md new file mode 100644 index 00000000..b1b5f797 --- /dev/null +++ b/payment-dispute-evidence-guard/README.md @@ -0,0 +1,50 @@ +# 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. +- `artifacts/` - generated JSON, Markdown, and SVG outputs after running the demo. + +## Validation + +```bash +npm test +npm run demo +``` + +The demo writes: + +- `artifacts/dispute-review-report.json` +- `artifacts/dispute-review-report.md` +- `artifacts/dispute-review-demo.svg` + +## 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..2f5b1d5e --- /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-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..91fe9a1f --- /dev/null +++ b/payment-dispute-evidence-guard/package.json @@ -0,0 +1,10 @@ +{ + "name": "payment-dispute-evidence-guard", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "demo": "node scripts/run-demo.mjs", + "test": "node --test test/*.test.mjs" + } +} 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..fd00ff52 --- /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); + }); +}); From 76942a4b7044a0a5e97223393128723d46dde1bb Mon Sep 17 00:00:00 2001 From: admin Date: Sun, 28 Jun 2026 12:00:06 +0800 Subject: [PATCH 2/2] Add dispute guard demo video artifact --- payment-dispute-evidence-guard/README.md | 3 + .../artifacts/dispute-review-demo.svg | 2 +- .../artifacts/dispute-review-demo.webm | Bin 0 -> 37688 bytes payment-dispute-evidence-guard/package.json | 1 + .../scripts/render-demo-video.mjs | 68 ++++++++++++++++++ .../scripts/run-demo.mjs | 2 +- 6 files changed, 74 insertions(+), 2 deletions(-) create mode 100644 payment-dispute-evidence-guard/artifacts/dispute-review-demo.webm create mode 100644 payment-dispute-evidence-guard/scripts/render-demo-video.mjs diff --git a/payment-dispute-evidence-guard/README.md b/payment-dispute-evidence-guard/README.md index b1b5f797..52f0a61f 100644 --- a/payment-dispute-evidence-guard/README.md +++ b/payment-dispute-evidence-guard/README.md @@ -30,6 +30,7 @@ The guard is intentionally offline: it uses no credentials, no payment processor - `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 @@ -37,6 +38,7 @@ The guard is intentionally offline: it uses no credentials, no payment processor ```bash npm test npm run demo +npm run demo:video ``` The demo writes: @@ -44,6 +46,7 @@ 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 diff --git a/payment-dispute-evidence-guard/artifacts/dispute-review-demo.svg b/payment-dispute-evidence-guard/artifacts/dispute-review-demo.svg index 2f5b1d5e..0679be24 100644 --- a/payment-dispute-evidence-guard/artifacts/dispute-review-demo.svg +++ b/payment-dispute-evidence-guard/artifacts/dispute-review-demo.svg @@ -1,4 +1,4 @@ - + Payment dispute evidence guard demo Reviewer artifact showing release, review, and hold decisions for synthetic payment dispute packets. 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 0000000000000000000000000000000000000000..601a8091e3f1d8cd88ceb6a2bc2485082e2d18f9 GIT binary patch literal 37688 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 TCR?4-!g={gi|={y_)1GZuyv{t literal 0 HcmV?d00001 diff --git a/payment-dispute-evidence-guard/package.json b/payment-dispute-evidence-guard/package.json index 91fe9a1f..5a9603c4 100644 --- a/payment-dispute-evidence-guard/package.json +++ b/payment-dispute-evidence-guard/package.json @@ -5,6 +5,7 @@ "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 index fd00ff52..07e40519 100644 --- a/payment-dispute-evidence-guard/scripts/run-demo.mjs +++ b/payment-dispute-evidence-guard/scripts/run-demo.mjs @@ -86,7 +86,7 @@ function renderSvg(report) { `; }).join('\n'); - return ` + return ` Payment dispute evidence guard demo Reviewer artifact showing release, review, and hold decisions for synthetic payment dispute packets.