Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,29 @@ contracts/migrations/history/*
!contracts/migrations/history/.gitkeep
contracts/migrations/snapshots/*
!contracts/migrations/snapshots/.gitkeep

# Test snapshots (generated — do not commit)
**/__snapshots__/
**/test_snapshots/
contracts/test_snapshots/
contracts/proxy/test_snapshots/
contracts/sla/test_snapshots/
contracts/invoice/test_snapshots/

# Unnecessary root-level output/debug files
test_output.txt
tsc_output.txt
tsc_output_2.txt
lint_output.txt
lint_final_error.txt
final_lint_check.txt
contracts/clippy_output.txt

# Duplicate/backup files
*.backup
package.json.backup
**/src/lib\ copy.rs
SubTrackr

# Storybook build
storybook-static/
131 changes: 131 additions & 0 deletions backend/chargeback/domain/chargebackService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import {
Chargeback,
ChargebackAnalytics,
ChargebackStatus,
EvidenceItem,
EVIDENCE_CHECKLIST,
REASON_CODES,
} from './types';

// In-memory store (replace with DB in production)
const chargebacks = new Map<string, Chargeback>();

function evidenceChecklistFor(reasonCode: string): string[] {
return EVIDENCE_CHECKLIST[reasonCode] ?? EVIDENCE_CHECKLIST['default'];
}

