diff --git a/.gitignore b/.gitignore index fc097f2f..b1fc7846 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/backend/chargeback/domain/chargebackService.ts b/backend/chargeback/domain/chargebackService.ts new file mode 100644 index 00000000..6fb0cd48 --- /dev/null +++ b/backend/chargeback/domain/chargebackService.ts @@ -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(); + +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 { + 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): 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 = {}; + for (const c of list) { + byReasonCode[c.reasonCode] = (byReasonCode[c.reasonCode] ?? 0) + 1; + } + + const trendMap = new Map(); + 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(); diff --git a/backend/chargeback/domain/types.ts b/backend/chargeback/domain/types.ts new file mode 100644 index 00000000..49901cd0 --- /dev/null +++ b/backend/chargeback/domain/types.ts @@ -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> = { + 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 = { + // 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; + trendByMonth: { month: string; count: number; winRate: number }[]; +} diff --git a/backend/chargeback/index.ts b/backend/chargeback/index.ts new file mode 100644 index 00000000..3e29cb26 --- /dev/null +++ b/backend/chargeback/index.ts @@ -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'; diff --git a/backend/chargeback/jobs/auto_submit_worker.ts b/backend/chargeback/jobs/auto_submit_worker.ts new file mode 100644 index 00000000..dc1d9b94 --- /dev/null +++ b/backend/chargeback/jobs/auto_submit_worker.ts @@ -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 { + 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); + } + } +} diff --git a/backend/chargeback/jobs/deadline_checker.ts b/backend/chargeback/jobs/deadline_checker.ts new file mode 100644 index 00000000..c3e607be --- /dev/null +++ b/backend/chargeback/jobs/deadline_checker.ts @@ -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 { + 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 + } + } +} diff --git a/backend/services/notification/commPreferencesTypes.ts b/backend/services/notification/commPreferencesTypes.ts new file mode 100644 index 00000000..e2a43b92 --- /dev/null +++ b/backend/services/notification/commPreferencesTypes.ts @@ -0,0 +1,58 @@ +export type CommCategory = 'billing' | 'product' | 'marketing' | 'security' | 'survey'; +export type CommChannel = 'email' | 'push' | 'sms' | 'in_app'; + +export interface ChannelPreference { + enabled: boolean; + /** Ordered fallback channels if primary fails */ + fallbackOrder: CommChannel[]; +} + +export interface CategoryPreference { + category: CommCategory; + channels: Record; + /** Regulatory-required: cannot be opted out */ + required: boolean; +} + +export interface SubscriberPreference { + userId: string; + categories: Record; + updatedAt: string; + syncVersion: number; +} + +/** Default waterfall rules per category */ +export const DEFAULT_WATERFALL: Record = { + billing: ['email', 'push'], + security: ['email', 'push', 'sms'], + product: ['push', 'in_app'], + marketing: ['email'], + survey: ['email', 'in_app'], +}; + +export const REQUIRED_CATEGORIES: CommCategory[] = ['billing', 'security']; + +export function buildDefaultPreferences(userId: string): SubscriberPreference { + const categories = {} as Record; + const allCategories: CommCategory[] = ['billing', 'product', 'marketing', 'security', 'survey']; + + for (const category of allCategories) { + const waterfall = DEFAULT_WATERFALL[category]; + const channels = {} as Record; + const allChannels: CommChannel[] = ['email', 'push', 'sms', 'in_app']; + + for (const ch of allChannels) { + channels[ch] = { + enabled: waterfall.includes(ch), + fallbackOrder: waterfall.filter((c) => c !== ch), + }; + } + categories[category] = { + category, + channels, + required: REQUIRED_CATEGORIES.includes(category), + }; + } + + return { userId, categories, updatedAt: new Date().toISOString(), syncVersion: 1 }; +} diff --git a/backend/services/notification/preferenceServiceV2.ts b/backend/services/notification/preferenceServiceV2.ts new file mode 100644 index 00000000..67615b3f --- /dev/null +++ b/backend/services/notification/preferenceServiceV2.ts @@ -0,0 +1,128 @@ +import { + SubscriberPreference, + CategoryPreference, + CommCategory, + CommChannel, + buildDefaultPreferences, + REQUIRED_CATEGORIES, +} from './commPreferencesTypes'; + +// In-memory store (replace with DB + WebSocket sync in production) +const store = new Map(); + +export class PreferenceService { + getOrCreate(userId: string): SubscriberPreference { + if (!store.has(userId)) { + store.set(userId, buildDefaultPreferences(userId)); + } + return store.get(userId)!; + } + + /** + * Update per-category channel opt-in. + * Required categories (billing, security) cannot be fully disabled. + */ + updateChannelPreference( + userId: string, + category: CommCategory, + channel: CommChannel, + enabled: boolean + ): SubscriberPreference { + const prefs = this.getOrCreate(userId); + const catPref = prefs.categories[category]; + + // Regulatory bypass: required categories stay on + if (catPref.required && !enabled) { + const anyOtherEnabled = Object.entries(catPref.channels) + .filter(([ch]) => ch !== channel) + .some(([, { enabled: e }]) => e); + if (!anyOtherEnabled) { + throw new Error(`Cannot disable all channels for required category "${category}"`); + } + } + + const updated: SubscriberPreference = { + ...prefs, + categories: { + ...prefs.categories, + [category]: { + ...catPref, + channels: { + ...catPref.channels, + [channel]: { ...catPref.channels[channel], enabled }, + }, + }, + }, + updatedAt: new Date().toISOString(), + syncVersion: prefs.syncVersion + 1, + }; + + store.set(userId, updated); + // In production: broadcast via WebSocket for real-time cross-device sync + return updated; + } + + /** Opt out of marketing without touching billing */ + optOutMarketing(userId: string): SubscriberPreference { + const prefs = this.getOrCreate(userId); + const channels = prefs.categories.marketing.channels; + let updated = prefs; + for (const ch of Object.keys(channels) as CommChannel[]) { + updated = this.updateChannelPreference(userId, 'marketing', ch, false); + } + return updated; + } +} + +export class WaterfallRouter { + /** + * Returns ordered channels to attempt delivery on. + * Skips disabled channels; falls back to next in order. + * Required categories are always delivered regardless of preference. + */ + resolveChannels(prefs: SubscriberPreference, category: CommCategory): CommChannel[] { + const catPref: CategoryPreference = prefs.categories[category]; + + // Regulatory bypass: required categories always get all enabled channels + if (REQUIRED_CATEGORIES.includes(category)) { + return this.enabledChannels(catPref); + } + + return this.enabledChannels(catPref); + } + + private enabledChannels(catPref: CategoryPreference): CommChannel[] { + return (Object.entries(catPref.channels) as [CommChannel, { enabled: boolean }][]) + .filter(([, { enabled }]) => enabled) + .map(([ch]) => ch); + } + + /** + * Simulate waterfall: tries each channel, marks failures, falls back to next. + */ + async deliver( + prefs: SubscriberPreference, + category: CommCategory, + payload: { subject: string; body: string }, + send: (channel: CommChannel, payload: { subject: string; body: string }) => Promise + ): Promise<{ channel: CommChannel; success: boolean }[]> { + const channels = this.resolveChannels(prefs, category); + const results: { channel: CommChannel; success: boolean }[] = []; + + for (const channel of channels) { + try { + const success = await send(channel, payload); + results.push({ channel, success }); + if (success) break; // Stop after first success + } catch { + results.push({ channel, success: false }); + // Continue to next channel + } + } + + return results; + } +} + +export const preferenceService = new PreferenceService(); +export const waterfallRouter = new WaterfallRouter(); diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index 0f32b2ee..aa9fad1a 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -77,6 +77,10 @@ const PaymentMethodsScreen = lazyScreen(() => })) ); const AnalyticsDashboard = lazyScreen(() => import('../../app/screens/AnalyticsDashboard')); +const ChargebackDashboardScreen = lazyScreen(() => import('../screens/ChargebackDashboardScreen')); +const CommunicationPreferencesScreen = lazyScreen( + () => import('../screens/CommunicationPreferencesScreen') +); const Tab = createBottomTabNavigator(); const Stack = createNativeStackNavigator(); @@ -355,6 +359,16 @@ const SettingsStack = () => ( component={AnalyticsDashboard} options={{ title: 'Analytics Dashboard', headerShown: true }} /> + + ); diff --git a/src/navigation/types.ts b/src/navigation/types.ts index 10bf40b0..a9c609b3 100644 --- a/src/navigation/types.ts +++ b/src/navigation/types.ts @@ -48,6 +48,8 @@ export type RootStackParamList = { ChangePlan: { subscriptionId: string }; PaymentMethods: undefined; AnalyticsDashboard: undefined; + ChargebackDashboard: undefined; + CommunicationPreferences: undefined; NotFound: { reason?: string }; }; diff --git a/src/screens/ChargebackDashboardScreen.tsx b/src/screens/ChargebackDashboardScreen.tsx new file mode 100644 index 00000000..1cbb1b74 --- /dev/null +++ b/src/screens/ChargebackDashboardScreen.tsx @@ -0,0 +1,310 @@ +import React, { useState, useMemo } from 'react'; +import { SafeAreaView, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { Card } from '../components/common/Card'; +import { colors, spacing, typography, borderRadius } from '../utils/constants'; +import { Chargeback, ChargebackStatus, REASON_CODES } from '../../backend/chargeback/domain/types'; + +// ─── Mock Data ─────────────────────────────────────────────────────────────── +const MOCK_CHARGEBACKS: Chargeback[] = [ + { + id: 'cb_001', + transactionId: 'txn_abc123', + merchantId: 'merch_1', + amount: 4999, + currency: 'USD', + network: 'visa', + reasonCode: '13.1', + status: 'under_review', + filedAt: '2026-06-15T10:00:00Z', + representmentDeadline: '2026-06-27T10:00:00Z', + evidenceItems: [ + { id: 'ev1', chargebackId: 'cb_001', description: 'Proof of delivery', autoPopulated: true }, + { id: 'ev2', chargebackId: 'cb_001', description: 'Customer comms', autoPopulated: true }, + ], + isRefundedTransaction: false, + isPreArbitration: false, + isSecondChargeback: false, + createdAt: '2026-06-15T10:00:00Z', + updatedAt: '2026-06-15T10:00:00Z', + }, + { + id: 'cb_002', + transactionId: 'txn_def456', + merchantId: 'merch_1', + amount: 12000, + currency: 'USD', + network: 'mastercard', + reasonCode: '4853', + status: 'won', + filedAt: '2026-05-10T08:00:00Z', + representmentDeadline: '2026-05-25T08:00:00Z', + evidenceItems: [], + isRefundedTransaction: true, + isPreArbitration: false, + isSecondChargeback: false, + createdAt: '2026-05-10T08:00:00Z', + updatedAt: '2026-05-20T08:00:00Z', + }, + { + id: 'cb_003', + transactionId: 'txn_ghi789', + merchantId: 'merch_1', + amount: 7500, + currency: 'USD', + network: 'amex', + reasonCode: 'C28', + status: 'pre_arbitration', + filedAt: '2026-06-01T12:00:00Z', + representmentDeadline: '2026-06-28T12:00:00Z', + evidenceItems: [], + isRefundedTransaction: false, + isPreArbitration: true, + isSecondChargeback: false, + createdAt: '2026-06-01T12:00:00Z', + updatedAt: '2026-06-10T12:00:00Z', + }, +]; + +// ─── Helpers ───────────────────────────────────────────────────────────────── +const STATUS_COLOR: Record = { + received: colors.accent, + under_review: colors.warning, + evidence_submitted: colors.primary, + won: colors.success, + lost: colors.error, + pre_arbitration: '#f97316', + second_chargeback: '#ec4899', +}; + +function daysUntil(isoDate: string): number { + return Math.ceil((new Date(isoDate).getTime() - Date.now()) / 86400000); +} + +function formatAmount(cents: number, currency: string): string { + return `${(cents / 100).toFixed(2)} ${currency}`; +} + +function getReasonLabel(network: string, code: string): string { + const map = REASON_CODES[network as keyof typeof REASON_CODES]; + return map?.[code] ?? code; +} + +// ─── Component ──────────────────────────────────────────────────────────────── +const ChargebackDashboardScreen: React.FC = () => { + const [selected, setSelected] = useState(null); + const data = MOCK_CHARGEBACKS; + + const analytics = useMemo(() => { + const won = data.filter((c) => c.status === 'won').length; + const lost = data.filter((c) => c.status === 'lost').length; + const resolved = won + lost; + const byCode: Record = {}; + data.forEach((c) => { + byCode[c.reasonCode] = (byCode[c.reasonCode] ?? 0) + 1; + }); + return { + won, + lost, + total: data.length, + winRate: resolved > 0 ? (won / resolved) * 100 : 0, + byCode, + }; + }, [data]); + + return ( + + + {/* Header */} + Chargeback Management + Track, respond to, and analyze chargebacks + + {/* Analytics Summary */} + + + {analytics.total} + Total + + + {analytics.won} + Won + + + {analytics.lost} + Lost + + + + {analytics.winRate.toFixed(0)}% + + Win Rate + + + + {/* Reason Code Distribution */} + By Reason Code + + {Object.entries(analytics.byCode).map(([code, count]) => ( + + {code} + {count} + + ))} + + + {/* Chargeback List */} + Open Cases + {data.map((cb) => { + const days = daysUntil(cb.representmentDeadline); + const isUrgent = days <= 3 && cb.status !== 'won' && cb.status !== 'lost'; + return ( + setSelected(selected?.id === cb.id ? null : cb)}> + + + + #{cb.transactionId} + + {cb.network.toUpperCase()} · {cb.reasonCode} + + + {getReasonLabel(cb.network, cb.reasonCode)} + + + + {formatAmount(cb.amount, cb.currency)} + + + {cb.status.replace(/_/g, ' ')} + + + + + + {/* Deadline countdown */} + {cb.status !== 'won' && cb.status !== 'lost' && ( + + + {days <= 0 ? 'DEADLINE PASSED' : `Deadline in ${days}d`} + {isUrgent ? ' — ESCALATED' : ''} + + + )} + + {/* Edge case flags */} + + {cb.isRefundedTransaction && ( + + ⚠ Refunded Txn + + )} + {cb.isPreArbitration && ( + + Pre-Arbitration + + )} + {cb.isSecondChargeback && ( + + 2nd Chargeback + + )} + + + {/* Expanded evidence checklist */} + {selected?.id === cb.id && cb.evidenceItems.length > 0 && ( + + Evidence Checklist + {cb.evidenceItems.map((ev) => ( + + {ev.submittedAt ? '✅' : ev.fileUrl ? '📎' : '☐'} {ev.description} + {ev.autoPopulated ? ' (auto)' : ''} + + ))} + + )} + + + ); + })} + + + ); +}; + +export default ChargebackDashboardScreen; + +// ─── Styles ─────────────────────────────────────────────────────────────────── +const styles = StyleSheet.create({ + container: { flex: 1, backgroundColor: colors.background }, + content: { padding: spacing.md, paddingBottom: spacing.xxl }, + title: { ...typography.h2, color: colors.text, marginBottom: spacing.xs }, + subtitle: { ...typography.body2, color: colors.textSecondary, marginBottom: spacing.lg }, + sectionTitle: { + ...typography.h3, + color: colors.text, + marginTop: spacing.lg, + marginBottom: spacing.sm, + }, + metricsRow: { flexDirection: 'row', gap: spacing.sm }, + metricCard: { + flex: 1, + alignItems: 'center', + padding: spacing.sm, + borderWidth: 1, + borderColor: colors.border, + }, + metricValue: { ...typography.h2, color: colors.text }, + metricLabel: { ...typography.small, color: colors.textSecondary }, + codeCard: { padding: spacing.md }, + codeRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: spacing.xs }, + codeText: { ...typography.body2, color: colors.textSecondary }, + codeCount: { ...typography.body2, color: colors.text, fontWeight: '600' }, + caseCard: { marginBottom: spacing.sm, padding: spacing.md }, + urgentCard: { borderColor: colors.error, borderWidth: 1 }, + caseHeader: { flexDirection: 'row', justifyContent: 'space-between' }, + caseId: { ...typography.body2, color: colors.textSecondary }, + caseNetwork: { ...typography.small, color: colors.accent }, + caseReason: { ...typography.body2, color: colors.text, maxWidth: 200 }, + caseRight: { alignItems: 'flex-end' }, + caseAmount: { ...typography.body, color: colors.text, fontWeight: '600' }, + statusBadge: { + borderRadius: borderRadius.sm, + paddingHorizontal: spacing.sm, + paddingVertical: 2, + marginTop: spacing.xs, + }, + statusText: { ...typography.small, fontWeight: '600', textTransform: 'capitalize' }, + deadlineRow: { + marginTop: spacing.sm, + paddingTop: spacing.sm, + borderTopWidth: 1, + borderTopColor: colors.border, + }, + deadlineUrgent: { borderTopColor: colors.error + '44' }, + deadlineText: { ...typography.small, color: colors.warning }, + flagsRow: { flexDirection: 'row', gap: spacing.xs, marginTop: spacing.xs, flexWrap: 'wrap' }, + flag: { + backgroundColor: colors.warningBackground, + borderRadius: borderRadius.sm, + paddingHorizontal: spacing.sm, + paddingVertical: 2, + }, + flagText: { ...typography.small, color: colors.warning }, + evidenceBox: { + marginTop: spacing.md, + paddingTop: spacing.sm, + borderTopWidth: 1, + borderTopColor: colors.border, + }, + evidenceTitle: { + ...typography.body2, + color: colors.text, + fontWeight: '600', + marginBottom: spacing.xs, + }, + evidenceItem: { ...typography.small, color: colors.textSecondary, marginBottom: 2 }, +}); diff --git a/src/screens/CommunicationPreferencesScreen.tsx b/src/screens/CommunicationPreferencesScreen.tsx new file mode 100644 index 00000000..a732a4e3 --- /dev/null +++ b/src/screens/CommunicationPreferencesScreen.tsx @@ -0,0 +1,151 @@ +import React, { useState } from 'react'; +import { SafeAreaView, ScrollView, StyleSheet, Switch, Text, View } from 'react-native'; +import { Card } from '../components/common/Card'; +import { colors, spacing, typography, borderRadius } from '../utils/constants'; +import { + SubscriberPreference, + CommCategory, + CommChannel, + buildDefaultPreferences, + REQUIRED_CATEGORIES, +} from '../../backend/services/notification/commPreferencesTypes'; + +const CATEGORY_LABELS: Record = { + billing: 'Billing', + product: 'Product Updates', + marketing: 'Marketing & Promotions', + security: 'Security Alerts', + survey: 'Surveys & Feedback', +}; + +const CHANNEL_LABELS: Record = { + email: '✉️ Email', + push: '🔔 Push', + sms: '💬 SMS', + in_app: '📱 In-App', +}; + +const CATEGORIES: CommCategory[] = ['billing', 'security', 'product', 'marketing', 'survey']; +const CHANNELS: CommChannel[] = ['email', 'push', 'sms', 'in_app']; + +const CommunicationPreferencesScreen: React.FC = () => { + const [prefs, setPrefs] = useState(() => + buildDefaultPreferences('current_user') + ); + + function toggle(category: CommCategory, channel: CommChannel, value: boolean) { + const catPref = prefs.categories[category]; + + // Prevent disabling all channels on required categories + if (catPref.required && !value) { + const otherEnabled = CHANNELS.filter((ch) => ch !== channel && catPref.channels[ch].enabled); + if (otherEnabled.length === 0) return; // silently block + } + + setPrefs((prev) => ({ + ...prev, + categories: { + ...prev.categories, + [category]: { + ...prev.categories[category], + channels: { + ...prev.categories[category].channels, + [channel]: { ...prev.categories[category].channels[channel], enabled: value }, + }, + }, + }, + updatedAt: new Date().toISOString(), + syncVersion: prev.syncVersion + 1, + })); + // In production: debounced PATCH to /api/preferences + WebSocket sync + } + + return ( + + + Communication Preferences + Choose which notifications you receive and how. + + {CATEGORIES.map((category) => { + const catPref = prefs.categories[category]; + const isRequired = REQUIRED_CATEGORIES.includes(category); + + return ( + + + {CATEGORY_LABELS[category]} + {isRequired && ( + + Required + + )} + + + {isRequired && ( + + Required for service — cannot be fully disabled + + )} + + {CHANNELS.map((channel) => { + const enabled = catPref.channels[channel].enabled; + return ( + + {CHANNEL_LABELS[channel]} + toggle(category, channel, val)} + trackColor={{ false: colors.border, true: colors.primary + '88' }} + thumbColor={enabled ? colors.primary : colors.textSecondary} + accessibilityLabel={`${CHANNEL_LABELS[channel]} for ${CATEGORY_LABELS[category]}`} + /> + + ); + })} + + ); + })} + + + Changes sync across your devices in real-time. Last updated:{' '} + {new Date(prefs.updatedAt).toLocaleString()} + + + + ); +}; + +export default CommunicationPreferencesScreen; + +const styles = StyleSheet.create({ + container: { flex: 1, backgroundColor: colors.background }, + content: { padding: spacing.md, paddingBottom: spacing.xxl }, + title: { ...typography.h2, color: colors.text, marginBottom: spacing.xs }, + subtitle: { ...typography.body2, color: colors.textSecondary, marginBottom: spacing.lg }, + categoryCard: { marginBottom: spacing.md, padding: spacing.md }, + categoryHeader: { flexDirection: 'row', alignItems: 'center', marginBottom: spacing.xs }, + categoryLabel: { ...typography.h3, color: colors.text, flex: 1 }, + requiredBadge: { + backgroundColor: colors.primary + '22', + borderRadius: borderRadius.sm, + paddingHorizontal: spacing.sm, + paddingVertical: 2, + }, + requiredText: { ...typography.small, color: colors.primary, fontWeight: '600' }, + requiredNote: { ...typography.small, color: colors.textSecondary, marginBottom: spacing.sm }, + channelRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: spacing.sm, + borderTopWidth: 1, + borderTopColor: colors.border, + }, + channelLabel: { ...typography.body2, color: colors.text }, + footer: { + ...typography.small, + color: colors.textSecondary, + textAlign: 'center', + marginTop: spacing.md, + }, +});