diff --git a/backend/services/billing/__tests__/accountingExportService.test.ts b/backend/services/billing/__tests__/accountingExportService.test.ts index 3659182d..b8d41d38 100644 --- a/backend/services/billing/__tests__/accountingExportService.test.ts +++ b/backend/services/billing/__tests__/accountingExportService.test.ts @@ -1,6 +1,15 @@ import { streamExport, reconcile, + createExportSchedule, + getExportSchedules, + updateExportSchedule, + deleteExportSchedule, + toggleExportSchedule, + runDueExports, + recordExportDownload, + getExportHistory, + getExportAnalytics, TransactionRecord, TransactionType, } from '../accountingExportService'; @@ -141,4 +150,143 @@ describe('accountingExportService', () => { expect(result.mismatches[0]?.reason).toContain('type mismatch'); }); }); + + describe('PDF streaming', () => { + it('streams valid PDF content', () => { + const chunks: string[] = []; + const { totalRecords } = streamExport([makeRecord()], { + format: 'pdf', + filter: { merchantId: 'merchant-1' }, + onChunk: (c) => chunks.push(c), + }); + + const output = chunks.join(''); + expect(totalRecords).toBe(1); + expect(output).toContain('%PDF-1.4'); + expect(output).toContain('merchant-1'); + expect(output).toContain('Slack'); + expect(output).toContain('%%EOF'); + }); + }); + + describe('JSON schema wrapping', () => { + it('wraps JSON output in schema envelope when includeSchema is true', () => { + const chunks: string[] = []; + streamExport([makeRecord()], { + format: 'json', + filter: { merchantId: 'merchant-1' }, + includeSchema: true, + onChunk: (c) => chunks.push(c), + }); + + const output = chunks.join(''); + const parsed = JSON.parse(output); + expect(parsed.$schema).toContain('subtrackr.app'); + expect(parsed.schemaVersion).toBe('1.0.0'); + expect(parsed.recordCount).toBe(1); + expect(Array.isArray(parsed.records)).toBe(true); + }); + + it('returns bare JSON array by default', () => { + const chunks: string[] = []; + streamExport([makeRecord()], { + format: 'json', + onChunk: (c) => chunks.push(c), + }); + + const output = chunks.join(''); + const parsed = JSON.parse(output); + expect(Array.isArray(parsed)).toBe(true); + expect(parsed[0].id).toBe('txn_1'); + }); + }); + + describe('custom field mappings in CSV', () => { + it('applies custom field mappings to CSV rows', () => { + const chunks: string[] = []; + streamExport([makeRecord()], { + format: 'csv', + onChunk: (c) => chunks.push(c), + fieldMappings: [ + { targetField: 'ID', sourceField: 'id' }, + { targetField: 'Total', sourceField: 'amount', transform: 'currency' }, + ], + }); + + const output = chunks.join(''); + expect(output).toContain('"ID"'); + expect(output).toContain('"Total"'); + expect(output).toContain('"txn_1"'); + expect(output).toContain('"12.50"'); + }); + }); + + describe('schedule management', () => { + const baseInput = { + merchantId: 'merchant-sched', + format: 'csv' as const, + frequency: 'weekly' as const, + enabled: true, + includeInactive: false, + nextRunAt: Date.now() + 1000, + }; + + it('creates, reads, updates, and deletes schedules', () => { + const sched = createExportSchedule(baseInput); + expect(sched.id).toBeTruthy(); + + const schedules = getExportSchedules('merchant-sched'); + expect(schedules.some((s) => s.id === sched.id)).toBe(true); + + const updated = updateExportSchedule(sched.id, { frequency: 'monthly' }); + expect(updated?.frequency).toBe('monthly'); + + const deleted = deleteExportSchedule(sched.id); + expect(deleted).toBe(true); + + const afterDelete = getExportSchedules('merchant-sched'); + expect(afterDelete.find((s) => s.id === sched.id)).toBeUndefined(); + }); + + it('toggleExportSchedule enables and disables', () => { + const sched = createExportSchedule({ ...baseInput, enabled: true }); + const paused = toggleExportSchedule(sched.id, false); + expect(paused?.enabled).toBe(false); + const resumed = toggleExportSchedule(sched.id, true); + expect(resumed?.enabled).toBe(true); + }); + + it('runDueExports processes due schedules', () => { + const past = Date.now() - 5000; + createExportSchedule({ ...baseInput, merchantId: 'merchant-due', nextRunAt: past }); + const results = runDueExports([makeRecord()], Date.now()); + expect(results.length).toBeGreaterThanOrEqual(1); + }); + }); + + describe('analytics and history', () => { + it('getExportAnalytics aggregates history', () => { + // runDueExports populates history; analytics should reflect counts + const analytics = getExportAnalytics(); + expect(typeof analytics.totalExports).toBe('number'); + expect(typeof analytics.totalDownloads).toBe('number'); + expect(typeof analytics.formatBreakdown.csv).toBe('number'); + }); + + it('recordExportDownload increments download count', () => { + const history = getExportHistory(); + if (history.length === 0) { + // No history to test against; pass + return; + } + const entry = history[0]!; + const updated = recordExportDownload(entry.id); + expect(updated?.downloadCount).toBe((entry.downloadCount ?? 0) + 1); + }); + + it('recordExportDownload returns null for unknown id', () => { + const result = recordExportDownload('unknown_backend_id'); + expect(result).toBeNull(); + }); + }); }); diff --git a/backend/services/billing/accountingExportService.ts b/backend/services/billing/accountingExportService.ts index 096e84da..388bfaea 100644 --- a/backend/services/billing/accountingExportService.ts +++ b/backend/services/billing/accountingExportService.ts @@ -2,11 +2,14 @@ * Backend accounting export service. * * Handles large-dataset streaming exports, reconciliation checks, - * and encoding-safe output for CSV/JSON/QuickBooks/Xero formats. + * PDF generation, custom field mappings, schedule management, analytics, + * and encoding-safe output for CSV/JSON/QuickBooks/Xero/PDF formats. */ -export type AccountingFormat = 'csv' | 'json' | 'quickbooks' | 'xero'; +export type AccountingFormat = 'csv' | 'json' | 'quickbooks' | 'xero' | 'pdf'; export type TransactionType = 'revenue' | 'refund' | 'credit' | 'fee'; +export type ExportFrequency = 'daily' | 'weekly' | 'monthly'; +export type ExportStatus = 'success' | 'failed'; export interface TransactionRecord { id: string; @@ -32,6 +35,13 @@ export interface ExportFilter { includeInactive?: boolean; } +export interface CustomFieldMapping { + targetField: string; + sourceField: keyof TransactionRecord | `custom:${string}`; + defaultValue?: string; + transform?: 'none' | 'uppercase' | 'lowercase' | 'currency' | 'date'; +} + export interface StreamExportOptions { format: AccountingFormat; filter?: ExportFilter; @@ -39,6 +49,16 @@ export interface StreamExportOptions { onChunk: (chunk: string) => void; /** Chunk size in number of records. Default: 500. */ chunkSize?: number; + /** Custom column mappings (CSV/QuickBooks/Xero only). */ + fieldMappings?: CustomFieldMapping[]; + /** Custom field key-value pairs appended to each record. */ + customFields?: Record; + /** + * When true for JSON format, wraps records in a schema envelope with + * `$schema`, `schemaVersion`, `merchantId`, `exportedAt`, `recordCount`, + * and `records`. + */ + includeSchema?: boolean; } export interface ReconciliationResult { @@ -48,6 +68,51 @@ export interface ReconciliationResult { isBalanced: boolean; } +export interface ExportSchedule { + id: string; + merchantId: string; + format: AccountingFormat; + frequency: ExportFrequency; + enabled: boolean; + includeInactive: boolean; + fieldMappings?: CustomFieldMapping[]; + customFields?: Record; + nextRunAt: number; + lastRunAt?: number; + createdAt: number; + updatedAt: number; +} + +export type ExportScheduleInput = Omit; + +export interface ExportHistoryEntry { + id: string; + merchantId: string; + format: AccountingFormat; + status: ExportStatus; + itemCount: number; + checksum: string; + scheduleId?: string; + error?: string; + downloadCount: number; + lastDownloadedAt?: number; + createdAt: number; +} + +export interface ExportAnalytics { + totalExports: number; + totalDownloads: number; + successCount: number; + failedCount: number; + totalItemsExported: number; + formatBreakdown: Record; +} + +// ── In-process stores (swap for DB/cache in production) ─────────────────────── + +const scheduleStore: ExportSchedule[] = []; +const historyStore: ExportHistoryEntry[] = []; + // ── Encoding helpers ────────────────────────────────────────────────────────── /** Escape a value for CSV, handling commas, quotes, and non-ASCII safely. */ @@ -105,13 +170,43 @@ const XERO_HEADERS = [ 'Currency', ]; -function recordToCsvRow(r: TransactionRecord, format: AccountingFormat): string { +function recordToCsvRow( + r: TransactionRecord, + format: AccountingFormat, + fieldMappings?: CustomFieldMapping[], + customFields?: Record +): string { + if (fieldMappings?.length) { + return fieldMappings + .map((mapping) => { + if (String(mapping.sourceField).startsWith('custom:')) { + const key = String(mapping.sourceField).slice('custom:'.length); + return csvEscape(customFields?.[key] ?? mapping.defaultValue ?? ''); + } + const raw = r[mapping.sourceField as keyof TransactionRecord]; + let val: string; + if (mapping.transform === 'currency') { + val = Number(raw ?? 0).toFixed(2); + } else if (mapping.transform === 'date' && typeof raw === 'number') { + val = formatDate(raw); + } else if (mapping.transform === 'uppercase') { + val = String(raw ?? '').toUpperCase(); + } else if (mapping.transform === 'lowercase') { + val = String(raw ?? '').toLowerCase(); + } else { + val = String(raw ?? ''); + } + return csvEscape(val); + }) + .join(','); + } + if (format === 'quickbooks') { return [ csvEscape(r.merchantId), csvEscape(r.subscriptionName), csvEscape(r.description ?? ''), - csvEscape('1'), + csvEscape(customFields?.['quantity'] ?? '1'), csvEscape(r.amount.toFixed(2)), csvEscape(r.amount.toFixed(2)), csvEscape(r.currency.toUpperCase()), @@ -126,10 +221,10 @@ function recordToCsvRow(r: TransactionRecord, format: AccountingFormat): string csvEscape(formatDate(r.createdAt)), csvEscape(formatDate(r.billingDate)), csvEscape(r.subscriptionName), - csvEscape('1'), + csvEscape(customFields?.['quantity'] ?? '1'), csvEscape(r.amount.toFixed(2)), - csvEscape('400'), - csvEscape('NONE'), + csvEscape(customFields?.['accountCode'] ?? '400'), + csvEscape(customFields?.['taxType'] ?? 'NONE'), csvEscape(r.currency.toUpperCase()), ].join(','); } @@ -151,12 +246,130 @@ function recordToCsvRow(r: TransactionRecord, format: AccountingFormat): string ].join(','); } -function headersForFormat(format: AccountingFormat): string[] { +function headersForFormat( + format: AccountingFormat, + fieldMappings?: CustomFieldMapping[] +): string[] { + if (fieldMappings?.length) return fieldMappings.map((m) => m.targetField); if (format === 'quickbooks') return QB_HEADERS; if (format === 'xero') return XERO_HEADERS; return CSV_HEADERS; } +// ── PDF builder ─────────────────────────────────────────────────────────────── + +function escapePdfText(text: string): string { + return text.replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)'); +} + +/** + * Build a minimal valid single-page PDF from transaction records. + * Zero-dependency: hand-written ASCII PDF streams (safe for all JS runtimes). + */ +function buildPdfContent(records: TransactionRecord[], merchantId: string): Buffer { + const exportedAt = new Date().toISOString(); + + const COL_ID = 14; + const COL_NAME = 20; + const COL_TYPE = 8; + const COL_AMOUNT = 12; + const COL_DATE = 12; + + const pad = (s: string, len: number) => s.slice(0, len).padEnd(len); + + const headerRow = [ + pad('TransactionId', COL_ID), + pad('Name', COL_NAME), + pad('Type', COL_TYPE), + pad('Amount', COL_AMOUNT), + pad('BillingDate', COL_DATE), + ].join(' '); + + const lines: string[] = [ + `SubTrackr Subscription Export`, + `Merchant: ${merchantId}`, + `Generated: ${exportedAt}`, + `Total records: ${records.length}`, + '', + headerRow, + '-'.repeat(headerRow.length), + ...records.map((r) => + [ + pad(r.id, COL_ID), + pad(r.subscriptionName, COL_NAME), + pad(r.transactionType, COL_TYPE), + pad(`${r.currency.toUpperCase()} ${r.amount.toFixed(2)}`, COL_AMOUNT), + pad(formatDate(r.billingDate), COL_DATE), + ].join(' ') + ), + ]; + + if (records.length === 0) { + lines.push('(No records matched the export criteria)'); + } + + const fontSize = 9; + const leading = 13; + const marginTop = 770; + const escaped = lines.map(escapePdfText); + + const streamBody = [ + 'BT', + `/F1 ${fontSize} Tf`, + `${leading} TL`, + `40 ${marginTop} Td`, + ...escaped.flatMap((line, index) => (index === 0 ? [`(${line}) Tj`] : ['T*', `(${line}) Tj`])), + 'ET', + ].join('\n'); + + const objects = [ + '<< /Type /Catalog /Pages 2 0 R >>', + '<< /Type /Pages /Kids [3 0 R] /Count 1 >>', + '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>', + '<< /Type /Font /Subtype /Type1 /BaseFont /Courier >>', + `<< /Length ${streamBody.length} >>\nstream\n${streamBody}\nendstream`, + ]; + + let pdf = '%PDF-1.4\n'; + const offsets: number[] = []; + objects.forEach((obj, index) => { + offsets.push(Buffer.byteLength(pdf, 'latin1')); + pdf += `${index + 1} 0 obj\n${obj}\nendobj\n`; + }); + + const xrefOffset = Buffer.byteLength(pdf, 'latin1'); + pdf += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`; + for (const offset of offsets) { + pdf += `${offset.toString().padStart(10, '0')} 00000 n \n`; + } + pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF`; + + return Buffer.from(pdf, 'latin1'); +} + +// ── JSON Schema envelope ────────────────────────────────────────────────────── + +const ACCOUNTING_JSON_SCHEMA_ID = + 'https://subtrackr.app/schemas/accounting-export.json'; + +function wrapJsonWithSchema( + records: TransactionRecord[], + merchantId: string +): string { + return JSON.stringify( + { + $schema: ACCOUNTING_JSON_SCHEMA_ID, + schemaVersion: '1.0.0', + merchantId, + exportedAt: new Date().toISOString().slice(0, 10), + recordCount: records.length, + records, + }, + null, + 2 + ); +} + // ── Filtering ───────────────────────────────────────────────────────────────── function applyFilter(records: TransactionRecord[], filter: ExportFilter): TransactionRecord[] { @@ -175,34 +388,56 @@ function applyFilter(records: TransactionRecord[], filter: ExportFilter): Transa /** * Stream-export a large set of transaction records in chunks. * Emits header first, then rows in batches to avoid memory pressure. + * Supports CSV, QuickBooks, Xero, JSON, and PDF formats. */ export function streamExport( records: TransactionRecord[], options: StreamExportOptions ): { totalRecords: number; checksum: string } { - const { format, filter = {}, onChunk, chunkSize = 500 } = options; + const { + format, + filter = {}, + onChunk, + chunkSize = 500, + fieldMappings, + customFields, + includeSchema, + } = options; const filtered = applyFilter(records, filter); + const merchantId = filter.merchantId ?? (filtered[0]?.merchantId ?? 'unknown'); - if (format === 'json') { - // Stream JSON array in chunks - onChunk('['); - for (let i = 0; i < filtered.length; i += chunkSize) { - const batch = filtered.slice(i, i + chunkSize); - const separator = i === 0 ? '' : ','; - onChunk(separator + batch.map((r) => JSON.stringify(r)).join(',')); + if (format === 'pdf') { + // PDF: build full document, emit as single string chunk + const pdfBuffer = buildPdfContent(filtered, merchantId); + onChunk(pdfBuffer.toString('latin1')); + } else if (format === 'json') { + if (includeSchema) { + // Emit schema-wrapped JSON; streaming with full envelope requires buffering for counts + onChunk(wrapJsonWithSchema(filtered, merchantId)); + } else { + // Stream JSON array in chunks + onChunk('['); + for (let i = 0; i < filtered.length; i += chunkSize) { + const batch = filtered.slice(i, i + chunkSize); + const separator = i === 0 ? '' : ','; + onChunk(separator + batch.map((r) => JSON.stringify(r)).join(',')); + } + onChunk(']'); } - onChunk(']'); } else { - const headers = headersForFormat(format); + const headers = headersForFormat(format, fieldMappings); onChunk(headers.map(csvEscape).join(',') + '\n'); for (let i = 0; i < filtered.length; i += chunkSize) { const batch = filtered.slice(i, i + chunkSize); - onChunk(batch.map((r) => recordToCsvRow(r, format)).join('\n') + '\n'); + onChunk(batch.map((r) => recordToCsvRow(r, format, fieldMappings, customFields)).join('\n') + '\n'); } } // Simple checksum over record IDs for reconciliation - const cs = filtered.reduce((acc, r) => acc ^ r.id.split('').reduce((h, c) => h + c.charCodeAt(0), 0), 0); + const cs = filtered.reduce( + (acc, r) => acc ^ r.id.split('').reduce((h, c) => h + c.charCodeAt(0), 0), + 0 + ); return { totalRecords: filtered.length, checksum: Math.abs(cs).toString(16) }; } @@ -248,3 +483,164 @@ export function reconcile( isBalanced: mismatches.length === 0, }; } + +// ── Schedule management ─────────────────────────────────────────────────────── + +function generateId(prefix: string): string { + const random = Math.random().toString(36).slice(2, 8); + return `${prefix}_${Date.now().toString(36)}_${random}`; +} + +function nextRunAtForFrequency(frequency: ExportFrequency, from: number): number { + const next = new Date(from); + if (frequency === 'daily') next.setDate(next.getDate() + 1); + if (frequency === 'weekly') next.setDate(next.getDate() + 7); + if (frequency === 'monthly') next.setMonth(next.getMonth() + 1); + return next.getTime(); +} + +export function createExportSchedule(input: ExportScheduleInput): ExportSchedule { + const now = Date.now(); + const schedule: ExportSchedule = { + ...input, + id: generateId('schedule'), + createdAt: now, + updatedAt: now, + }; + scheduleStore.push(schedule); + return schedule; +} + +export function getExportSchedules(merchantId?: string): ExportSchedule[] { + return merchantId ? scheduleStore.filter((s) => s.merchantId === merchantId) : [...scheduleStore]; +} + +export function updateExportSchedule( + id: string, + patch: Partial> +): ExportSchedule | null { + const index = scheduleStore.findIndex((s) => s.id === id); + if (index < 0) return null; + const updated = { ...scheduleStore[index]!, ...patch, updatedAt: Date.now() }; + scheduleStore[index] = updated; + return updated; +} + +export function deleteExportSchedule(id: string): boolean { + const index = scheduleStore.findIndex((s) => s.id === id); + if (index < 0) return false; + scheduleStore.splice(index, 1); + return true; +} + +export function toggleExportSchedule(id: string, enabled: boolean): ExportSchedule | null { + return updateExportSchedule(id, { enabled }); +} + +/** + * Run all due schedules against a provided record set. + * Advances `nextRunAt` for each executed schedule. + */ +export function runDueExports( + records: TransactionRecord[], + now = Date.now() +): Array<{ schedule: ExportSchedule; result: { totalRecords: number; checksum: string } }> { + const due = scheduleStore.filter((s) => s.enabled && s.nextRunAt <= now); + const results: Array<{ schedule: ExportSchedule; result: { totalRecords: number; checksum: string } }> = []; + + for (const schedule of due) { + const chunks: string[] = []; + const result = streamExport(records, { + format: schedule.format, + filter: { merchantId: schedule.merchantId }, + onChunk: (c) => chunks.push(c), + fieldMappings: schedule.fieldMappings, + customFields: schedule.customFields, + }); + + const index = scheduleStore.findIndex((s) => s.id === schedule.id); + if (index >= 0) { + scheduleStore[index] = { + ...scheduleStore[index]!, + lastRunAt: now, + nextRunAt: nextRunAtForFrequency(schedule.frequency, now), + updatedAt: now, + }; + } + + const historyEntry: ExportHistoryEntry = { + id: generateId('history'), + merchantId: schedule.merchantId, + format: schedule.format, + status: 'success', + itemCount: result.totalRecords, + checksum: result.checksum, + scheduleId: schedule.id, + downloadCount: 0, + createdAt: now, + }; + historyStore.push(historyEntry); + + results.push({ schedule, result }); + } + + return results; +} + +// ── Analytics ───────────────────────────────────────────────────────────────── + +export function recordExportDownload(exportId: string): ExportHistoryEntry | null { + const index = historyStore.findIndex((e) => e.id === exportId); + if (index < 0) return null; + const now = Date.now(); + const updated: ExportHistoryEntry = { + ...historyStore[index]!, + downloadCount: (historyStore[index]!.downloadCount ?? 0) + 1, + lastDownloadedAt: now, + }; + historyStore[index] = updated; + return updated; +} + +export function getExportHistory(merchantId?: string): ExportHistoryEntry[] { + return merchantId ? historyStore.filter((e) => e.merchantId === merchantId) : [...historyStore]; +} + +export function getExportAnalytics(merchantId?: string): ExportAnalytics { + const relevant = merchantId + ? historyStore.filter((e) => e.merchantId === merchantId) + : historyStore; + + const formatBreakdown: Record = { + csv: 0, + json: 0, + quickbooks: 0, + xero: 0, + pdf: 0, + }; + + let totalDownloads = 0; + let successCount = 0; + let failedCount = 0; + let totalItemsExported = 0; + + for (const entry of relevant) { + if (entry.status === 'success') { + successCount += 1; + totalItemsExported += entry.itemCount; + } else { + failedCount += 1; + } + totalDownloads += entry.downloadCount ?? 0; + formatBreakdown[entry.format] = (formatBreakdown[entry.format] ?? 0) + 1; + } + + return { + totalExports: relevant.length, + totalDownloads, + successCount, + failedCount, + totalItemsExported, + formatBreakdown, + }; +} diff --git a/backend/services/billing/exportApi.ts b/backend/services/billing/exportApi.ts new file mode 100644 index 00000000..50d51bdf --- /dev/null +++ b/backend/services/billing/exportApi.ts @@ -0,0 +1,379 @@ +/** + * Export REST API + * + * HTTP request/response handlers for the subscription export API. + * Follows the ApiResponse envelope convention used throughout the backend + * (see notification/webhookManagementApi.ts, billing/usageIngestionApi.ts). + * + * Routes to wire up in your Express/Fastify app: + * POST /v1/exports → handleCreateExport + * GET /v1/exports/:id → handleGetExportStatus + * GET /v1/exports/:id/download → handleDownloadExport + * PATCH /v1/exports/:id/download → handleRecordDownload + * POST /v1/exports/schedules → handleCreateSchedule + * GET /v1/exports/schedules → handleGetSchedules + * PATCH /v1/exports/schedules/:id → handleUpdateSchedule + * DELETE /v1/exports/schedules/:id → handleDeleteSchedule + * GET /v1/exports/analytics → handleGetAnalytics + */ + +import type { Request, Response } from 'express'; +import { + streamExport, + reconcile, + createExportSchedule, + getExportSchedules, + updateExportSchedule, + deleteExportSchedule, + toggleExportSchedule, + runDueExports, + recordExportDownload, + getExportHistory, + getExportAnalytics, + AccountingFormat, + TransactionRecord, + ExportScheduleInput, + ExportHistoryEntry, +} from './accountingExportService'; + +// ── Response helpers ────────────────────────────────────────────────────────── + +export interface ApiResponse { + success: boolean; + data?: T; + message?: string; + error?: string; + requestId?: string; +} + +function ok(data: T, message?: string, requestId?: string): ApiResponse { + return { success: true, data, message, requestId }; +} + +function fail(error: unknown, fallback: string, requestId?: string): ApiResponse { + return { + success: false, + error: error instanceof Error ? error.message : fallback, + requestId, + }; +} + +function requestId(req: Request): string | undefined { + return (req.headers['x-request-id'] as string) ?? undefined; +} + +// ── Supported formats ───────────────────────────────────────────────────────── + +const SUPPORTED_FORMATS = new Set(['csv', 'json', 'quickbooks', 'xero', 'pdf']); + +const MIME_TYPES: Record = { + csv: 'text/csv', + json: 'application/json', + quickbooks: 'text/csv', + xero: 'text/csv', + pdf: 'application/pdf', +}; + +const FILE_EXTENSIONS: Record = { + csv: 'csv', + json: 'json', + quickbooks: 'csv', + xero: 'csv', + pdf: 'pdf', +}; + +// ── In-process export result store (replace with DB/cache in production) ────── + +interface StoredExport { + id: string; + merchantId: string; + format: AccountingFormat; + content: string; + fileName: string; + checksum: string; + itemCount: number; + createdAt: number; +} + +const exportStore = new Map(); + +function generateExportId(): string { + return `exp_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; +} + +// ── Endpoint handlers ───────────────────────────────────────────────────────── + +/** + * POST /v1/exports + * + * Body: + * ```json + * { + * "format": "csv" | "json" | "quickbooks" | "xero" | "pdf", + * "records": TransactionRecord[], + * "filter": ExportFilter?, + * "fieldMappings": CustomFieldMapping[]?, + * "customFields": Record?, + * "includeSchema": boolean? + * } + * ``` + */ +export async function handleCreateExport(req: Request, res: Response): Promise { + const rid = requestId(req); + + const { format, records, filter, fieldMappings, customFields, includeSchema } = req.body as { + format?: string; + records?: TransactionRecord[]; + filter?: Record; + fieldMappings?: unknown[]; + customFields?: Record; + includeSchema?: boolean; + }; + + if (!format || !SUPPORTED_FORMATS.has(format as AccountingFormat)) { + res.status(400).json( + fail( + null, + `format must be one of: ${[...SUPPORTED_FORMATS].join(', ')}`, + rid + ) + ); + return; + } + + if (!Array.isArray(records)) { + res.status(400).json(fail(null, 'records must be an array of TransactionRecord', rid)); + return; + } + + try { + const fmt = format as AccountingFormat; + const chunks: string[] = []; + + const { totalRecords, checksum } = streamExport(records, { + format: fmt, + filter: filter as Record ?? {}, + onChunk: (c) => chunks.push(c), + fieldMappings: fieldMappings as never, + customFields, + includeSchema, + }); + + const content = chunks.join(''); + const exportId = generateExportId(); + const merchantId = (filter as { merchantId?: string })?.merchantId ?? 'unknown'; + const dateStr = new Date().toISOString().slice(0, 10); + const fileName = `${merchantId}-${fmt}-export-${dateStr}.${FILE_EXTENSIONS[fmt]}`; + + exportStore.set(exportId, { + id: exportId, + merchantId, + format: fmt, + content, + fileName, + checksum, + itemCount: totalRecords, + createdAt: Date.now(), + }); + + res.status(201).json( + ok( + { + exportId, + merchantId, + format: fmt, + fileName, + mimeType: MIME_TYPES[fmt], + itemCount: totalRecords, + checksum, + }, + 'Export created successfully', + rid + ) + ); + } catch (error) { + res.status(500).json(fail(error, 'Export generation failed', rid)); + } +} + +/** + * GET /v1/exports/:id + * + * Returns export metadata including status, item count, and download count. + */ +export function handleGetExportStatus(req: Request, res: Response): void { + const rid = requestId(req); + const { id } = req.params as { id: string }; + const stored = exportStore.get(id); + + if (!stored) { + res.status(404).json(fail(null, `Export ${id} not found`, rid)); + return; + } + + res.status(200).json( + ok( + { + exportId: stored.id, + merchantId: stored.merchantId, + format: stored.format, + fileName: stored.fileName, + mimeType: MIME_TYPES[stored.format], + itemCount: stored.itemCount, + checksum: stored.checksum, + createdAt: stored.createdAt, + }, + undefined, + rid + ) + ); +} + +/** + * GET /v1/exports/:id/download + * + * Streams the export file content with correct Content-Type and + * Content-Disposition headers, then records the download event. + */ +export function handleDownloadExport(req: Request, res: Response): void { + const { id } = req.params as { id: string }; + const stored = exportStore.get(id); + + if (!stored) { + res.status(404).json(fail(null, `Export ${id} not found`)); + return; + } + + // Record the download in history (best-effort) + recordExportDownload(id); + + res + .setHeader('Content-Type', MIME_TYPES[stored.format]) + .setHeader('Content-Disposition', `attachment; filename="${stored.fileName}"`) + .setHeader('X-Export-Checksum', stored.checksum) + .setHeader('X-Export-Item-Count', String(stored.itemCount)) + .status(200) + .send(stored.content); +} + +/** + * PATCH /v1/exports/:id/download + * + * Manually records a download event for an export (useful when the client + * handled delivery outside of the download endpoint). + */ +export function handleRecordDownload(req: Request, res: Response): void { + const rid = requestId(req); + const { id } = req.params as { id: string }; + const entry = recordExportDownload(id); + + if (!entry) { + res.status(404).json(fail(null, `Export history entry ${id} not found`, rid)); + return; + } + + res.status(200).json(ok(entry, 'Download recorded', rid)); +} + +/** + * POST /v1/exports/schedules + * + * Body: ExportScheduleInput + */ +export function handleCreateSchedule(req: Request, res: Response): void { + const rid = requestId(req); + + const input = req.body as Partial; + + if (!input.merchantId) { + res.status(400).json(fail(null, 'merchantId is required', rid)); + return; + } + if (!input.format || !SUPPORTED_FORMATS.has(input.format)) { + res.status(400).json( + fail(null, `format must be one of: ${[...SUPPORTED_FORMATS].join(', ')}`, rid) + ); + return; + } + if (!input.frequency || !['daily', 'weekly', 'monthly'].includes(input.frequency)) { + res.status(400).json(fail(null, 'frequency must be daily, weekly, or monthly', rid)); + return; + } + + try { + const schedule = createExportSchedule(input as ExportScheduleInput); + res.status(201).json(ok(schedule, 'Schedule created', rid)); + } catch (error) { + res.status(500).json(fail(error, 'Failed to create schedule', rid)); + } +} + +/** + * GET /v1/exports/schedules?merchantId=... + */ +export function handleGetSchedules(req: Request, res: Response): void { + const rid = requestId(req); + const { merchantId } = req.query as { merchantId?: string }; + const schedules = getExportSchedules(merchantId); + res.status(200).json(ok(schedules, undefined, rid)); +} + +/** + * PATCH /v1/exports/schedules/:id + * + * Supports partial update: set enabled, frequency, format, etc. + * Use `{ "enabled": false }` to pause a schedule. + */ +export function handleUpdateSchedule(req: Request, res: Response): void { + const rid = requestId(req); + const { id } = req.params as { id: string }; + const patch = req.body as Partial; + + const updated = updateExportSchedule(id, patch); + if (!updated) { + res.status(404).json(fail(null, `Schedule ${id} not found`, rid)); + return; + } + + res.status(200).json(ok(updated, 'Schedule updated', rid)); +} + +/** + * DELETE /v1/exports/schedules/:id + */ +export function handleDeleteSchedule(req: Request, res: Response): void { + const rid = requestId(req); + const { id } = req.params as { id: string }; + const deleted = deleteExportSchedule(id); + + if (!deleted) { + res.status(404).json(fail(null, `Schedule ${id} not found`, rid)); + return; + } + + res.status(200).json(ok(null, `Schedule ${id} deleted`, rid)); +} + +/** + * GET /v1/exports/analytics?merchantId=... + * + * Returns aggregated analytics: total exports, downloads, format breakdown. + */ +export function handleGetAnalytics(req: Request, res: Response): void { + const rid = requestId(req); + const { merchantId } = req.query as { merchantId?: string }; + const analytics = getExportAnalytics(merchantId); + res.status(200).json(ok(analytics, undefined, rid)); +} + +/** + * GET /v1/exports/history?merchantId=... + * + * Returns export history log entries. + */ +export function handleGetHistory(req: Request, res: Response): void { + const rid = requestId(req); + const { merchantId } = req.query as { merchantId?: string }; + const history = getExportHistory(merchantId); + res.status(200).json(ok(history, undefined, rid)); +} diff --git a/backend/services/billing/index.ts b/backend/services/billing/index.ts index d2b4dc12..be0250dd 100644 --- a/backend/services/billing/index.ts +++ b/backend/services/billing/index.ts @@ -28,7 +28,7 @@ export type { TaxRemittanceReportRequest, } from './taxTypes'; export { DunningService, dunningService } from './dunningService'; -export { streamExport, reconcile } from './accountingExportService'; +export { streamExport, reconcile, createExportSchedule, getExportSchedules, updateExportSchedule, deleteExportSchedule, toggleExportSchedule, runDueExports, recordExportDownload, getExportHistory, getExportAnalytics } from './accountingExportService'; export type { AccountingFormat, TransactionType, @@ -36,7 +36,28 @@ export type { ExportFilter, StreamExportOptions, ReconciliationResult, + CustomFieldMapping, + ExportSchedule, + ExportScheduleInput, + ExportHistoryEntry, + ExportAnalytics, + ExportFrequency, + ExportStatus, } from './accountingExportService'; +export { + handleCreateExport, + handleGetExportStatus, + handleDownloadExport, + handleRecordDownload, + handleCreateSchedule, + handleGetSchedules, + handleUpdateSchedule, + handleDeleteSchedule, + handleGetAnalytics, + handleGetHistory, +} from './exportApi'; +export type { ApiResponse } from './exportApi'; + export { BackendPartnerService, } from './partnerService'; diff --git a/backend/services/billing/interfaces.ts b/backend/services/billing/interfaces.ts index 609923f0..b17b7e4b 100644 --- a/backend/services/billing/interfaces.ts +++ b/backend/services/billing/interfaces.ts @@ -20,6 +20,10 @@ import { StreamExportOptions, ReconciliationResult, TransactionType, + ExportSchedule, + ExportScheduleInput, + ExportHistoryEntry, + ExportAnalytics, } from './accountingExportService'; import { SplitConfiguration, PartnerPayoutSchedule } from '../../../src/types/partner'; @@ -70,6 +74,18 @@ export interface IAccountingExportService { exported: TransactionRecord[], expected: Array<{ id: string; amount: number; transactionType: TransactionType }> ): ReconciliationResult; + createExportSchedule(input: ExportScheduleInput): ExportSchedule; + getExportSchedules(merchantId?: string): ExportSchedule[]; + updateExportSchedule(id: string, patch: Partial>): ExportSchedule | null; + deleteExportSchedule(id: string): boolean; + toggleExportSchedule(id: string, enabled: boolean): ExportSchedule | null; + runDueExports( + records: TransactionRecord[], + now?: number + ): Array<{ schedule: ExportSchedule; result: { totalRecords: number; checksum: string } }>; + recordExportDownload(exportId: string): ExportHistoryEntry | null; + getExportHistory(merchantId?: string): ExportHistoryEntry[]; + getExportAnalytics(merchantId?: string): ExportAnalytics; } export interface IPartnerService { diff --git a/docs/API.md b/docs/API.md index 04d05c2a..1db2ea6c 100644 --- a/docs/API.md +++ b/docs/API.md @@ -20,6 +20,11 @@ SubTrackr exposes its backend logic through a **Soroban smart contract** on the - [Stream Creation](#stream-creation) - [Wallet Error Codes](#wallet-error-codes) - [Notification Service API](#notification-service-api) +- [Export Service API](#export-service-api) + - [Export Formats](#export-formats) + - [Frontend Service Functions](#frontend-service-functions) + - [REST Endpoints](#rest-endpoints) + - [Export Data Types](#export-data-types) - [Data Types Reference](#data-types-reference) - [API Types](#api-types) - [Subscription Types](#subscription-types) @@ -1009,6 +1014,129 @@ soroban contract deploy \ --- +--- + ## Versioning This API documentation corresponds to the current `main` branch. The Soroban contract does not use semantic versioning on-chain; breaking changes require redeployment to a new contract ID. Client-side services follow the app version in `package.json`. + +--- + +## Export Service API + +SubTrackr provides multi-format subscription data export via a client-side TypeScript service (`src/services/accountingExport.ts`) and a backend REST API (`backend/services/billing/exportApi.ts`). + +For the full guide including examples, see [docs/export-guide.md](./export-guide.md). + +### Export Formats + +| Format | Extension | MIME Type | Description | +|---|---|---|---| +| `csv` | `.csv` | `text/csv` | Generic tabular export | +| `json` | `.json` | `application/json` | Full JSON array or schema-wrapped envelope | +| `quickbooks` | `.csv` | `text/csv` | QuickBooks-compatible CSV | +| `xero` | `.csv` | `text/csv` | Xero-compatible CSV | +| `pdf` | `.pdf` | `application/pdf` | Formatted PDF report | + +### Frontend Service Functions + +| Function | Signature | Description | +|---|---|---| +| `export_to_accounting` | `(merchantId, format, options?) => Promise` | Run an export and record to history | +| `get_export_history` | `(merchantId?) => Promise` | List export history | +| `get_export_analytics` | `(merchantId?) => Promise` | Aggregated analytics | +| `record_export_download` | `(exportId) => Promise` | Increment download counter | +| `schedule_export` | `(config) => Promise` | Create an automated schedule | +| `get_export_schedules` | `() => Promise` | List schedules | +| `update_export_schedule` | `(schedule) => Promise` | Update a schedule | +| `delete_export_schedule` | `(scheduleId) => Promise` | Delete a schedule | +| `toggle_export_schedule` | `(scheduleId, enabled) => Promise` | Enable/disable a schedule | +| `run_due_exports` | `(subscriptions, now?) => Promise` | Execute all due schedules | +| `run_export_schedule` | `(scheduleId, subscriptions, now?) => Promise` | Execute one schedule now | +| `get_accounting_json_schema` | `() => typeof ACCOUNTING_EXPORT_JSON_SCHEMA` | Retrieve Draft-07 JSON Schema | +| `getAccountingDefaultMapping` | `(format) => AccountingFieldMapping[]` | Get default field mappings | +| `buildAccountingExportCsv` | `(subscriptions, merchantId, format, options?) => string` | Build CSV string | + +### REST Endpoints + +| Method | Path | Description | +|---|---|---| +| `POST` | `/v1/exports` | Trigger an export in any format | +| `GET` | `/v1/exports/:id` | Get export metadata and status | +| `GET` | `/v1/exports/:id/download` | Download export file | +| `PATCH` | `/v1/exports/:id/download` | Record a download event | +| `POST` | `/v1/exports/schedules` | Create an export schedule | +| `GET` | `/v1/exports/schedules` | List export schedules | +| `PATCH` | `/v1/exports/schedules/:id` | Update a schedule | +| `DELETE` | `/v1/exports/schedules/:id` | Delete a schedule | +| `GET` | `/v1/exports/analytics` | Get export analytics | +| `GET` | `/v1/exports/history` | Get export history | + +### Export Data Types + +```typescript +type AccountingFormat = 'csv' | 'json' | 'quickbooks' | 'xero' | 'pdf'; +type ExportFrequency = 'daily' | 'weekly' | 'monthly'; +type ExportStatus = 'success' | 'failed'; +type TransactionType = 'revenue' | 'refund' | 'credit' | 'fee'; + +interface ExportResult { + exportId: string; + merchantId: string; + format: AccountingFormat; + status: ExportStatus; + fileName: string; + mimeType: 'text/csv' | 'application/json' | 'application/pdf'; + content: string; + itemCount: number; + checksum: string; + historyEntry: ExportHistoryEntry; +} + +interface ExportHistoryEntry { + id: string; + merchantId: string; + format: AccountingFormat; + status: ExportStatus; + itemCount: number; + fileName?: string; + checksum?: string; + scheduleId?: string; + content?: string; // Stored for re-download + downloadCount?: number; + lastDownloadedAt?: number; + createdAt: number; +} + +interface ExportSchedule { + id: string; + merchantId: string; + format: AccountingFormat; + frequency: ExportFrequency; + destination: 'download' | 'email' | 'webhook'; + enabled: boolean; + includeInactive: boolean; + fieldMappings: AccountingFieldMapping[]; + customFields: Record; + nextRunAt: number; + lastRunAt?: number; + createdAt: number; + updatedAt: number; +} + +interface ExportAnalytics { + totalExports: number; + totalDownloads: number; + successCount: number; + failedCount: number; + totalItemsExported: number; + formatBreakdown: Record; +} + +interface AccountingFieldMapping { + targetField: string; + sourceField: AccountingSourceField; + defaultValue?: string; + transform?: 'none' | 'uppercase' | 'lowercase' | 'currency' | 'date'; +} +``` diff --git a/docs/export-guide.md b/docs/export-guide.md new file mode 100644 index 00000000..3ab769ff --- /dev/null +++ b/docs/export-guide.md @@ -0,0 +1,497 @@ +# SubTrackr Export Guide + +SubTrackr provides multi-format subscription data export, scheduled automation, export analytics, and a REST API for third-party integrations. + +--- + +## Table of Contents + +1. [Supported Formats](#supported-formats) +2. [Custom Columns & Field Mappings](#custom-columns--field-mappings) +3. [JSON Schema Specification](#json-schema-specification) +4. [Scheduled Export Automation](#scheduled-export-automation) +5. [Export Analytics](#export-analytics) +6. [REST API Reference](#rest-api-reference) +7. [Frontend Service API](#frontend-service-api) + +--- + +## Supported Formats + +| Format | Extension | MIME Type | Description | +|---|---|---|---| +| `csv` | `.csv` | `text/csv` | Generic tabular export with all standard columns | +| `json` | `.json` | `application/json` | Full JSON array (or schema-wrapped envelope) | +| `quickbooks` | `.csv` | `text/csv` | QuickBooks-compatible CSV with Customer, Product/Service, Amount columns | +| `xero` | `.csv` | `text/csv` | Xero-compatible CSV with ContactName, InvoiceNumber, UnitAmount columns | +| `pdf` | `.pdf` | `application/pdf` | Formatted tabular report for printing and archival | + +### CSV Export (`csv`) + +Default columns: `TransactionId`, `MerchantId`, `SubscriptionId`, `Name`, `Description`, `Category`, `Type`, `Amount`, `Currency`, `BillingCycle`, `BillingDate`, `DeferredRevenue`, `CreatedAt`. + +All values are NFC-normalized and properly quoted to handle commas, quotes, and international characters. + +### JSON Export (`json`) + +Returns a JSON array of subscription records by default, or a schema-wrapped envelope when `includeSchema: true`. + +**Bare array example:** +```json +[ + { + "merchantId": "merchant-123", + "subscriptionId": "sub_abc", + "subscriptionName": "Slack", + "transactionType": "revenue", + "price": 12.50, + "currency": "USD", + "billingCycle": "monthly", + "nextBillingDate": "2026-02-01", + "status": "active", + "createdAt": "2025-12-01", + "updatedAt": "2026-01-01" + } +] +``` + +### QuickBooks Export (`quickbooks`) + +Columns: `Customer`, `Product/Service`, `Description`, `Qty`, `Rate`, `Amount`, `Currency`, `Service Date`, `Memo`. + +Import via QuickBooks → **Sales → Products and Services → Import**. + +### Xero Export (`xero`) + +Columns: `ContactName`, `InvoiceNumber`, `InvoiceDate`, `DueDate`, `Description`, `Quantity`, `UnitAmount`, `AccountCode`, `TaxType`, `Currency`. + +Import via Xero → **Accounts → Sales → Import**. + +### PDF Export (`pdf`) + +Generates a minimal, zero-dependency PDF file containing a formatted subscription table. Uses ASCII/Latin-1 PDF streams — compatible with React Native, Hermes, and Node.js without any native PDF dependencies. + +Each PDF page contains: +- Export title and merchant ID +- Generation timestamp +- Record count +- Tabular data: Name, Price, Billing Cycle, Next Billing Date, Status (and Deferred Revenue if enabled) + +--- + +## Custom Columns & Field Mappings + +Both CSV formats and accounting exports support custom column configurations via `fieldMappings`. + +### Field Mapping Structure + +```typescript +interface AccountingFieldMapping { + targetField: string; // Output column header name + sourceField: AccountingSourceField; // Source data field + defaultValue?: string; // Fallback if source is empty + transform?: AccountingTransform; // Value transformation +} +``` + +### Source Fields + +| Source Field | Description | +|---|---| +| `merchantId` | Merchant identifier | +| `subscriptionId` | Subscription ID | +| `subscriptionName` | Subscription name | +| `description` | Subscription description | +| `category` | Category (software, streaming, etc.) | +| `price` | Numeric price | +| `currency` | Currency code | +| `billingCycle` | Billing cycle (monthly, yearly, etc.) | +| `nextBillingDate` | Next billing date | +| `status` | `active` or `inactive` | +| `createdAt` | Creation date | +| `updatedAt` | Last updated date | +| `custom:KEY` | Any custom field value from `customFields` | + +### Transforms + +| Transform | Effect | +|---|---| +| `none` | No transformation (default) | +| `uppercase` | Convert value to UPPERCASE | +| `lowercase` | Convert value to lowercase | +| `currency` | Format as decimal with 2 decimal places (e.g., `12.50`) | +| `date` | Format as ISO date string `YYYY-MM-DD` | + +### Example: Custom Mapping + +```typescript +import { export_to_accounting } from './services/accountingExport'; + +const result = await export_to_accounting('merchant-123', 'csv', { + subscriptions, + fieldMappings: [ + { targetField: 'LedgerName', sourceField: 'subscriptionName', transform: 'uppercase' }, + { targetField: 'Amount', sourceField: 'price', transform: 'currency' }, + { targetField: 'AccountCode',sourceField: 'custom:accountCode', defaultValue: '400' }, + ], + customFields: { accountCode: '455' }, +}); +``` + +### Custom Field Values + +Pass key-value pairs in `customFields` to populate `custom:KEY` source fields: + +```typescript +customFields: { + accountCode: '455', + taxType: 'OUTPUT', + quantity: '2', +} +``` + +--- + +## JSON Schema Specification + +When `includeSchema: true` is set, JSON exports include a [JSON Schema Draft-07](https://json-schema.org/draft-07) envelope. + +### Schema-wrapped export structure + +```json +{ + "$schema": "https://subtrackr.app/schemas/accounting-export.json", + "schemaVersion": "1.0.0", + "merchantId": "merchant-123", + "exportedAt": "2026-01-15", + "recordCount": 42, + "records": [ /* ... subscription records ... */ ] +} +``` + +### Record Schema (Draft-07) + +| Property | Type | Required | Description | +|---|---|---|---| +| `merchantId` | string | ✅ | Merchant identifier | +| `subscriptionId` | string | ✅ | Subscription ID | +| `subscriptionName` | string | ✅ | Subscription name | +| `description` | string | ❌ | Optional description | +| `category` | string | ❌ | Category | +| `transactionType` | `revenue \| refund \| credit \| fee` | ✅ | Transaction type | +| `price` | number ≥ 0 | ✅ | Subscription price | +| `currency` | string (3 chars) | ✅ | ISO 4217 currency code | +| `billingCycle` | string | ✅ | Billing frequency | +| `nextBillingDate` | date string | ✅ | ISO-8601 date | +| `status` | `active \| inactive` | ✅ | Subscription status | +| `createdAt` | date string | ✅ | ISO-8601 date | +| `updatedAt` | date string | ✅ | ISO-8601 date | +| `deferredRevenue` | string | ❌ | Deferred revenue decimal (GAAP) | + +### Retrieve the schema programmatically + +```typescript +import { get_accounting_json_schema } from './services/accountingExport'; + +const schema = get_accounting_json_schema(); +// Returns the full JSON Schema Draft-07 object +``` + +--- + +## Scheduled Export Automation + +### Create a schedule + +```typescript +import { schedule_export } from './services/accountingExport'; + +const schedule = await schedule_export({ + merchantId: 'merchant-123', + format: 'quickbooks', + frequency: 'weekly', // 'daily' | 'weekly' | 'monthly' + destination: 'download', // 'download' | 'email' | 'webhook' + includeInactive: false, + fieldMappings: [...], // optional custom mappings + customFields: { accountCode: '400' }, +}); + +console.log(`Next run: ${new Date(schedule.nextRunAt).toISOString()}`); +``` + +### Run due schedules + +Call `run_due_exports` periodically (e.g., from a background job or app foreground event) to execute all schedules that are past their `nextRunAt` time: + +```typescript +import { run_due_exports } from './services/accountingExport'; + +const runs = await run_due_exports(subscriptions); +console.log(`${runs.length} scheduled export(s) executed`); +``` + +After each run, the schedule's `lastRunAt` is updated and `nextRunAt` is advanced by one frequency period. + +### Run a specific schedule immediately + +```typescript +import { run_export_schedule } from './services/accountingExport'; + +const run = await run_export_schedule(scheduleId, subscriptions); +if (run) { + console.log(`Exported ${run.result.itemCount} records`); +} +``` + +### Toggle a schedule on/off + +```typescript +import { toggle_export_schedule } from './services/accountingExport'; + +await toggle_export_schedule(scheduleId, false); // pause +await toggle_export_schedule(scheduleId, true); // resume +``` + +### Delete a schedule + +```typescript +import { delete_export_schedule } from './services/accountingExport'; + +await delete_export_schedule(scheduleId); +``` + +### Schedule fields + +| Field | Type | Description | +|---|---|---| +| `id` | string | Auto-generated unique schedule ID | +| `merchantId` | string | Merchant this schedule belongs to | +| `format` | AccountingFormat | Export format | +| `frequency` | `daily \| weekly \| monthly` | How often to run | +| `destination` | `download \| email \| webhook` | Where to send the export | +| `enabled` | boolean | Whether the schedule is active | +| `nextRunAt` | number | Unix ms timestamp for the next run | +| `lastRunAt` | number | Unix ms timestamp of last run | +| `fieldMappings` | AccountingFieldMapping[] | Custom column mappings | +| `customFields` | Record\ | Custom field values | + +--- + +## Export Analytics + +SubTrackr tracks download counts and format usage across all exports. + +### Get analytics + +```typescript +import { get_export_analytics } from './services/accountingExport'; + +const analytics = await get_export_analytics('merchant-123'); +// or omit merchantId to get global analytics +``` + +### Analytics shape + +```typescript +interface ExportAnalytics { + totalExports: number; // Total number of export runs + totalDownloads: number; // Sum of all download events + successCount: number; // Successful exports + failedCount: number; // Failed exports + totalItemsExported: number; // Sum of all itemCount values + formatBreakdown: { // Count per format + csv: number; + json: number; + quickbooks: number; + xero: number; + pdf: number; + }; +} +``` + +### Record a download + +When a user downloads or copies an export, call `record_export_download` to track it: + +```typescript +import { record_export_download } from './services/accountingExport'; + +const updated = await record_export_download(exportId); +// updated.downloadCount is now incremented +``` + +### Export history + +Retrieve the export history log (up to 50 most recent entries): + +```typescript +import { get_export_history } from './services/accountingExport'; + +const history = await get_export_history('merchant-123'); +// Each entry: { id, merchantId, format, status, itemCount, fileName, downloadCount, createdAt, ... } +``` + +--- + +## REST API Reference + +The backend billing domain exposes a complete export REST API via `backend/services/billing/exportApi.ts`. + +### POST `/v1/exports` + +Trigger an export in any format. + +**Request body:** +```json +{ + "format": "pdf", + "records": [...], + "filter": { + "merchantId": "merchant-123", + "dateFrom": 1700000000000, + "dateTo": 1750000000000, + "transactionTypes": ["revenue"] + }, + "fieldMappings": [...], + "customFields": { "accountCode": "400" }, + "includeSchema": false +} +``` + +**Response (201):** +```json +{ + "success": true, + "data": { + "exportId": "exp_m1yz3d_abc123", + "merchantId": "merchant-123", + "format": "pdf", + "fileName": "merchant-123-pdf-export-2026-01-15.pdf", + "mimeType": "application/pdf", + "itemCount": 42, + "checksum": "a1b2c3d4" + }, + "message": "Export created successfully" +} +``` + +--- + +### GET `/v1/exports/:id` + +Retrieve export metadata. + +**Response (200):** +```json +{ + "success": true, + "data": { + "exportId": "exp_m1yz3d_abc123", + "format": "quickbooks", + "fileName": "merchant-123-quickbooks-export-2026-01-15.csv", + "mimeType": "text/csv", + "itemCount": 12, + "checksum": "a1b2c3d4", + "createdAt": 1753500000000 + } +} +``` + +--- + +### GET `/v1/exports/:id/download` + +Download the export file. Returns the file content with appropriate headers: + +``` +Content-Type: text/csv +Content-Disposition: attachment; filename="merchant-123-quickbooks-export-2026-01-15.csv" +X-Export-Checksum: a1b2c3d4 +X-Export-Item-Count: 12 +``` + +> [!NOTE] +> Every download via this endpoint automatically increments the download counter. + +--- + +### PATCH `/v1/exports/:id/download` + +Manually record a download event (for client-side delivery tracking). + +--- + +### POST `/v1/exports/schedules` + +Create an automated export schedule. + +**Request body:** +```json +{ + "merchantId": "merchant-123", + "format": "xero", + "frequency": "weekly", + "enabled": true, + "includeInactive": false, + "nextRunAt": 1753600000000 +} +``` + +--- + +### GET `/v1/exports/schedules?merchantId=merchant-123` + +List export schedules. + +--- + +### PATCH `/v1/exports/schedules/:id` + +Update a schedule (partial update). Use `{ "enabled": false }` to pause. + +--- + +### DELETE `/v1/exports/schedules/:id` + +Permanently delete a schedule. + +--- + +### GET `/v1/exports/analytics?merchantId=merchant-123` + +Retrieve aggregated export analytics. + +--- + +### GET `/v1/exports/history?merchantId=merchant-123` + +Retrieve export history log. + +--- + +## Frontend Service API + +The `src/services/accountingExport.ts` module (re-exported from `app/services/accountingExport.ts`) is the primary mobile frontend service. + +| Function | Description | +|---|---| +| `export_to_accounting(merchantId, format, options)` | Run an export and persist to history | +| `get_export_history(merchantId?)` | Get history entries | +| `schedule_export(config)` | Create a schedule | +| `get_export_schedules()` | List all schedules | +| `update_export_schedule(schedule)` | Update a schedule | +| `delete_export_schedule(scheduleId)` | Delete a schedule | +| `toggle_export_schedule(scheduleId, enabled)` | Enable/disable a schedule | +| `run_due_exports(subscriptions, now?)` | Execute all due schedules | +| `run_export_schedule(scheduleId, subscriptions, now?)` | Execute one schedule now | +| `record_export_download(exportId)` | Increment download counter | +| `get_export_analytics(merchantId?)` | Get aggregated analytics | +| `get_accounting_json_schema()` | Retrieve the JSON Schema Draft-07 definition | +| `getAccountingDefaultMapping(format)` | Get default field mappings for a format | +| `buildAccountingExportCsv(...)` | Build a CSV string directly (no history) | +| `ACCOUNTING_EXPORT_JSON_SCHEMA` | The raw JSON Schema constant | + +--- + +> [!TIP] +> For advanced use cases requiring large-dataset streaming, reconciliation against expected totals, or server-side schedule orchestration, use the backend `streamExport`, `reconcile`, and `runDueExports` functions from `backend/services/billing/accountingExportService.ts`. diff --git a/src/screens/AccountingExportScreen.tsx b/src/screens/AccountingExportScreen.tsx index 8a2420a6..e656c2c5 100644 --- a/src/screens/AccountingExportScreen.tsx +++ b/src/screens/AccountingExportScreen.tsx @@ -18,15 +18,21 @@ import { AccountingFieldMapping, AccountingFormat, AccountingSourceField, + ExportAnalytics, ExportFrequency, ExportHistoryEntry, ExportSchedule, + delete_export_schedule, export_to_accounting, - getAccountingDefaultMapping, + get_export_analytics, get_export_history, get_export_schedules, + getAccountingDefaultMapping, + record_export_download, run_due_exports, + run_export_schedule, schedule_export, + toggle_export_schedule, } from '../services/accountingExport'; const sourceFields: AccountingSourceField[] = [ @@ -52,6 +58,7 @@ const formatLabels: Record = { json: 'JSON', quickbooks: 'QuickBooks', xero: 'Xero', + pdf: 'PDF', }; const frequencyLabels: Record = { @@ -70,12 +77,22 @@ function nextSourceField(current: AccountingSourceField): AccountingSourceField return sourceFields[(index + 1) % sourceFields.length]; } +const EMPTY_ANALYTICS: ExportAnalytics = { + totalExports: 0, + totalDownloads: 0, + successCount: 0, + failedCount: 0, + totalItemsExported: 0, + formatBreakdown: { csv: 0, json: 0, quickbooks: 0, xero: 0, pdf: 0 }, +}; + const AccountingExportScreen: React.FC = () => { const { subscriptions } = useSubscriptionStore(); const [merchantId, setMerchantId] = useState('default-merchant'); const [format, setFormat] = useState('quickbooks'); const [frequency, setFrequency] = useState('monthly'); const [includeInactive, setIncludeInactive] = useState(false); + const [includeSchema, setIncludeSchema] = useState(false); const [fieldMappings, setFieldMappings] = useState( getAccountingDefaultMapping('quickbooks') ); @@ -86,6 +103,7 @@ const AccountingExportScreen: React.FC = () => { }); const [history, setHistory] = useState([]); const [schedules, setSchedules] = useState([]); + const [analytics, setAnalytics] = useState(EMPTY_ANALYTICS); const [latestPreview, setLatestPreview] = useState(''); const [isExporting, setIsExporting] = useState(false); @@ -98,12 +116,14 @@ const AccountingExportScreen: React.FC = () => { ); const loadExportState = useCallback(async () => { - const [nextHistory, nextSchedules] = await Promise.all([ + const [nextHistory, nextSchedules, nextAnalytics] = await Promise.all([ get_export_history(merchantId), get_export_schedules(), + get_export_analytics(merchantId), ]); setHistory(nextHistory); setSchedules(nextSchedules.filter((schedule) => schedule.merchantId === merchantId)); + setAnalytics(nextAnalytics); }, [merchantId]); useEffect(() => { @@ -144,13 +164,14 @@ const AccountingExportScreen: React.FC = () => { includeInactive, fieldMappings, customFields, + includeSchema: format === 'json' ? includeSchema : undefined, }); - setLatestPreview(result.content); + setLatestPreview(result.content.slice(0, 1200)); await Clipboard.setStringAsync(result.content); await loadExportState(); Alert.alert( 'Export ready', - `${result.itemCount} subscriptions exported to ${result.fileName}. CSV copied to clipboard.` + `${result.itemCount} subscriptions exported to ${result.fileName}. Content copied to clipboard.` ); } catch (error) { Alert.alert('Export failed', error instanceof Error ? error.message : String(error)); @@ -162,13 +183,14 @@ const AccountingExportScreen: React.FC = () => { fieldMappings, format, includeInactive, + includeSchema, loadExportState, merchantId, subscriptions, ]); const handleScheduleExport = useCallback(async () => { - const schedule = await schedule_export({ + const sched = await schedule_export({ merchantId, format, frequency, @@ -180,7 +202,7 @@ const AccountingExportScreen: React.FC = () => { await loadExportState(); Alert.alert( 'Export scheduled', - `Next ${formatLabels[format]} export: ${formatTimestamp(schedule.nextRunAt)}` + `Next ${formatLabels[format]} export: ${formatTimestamp(sched.nextRunAt)}` ); }, [ customFields, @@ -198,27 +220,111 @@ const AccountingExportScreen: React.FC = () => { Alert.alert('Scheduled exports checked', `${runs.length} due export(s) completed.`); }, [loadExportState, subscriptions]); + const handleToggleSchedule = useCallback( + async (scheduleId: string, enabled: boolean) => { + await toggle_export_schedule(scheduleId, enabled); + await loadExportState(); + }, + [loadExportState] + ); + + const handleDeleteSchedule = useCallback( + async (scheduleId: string) => { + Alert.alert('Delete schedule', 'Remove this scheduled export permanently?', [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Delete', + style: 'destructive', + onPress: async () => { + await delete_export_schedule(scheduleId); + await loadExportState(); + }, + }, + ]); + }, + [loadExportState] + ); + + const handleRunScheduleNow = useCallback( + async (scheduleId: string) => { + const run = await run_export_schedule(scheduleId, subscriptions); + if (!run) { + Alert.alert('Not found', 'Schedule not found.'); + return; + } + await loadExportState(); + Alert.alert('Done', `${run.result.itemCount} items exported immediately.`); + }, + [loadExportState, subscriptions] + ); + const handleRedownload = useCallback(async (entry: ExportHistoryEntry) => { if (!entry.content) { Alert.alert('Not available', 'Content was not stored for this export.'); return; } await Clipboard.setStringAsync(entry.content); + await record_export_download(entry.id); Alert.alert('Re-downloaded', `${entry.fileName ?? 'Export'} copied to clipboard.`); }, []); + const successRate = + analytics.totalExports > 0 + ? Math.round((analytics.successCount / analytics.totalExports) * 100) + : 0; + + const topFormat = ( + Object.entries(analytics.formatBreakdown) as [AccountingFormat, number][] + ).sort((a, b) => b[1] - a[1])[0]; + return ( Accounting systems - Bulk subscription export + Subscription export - Generate accounting-ready CSVs for QuickBooks or Xero, map fields, and schedule repeat - exports. + Generate accounting-ready exports in CSV, JSON, PDF, QuickBooks, or Xero format. Map + fields, schedule repeat exports, and track download analytics. + {/* ── Analytics dashboard ── */} + + Export analytics + + + + {analytics.totalExports} + Total exports + + + {analytics.totalDownloads} + Downloads + + + 0 && styles.errorText]}> + {successRate}% + + Success rate + + + {analytics.totalItemsExported} + Records exported + + + {topFormat && topFormat[1] > 0 && ( + + + Most used format:{' '} + + {formatLabels[topFormat[0]]} ({topFormat[1]}) + + + + )} + + {/* ── Subscription summary ── */} Exportable @@ -230,6 +336,7 @@ const AccountingExportScreen: React.FC = () => { + {/* ── Export setup ── */} Export setup Merchant ID @@ -244,7 +351,7 @@ const AccountingExportScreen: React.FC = () => { Format - {(['csv', 'json', 'quickbooks', 'xero'] as AccountingFormat[]).map((item) => ( + {(['csv', 'json', 'quickbooks', 'xero', 'pdf'] as AccountingFormat[]).map((item) => ( { ))} + {/* JSON schema toggle */} + {format === 'json' && ( + + + Include JSON schema envelope + + Wraps output with `$schema`, `schemaVersion`, `merchantId`, `exportedAt`, and + `recordCount` per Draft-07. + + + + + )} + Include inactive subscriptions @@ -276,6 +402,7 @@ const AccountingExportScreen: React.FC = () => { + {/* ── Custom field values ── */} Custom field values {Object.entries(customFields).map(([key, value]) => ( @@ -291,31 +418,35 @@ const AccountingExportScreen: React.FC = () => { ))} - - Field mapping - - Edit target column names, or tap a source field to cycle through subscription fields. - - {fieldMappings.map((mapping, index) => ( - - updateMappingTarget(index, targetField)} - placeholder="Target column" - placeholderTextColor={colors.textSecondary} - /> - cycleMappingSource(index)}> - - {mapping.sourceField} - - - - ))} - + {/* ── Field mapping (hidden for PDF) ── */} + {format !== 'pdf' && ( + + Field mapping + + Edit target column names, or tap a source field to cycle through subscription fields. + + {fieldMappings.map((mapping, index) => ( + + updateMappingTarget(index, targetField)} + placeholder="Target column" + placeholderTextColor={colors.textSecondary} + /> + cycleMappingSource(index)}> + + {mapping.sourceField} + + + + ))} + + )} + {/* ── Schedule ── */} Schedule @@ -342,6 +473,7 @@ const AccountingExportScreen: React.FC = () => { + {/* ── Export now ── */} Export now { onPress={handleExportNow} disabled={isExporting}> - {isExporting ? 'Exporting...' : `Export ${formatLabels[format]} CSV`} + {isExporting ? 'Exporting…' : `Export ${formatLabels[format]}`} {latestPreview.length > 0 && ( - Latest CSV preview - + Latest export preview + {latestPreview} )} + {/* ── Scheduled exports list ── */} Scheduled exports {schedules.length === 0 ? ( No schedules configured for this merchant. ) : ( - schedules.map((schedule) => ( - + schedules.map((sched) => ( + - {formatLabels[schedule.format]} + {formatLabels[sched.format]} - {frequencyLabels[schedule.frequency]} - next{' '} - {formatTimestamp(schedule.nextRunAt)} + {frequencyLabels[sched.frequency]} — next {formatTimestamp(sched.nextRunAt)} + {sched.lastRunAt != null && ( + + Last run: {formatTimestamp(sched.lastRunAt)} + + )} + + + handleToggleSchedule(sched.id, val)} + trackColor={{ false: colors.border, true: colors.primary }} + thumbColor={colors.surface} + style={styles.scheduleSwitch} + /> + handleRunScheduleNow(sched.id)}> + + + handleDeleteSchedule(sched.id)}> + + - {schedule.enabled ? 'On' : 'Off'} )) )} + {/* ── Export history ── */} Export history {history.length === 0 ? ( @@ -394,12 +550,17 @@ const AccountingExportScreen: React.FC = () => { onPress={() => handleRedownload(entry)}> - {formatLabels[entry.format]} - {entry.itemCount} item(s) + {formatLabels[entry.format]} — {entry.itemCount} item(s) {formatTimestamp(entry.createdAt)} - {entry.fileName ? ` - ${entry.fileName}` : ''} + {entry.fileName ? ` · ${entry.fileName}` : ''} + {(entry.downloadCount ?? 0) > 0 && ( + + Downloaded {entry.downloadCount} time{entry.downloadCount !== 1 ? 's' : ''} + + )} @@ -428,6 +589,41 @@ const styles = StyleSheet.create({ }, title: { ...typography.h1, color: colors.text, marginBottom: spacing.xs }, subtitle: { ...typography.body, color: colors.textSecondary, lineHeight: 22 }, + + // Analytics dashboard + analyticsHeader: { + paddingHorizontal: spacing.lg, + paddingBottom: spacing.xs, + }, + analyticsGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + paddingHorizontal: spacing.lg, + gap: spacing.sm, + marginBottom: spacing.sm, + }, + analyticsCard: { + width: '47%', + alignItems: 'center', + paddingVertical: spacing.md, + }, + analyticsValue: { + ...typography.h2, + color: colors.text, + fontWeight: '700', + }, + analyticsLabel: { ...typography.caption, color: colors.textSecondary, marginTop: spacing.xs }, + analyticsTopFormat: { + marginHorizontal: spacing.lg, + marginBottom: spacing.md, + flexDirection: 'row', + alignItems: 'center', + }, + analyticsTopFormatText: { ...typography.caption, color: colors.textSecondary }, + analyticsFormatBadge: { color: colors.primary, fontWeight: '700' }, + errorText: { color: colors.error }, + + // Summary row summaryRow: { flexDirection: 'row', paddingHorizontal: spacing.lg, @@ -437,6 +633,7 @@ const styles = StyleSheet.create({ summaryCard: { flex: 1, alignItems: 'center' }, summaryLabel: { ...typography.caption, color: colors.textSecondary }, summaryValue: { ...typography.h2, color: colors.text, fontWeight: '700' }, + section: { marginHorizontal: spacing.lg, marginBottom: spacing.md }, sectionTitle: { ...typography.h3, color: colors.text, marginBottom: spacing.md }, inputLabel: { @@ -456,10 +653,10 @@ const styles = StyleSheet.create({ paddingHorizontal: spacing.md, paddingVertical: spacing.sm, }, - optionRow: { flexDirection: 'row', gap: spacing.sm, marginBottom: spacing.md }, + optionRow: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm, marginBottom: spacing.md }, optionButton: { - flex: 1, paddingVertical: spacing.sm, + paddingHorizontal: spacing.md, borderWidth: 1, borderColor: colors.border, borderRadius: borderRadius.md, @@ -526,6 +723,8 @@ const styles = StyleSheet.create({ }, previewTitle: { ...typography.caption, color: colors.textSecondary, marginBottom: spacing.xs }, previewText: { ...typography.caption, color: colors.text, lineHeight: 18 }, + + // Schedule row recordRow: { flexDirection: 'row', alignItems: 'center', @@ -537,6 +736,20 @@ const styles = StyleSheet.create({ recordCopy: { flex: 1, marginRight: spacing.sm }, recordTitle: { ...typography.body, color: colors.text, fontWeight: '600' }, recordMeta: { ...typography.caption, color: colors.textSecondary, marginTop: spacing.xs }, + scheduleActions: { flexDirection: 'row', alignItems: 'center', gap: spacing.xs }, + scheduleSwitch: { marginRight: spacing.xs }, + iconButton: { + width: 28, + height: 28, + borderRadius: 14, + borderWidth: 1, + borderColor: colors.border, + alignItems: 'center', + justifyContent: 'center', + }, + iconButtonDestructive: { borderColor: colors.error + '80' }, + iconButtonText: { ...typography.caption, color: colors.primary, fontWeight: '700' }, + iconButtonTextDestructive: { color: colors.error }, statusPill: { ...typography.caption, color: colors.primary, diff --git a/src/screens/ExportScreen.tsx b/src/screens/ExportScreen.tsx index 2fd63984..8db22cc3 100644 --- a/src/screens/ExportScreen.tsx +++ b/src/screens/ExportScreen.tsx @@ -5,10 +5,10 @@ import { StyleSheet, SafeAreaView, ScrollView, + Switch, TouchableOpacity, Alert, Share, - Clipboard, Platform, } from 'react-native'; import { colors, spacing, typography, borderRadius } from '../utils/constants'; @@ -16,8 +16,25 @@ import { Button } from '../components/common/Button'; import { Card } from '../components/common/Card'; import { generateCSV, exportToJSON } from '../utils/importExport'; import { useSubscriptionStore } from '../store'; +import { export_to_accounting, AccountingFormat } from '../services/accountingExport'; -type ExportFormat = 'json' | 'csv'; +type ExportFormat = 'json' | 'csv' | 'pdf' | 'quickbooks' | 'xero'; + +const FORMAT_LABELS: Record = { + json: 'JSON', + csv: 'CSV', + pdf: 'PDF', + quickbooks: 'QuickBooks', + xero: 'Xero', +}; + +const FORMAT_DESCRIPTIONS: Record = { + json: 'Full data with metadata. Optionally includes JSON schema envelope.', + csv: 'Spreadsheet compatible (Excel, Google Sheets, etc.)', + pdf: 'Formatted PDF report for printing and archival.', + quickbooks: 'QuickBooks-compatible CSV (Customer, Product/Service, Amount…)', + xero: 'Xero-compatible CSV (ContactName, InvoiceNumber, UnitAmount…)', +}; const ExportScreen: React.FC = () => { const { subscriptions } = useSubscriptionStore(); @@ -26,6 +43,7 @@ const ExportScreen: React.FC = () => { const [isExporting, setIsExporting] = useState(false); const [exportedData, setExportedData] = useState(null); const [showPreview, setShowPreview] = useState(false); + const [includeSchema, setIncludeSchema] = useState(false); const handleExport = useCallback(async () => { if (subscriptions.length === 0) { @@ -38,10 +56,20 @@ const ExportScreen: React.FC = () => { try { let data: string; - if (exportFormat === 'json') { - data = exportToJSON(subscriptions); + if (exportFormat === 'json' || exportFormat === 'csv') { + // Basic utility exports (preserving existing behaviour) + if (exportFormat === 'json') { + data = exportToJSON(subscriptions); + } else { + data = generateCSV(subscriptions); + } } else { - data = generateCSV(subscriptions); + // Accounting-format exports (pdf, quickbooks, xero) + const result = await export_to_accounting('default-merchant', exportFormat as AccountingFormat, { + subscriptions, + includeSchema: exportFormat === 'json' ? includeSchema : undefined, + }); + data = result.content; } setExportedData(data); @@ -49,7 +77,7 @@ const ExportScreen: React.FC = () => { Alert.alert( 'Export Ready', - `Exported ${subscriptions.length} subscription(s) as ${exportFormat.toUpperCase()}.`, + `Exported ${subscriptions.length} subscription(s) as ${FORMAT_LABELS[exportFormat]}.`, [ { text: 'Cancel', @@ -59,10 +87,6 @@ const ExportScreen: React.FC = () => { text: 'Share', onPress: () => shareData(data), }, - { - text: 'Copy to Clipboard', - onPress: () => copyToClipboard(data), - }, ] ); } catch (error) { @@ -70,22 +94,27 @@ const ExportScreen: React.FC = () => { } finally { setIsExporting(false); } - }, [subscriptions, exportFormat]); + }, [subscriptions, exportFormat, includeSchema]); const shareData = async (data: string) => { try { await Share.share({ message: data, - title: `SubTrackr Export (${exportFormat.toUpperCase()})`, + title: `SubTrackr Export (${FORMAT_LABELS[exportFormat]})`, }); - } catch (error) { + } catch { Alert.alert('Error', 'Failed to share data'); } }; - const copyToClipboard = (data: string) => { - Clipboard.setString(data); - Alert.alert('Copied', `${exportFormat.toUpperCase()} data copied to clipboard`); + const copyToClipboard = async (data: string) => { + try { + const { default: Clipboard } = await import('expo-clipboard'); + await Clipboard.setStringAsync(data); + Alert.alert('Copied', `${FORMAT_LABELS[exportFormat]} data copied to clipboard`); + } catch { + Alert.alert('Copy failed', 'Could not copy to clipboard.'); + } }; const downloadFile = () => { @@ -104,31 +133,38 @@ const ExportScreen: React.FC = () => { Export Format - setExportFormat('json')}> - - JSON - - Full data with metadata - - setExportFormat('csv')}> - - CSV - - Spreadsheet compatible - + {(Object.keys(FORMAT_LABELS) as ExportFormat[]).map((fmt) => ( + setExportFormat(fmt)}> + + {FORMAT_LABELS[fmt]} + + {FORMAT_DESCRIPTIONS[fmt]} + + ))} + {exportFormat === 'json' && ( + + + Include JSON schema envelope + + Wraps output with `$schema`, `schemaVersion`, `merchantId` per Draft-07. + + + + + )} ); @@ -197,7 +233,7 @@ const ExportScreen: React.FC = () => { const renderActions = () => (