export class ChargebackService {
/** Ingest via webhook or manual entry */
ingest(data: Omit<Chargeback, 'id' | 'evidenceItems' | 'createdAt' | 'updatedAt'>): Chargeback {
const id = `cb_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const now = new Date().toISOString();

// Auto-populate evidence checklist for reason code
const checklist = evidenceChecklistFor(data.reasonCode);
const evidenceItems: EvidenceItem[] = checklist.map((desc, i) => ({
id: `ev_${id}_${i}`,
chargebackId: id,
description: desc,
autoPopulated: true,
}));

const chargeback: Chargeback = {
...data,
id,
evidenceItems,
createdAt: now,
updatedAt: now,
};

chargebacks.set(id, chargeback);
return chargeback;
}

get(id: string): Chargeback | undefined {
return chargebacks.get(id);
}

listByMerchant(merchantId: string): Chargeback[] {
return Array.from(chargebacks.values()).filter((c) => c.merchantId === merchantId);
}

updateStatus(id: string, status: ChargebackStatus): Chargeback {
const cb = chargebacks.get(id);
if (!cb) throw new Error(`Chargeback ${id} not found`);
const updated = { ...cb, status, updatedAt: new Date().toISOString() };
chargebacks.set(id, updated);
return updated;
}

addEvidence(id: string, item: Omit<EvidenceItem, 'id' | 'chargebackId'>): Chargeback {
const cb = chargebacks.get(id);
if (!cb) throw new Error(`Chargeback ${id} not found`);
const evidenceItem: EvidenceItem = {
...item,
id: `ev_${id}_${Date.now()}`,
chargebackId: id,
};
const updated = {
...cb,
evidenceItems: [...cb.evidenceItems, evidenceItem],
updatedAt: new Date().toISOString(),
};
chargebacks.set(id, updated);
return updated;
}

/** Submit evidence to acquirer API (stub) */
async submitRepresentment(id: string): Promise<{ success: boolean; referenceId: string }> {
const cb = chargebacks.get(id);
if (!cb) throw new Error(`Chargeback ${id} not found`);
// In production: call acquirer REST API with evidence files
const referenceId = `repr_${id}_${Date.now()}`;
this.updateStatus(id, 'evidence_submitted');
return { success: true, referenceId };
}

getAnalytics(merchantId: string): ChargebackAnalytics {
const list = this.listByMerchant(merchantId);
const won = list.filter((c) => c.status === 'won').length;
const lost = list.filter((c) => c.status === 'lost').length;
const resolved = won + lost;

const byReasonCode: Record<string, number> = {};
for (const c of list) {
byReasonCode[c.reasonCode] = (byReasonCode[c.reasonCode] ?? 0) + 1;
}

const trendMap = new Map<string, { count: number; won: number }>();
for (const c of list) {
const month = c.createdAt.slice(0, 7);
const entry = trendMap.get(month) ?? { count: 0, won: 0 };
entry.count += 1;
if (c.status === 'won') entry.won += 1;
trendMap.set(month, entry);
}
const trendByMonth = Array.from(trendMap.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([month, { count, won: w }]) => ({
month,
count,
winRate: count > 0 ? w / count : 0,
}));

return {
totalCount: list.length,
winCount: won,
lossCount: lost,
winRate: resolved > 0 ? won / resolved : 0,
chargebackRate: list.length > 0 ? list.length / 1000 : 0, // placeholder total txn count
byReasonCode,
trendByMonth,
};
}

getReasonCodeLabel(network: string, code: string): string {
const networkCodes = REASON_CODES[network as keyof typeof REASON_CODES];
return networkCodes?.[code] ?? code;
}
}

export const chargebackService = new ChargebackService();
163 changes: 163 additions & 0 deletions backend/chargeback/domain/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
export type ChargebackNetwork = 'visa' | 'mastercard' | 'amex';

export type ChargebackStatus =
| 'received'
| 'under_review'
| 'evidence_submitted'
| 'won'
| 'lost'
| 'pre_arbitration'
| 'second_chargeback';

// Reason codes per network
export const REASON_CODES: Record<ChargebackNetwork, Record<string, string>> = {
visa: {
'10.1': 'EMV Liability Shift Counterfeit Fraud',
'10.2': 'EMV Liability Shift Non-Counterfeit Fraud',
'10.3': 'Other Fraud – Card-Present Environment',
'10.4': 'Other Fraud – Card-Absent Environment',
'10.5': 'Visa Fraud Monitoring Program',
'11.1': 'Card Recovery Bulletin',
'12.1': 'Late Presentment',
'12.5': 'Incorrect Transaction Amount',
'13.1': 'Merchandise/Services Not Received',
'13.2': 'Cancelled Recurring Transaction',
'13.3': 'Not as Described or Defective Merchandise/Services',
'13.6': 'Credit Not Processed',
'13.7': 'Cancelled Merchandise/Services',
},
mastercard: {
'4837': 'No Cardholder Authorization',
'4849': 'Questionable Merchant Activity',
'4853': 'Cardholder Dispute',
'4855': 'Goods or Services Not Provided',
'4860': 'Credit Not Processed',
'4863': 'Cardholder Does Not Recognize',
'4870': 'Chip Liability Shift',
'4871': 'Chip/PIN Liability Shift',
},
amex: {
A01: 'Charge Amount Exceeds Authorization Amount',
A02: 'No Valid Authorization',
A08: 'Authorization Approval Expired',
C02: 'Credit Not Processed',
C04: 'Goods/Services Returned or Refused',
C05: 'Goods/Services Cancelled',
C08: 'Goods/Services Not Received or Only Partially Received',
C14: 'Paid by Other Means',
C18: 'No Show or CARDeposit Cancelled',
C28: 'Cancelled Recurring Billing',
C31: 'Goods/Services Not as Described',
C32: 'Goods/Services Damaged or Defective',
F10: 'Missing Imprint',
F14: 'Missing Signature',
F24: 'No Cardmember Authorization',
F29: 'Card Not Present',
F30: 'EMV Counterfeit',
F31: 'EMV Lost/Stolen/Non-Received',
P01: 'Unassigned Card Number',
P03: 'Credit Processed as Charge',
P04: 'Charge Processed as Credit',
P05: 'Incorrect Charge Amount',
P07: 'Late Submission',
P08: 'Duplicate Charge',
P22: 'Non-Matching Card Number',
P23: 'Currency Discrepancy',
R03: 'Insufficient Reply',
R13: 'No Reply',
M01: 'Chargeback Authorization',
},
};

export const EVIDENCE_CHECKLIST: Record<string, string[]> = {
// Not received
'13.1': [
'Proof of delivery with signature',
'Tracking number and carrier confirmation',
'Customer communication logs',
'IP address and geolocation of order',
],
'4855': [
'Proof of delivery or service completion',
'Customer communication logs',
'Signed contract or order confirmation',
],
C08: [
'Proof of delivery or service completion',
'Signed delivery confirmation',
'Customer communication logs',
],
// Cancelled recurring
'13.2': [
'Cancellation policy disclosed at signup',
'Proof customer did not cancel before billing',
'Terms and conditions accepted by customer',
'Transaction receipts for all prior cycles',
],
C28: [
'Recurring billing terms accepted by customer',
'Proof of service usage after alleged cancellation',
'Cancellation policy documentation',
],
// Fraud
'10.4': [
'AVS and CVV match confirmation',
'IP address and device fingerprint',
'3D Secure authentication proof',
'Customer order history showing prior purchases',
],
'4837': [
'Signed authorization',
'3D Secure authentication proof',
'Customer communication confirming the purchase',
'Device fingerprint data',
],
F29: ['IP address log', 'Device fingerprint', '3D Secure authentication data'],
// Default checklist
default: [
'Transaction receipt',
'Customer communication',
'Terms and conditions',
'Proof of service or product delivery',
],
};

export interface Chargeback {
id: string;
transactionId: string;
merchantId: string;
amount: number;
currency: string;
network: ChargebackNetwork;
reasonCode: string;
status: ChargebackStatus;
filedAt: string; // ISO date
representmentDeadline: string; // ISO date
evidenceItems: EvidenceItem[];
isRefundedTransaction: boolean;
isPreArbitration: boolean;
isSecondChargeback: boolean;
acquirerReferenceNumber?: string;
notes?: string;
createdAt: string;
updatedAt: string;
}

export interface EvidenceItem {
id: string;
chargebackId: string;
description: string;
fileUrl?: string;
autoPopulated: boolean;
submittedAt?: string;
}

export interface ChargebackAnalytics {
totalCount: number;
winCount: number;
lossCount: number;
winRate: number;
chargebackRate: number;
byReasonCode: Record<string, number>;
trendByMonth: { month: string; count: number; winRate: number }[];
}
4 changes: 4 additions & 0 deletions backend/chargeback/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export * from './domain/types';
export * from './domain/chargebackService';
export { runDeadlineChecker } from './jobs/deadline_checker';
export { runAutoSubmitWorker } from './jobs/auto_submit_worker';
30 changes: 30 additions & 0 deletions backend/chargeback/jobs/auto_submit_worker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { chargebackService } from '../domain/chargebackService';

/**
* Auto-submits representment for chargebacks that have all evidence populated
* and are within 5 days of their deadline.
*/
export async function runAutoSubmitWorker(merchantId: string): Promise<void> {
const now = Date.now();
const SUBMIT_WINDOW_MS = 5 * 24 * 60 * 60 * 1000;

const chargebacks = chargebackService.listByMerchant(merchantId);

for (const cb of chargebacks) {
if (cb.status !== 'under_review') continue;

const deadline = new Date(cb.representmentDeadline).getTime();
const remaining = deadline - now;
if (remaining <= 0 || remaining > SUBMIT_WINDOW_MS) continue;

const allEvidenceReady = cb.evidenceItems.every((e) => e.autoPopulated || e.fileUrl);
if (!allEvidenceReady) continue;

try {
const result = await chargebackService.submitRepresentment(cb.id);
console.log(`[auto_submit_worker] Submitted ${cb.id}: ref=${result.referenceId}`);
} catch (err) {
console.error(`[auto_submit_worker] Failed to submit ${cb.id}:`, err);
}
}
}
31 changes: 31 additions & 0 deletions backend/chargeback/jobs/deadline_checker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { chargebackService } from '../domain/chargebackService';

const D3_THRESHOLD_MS = 3 * 24 * 60 * 60 * 1000; // 3 days

/**
* Runs on a schedule (e.g., daily cron).
* Flags chargebacks approaching representment deadline and escalates at D-3.
*/
export async function runDeadlineChecker(merchantId: string): Promise<void> {
const now = Date.now();
const chargebacks = chargebackService.listByMerchant(merchantId);

for (const cb of chargebacks) {
if (cb.status !== 'received' && cb.status !== 'under_review') continue;

const deadline = new Date(cb.representmentDeadline).getTime();
const remaining = deadline - now;

if (remaining <= 0) {
console.warn(`[deadline_checker] EXPIRED: chargeback ${cb.id} past deadline`);
continue;
}

if (remaining <= D3_THRESHOLD_MS) {
console.warn(
`[deadline_checker] ESCALATION: chargeback ${cb.id} due in ${Math.ceil(remaining / 86400000)}d — requires immediate action`
);
// In production: trigger push notification + email alert to merchant
}
}
}
Loading