diff --git a/.changeset/add-reversals-sheet.md b/.changeset/add-reversals-sheet.md new file mode 100644 index 0000000..6c7ff88 --- /dev/null +++ b/.changeset/add-reversals-sheet.md @@ -0,0 +1,5 @@ +--- +"mpesa2csv": minor +--- + +Add optional Reversals Excel sheet and fix multiline Tabula CSV parsing. diff --git a/package.json b/package.json index 94a43f0..346e28f 100644 --- a/package.json +++ b/package.json @@ -63,14 +63,15 @@ "tmp": ">=0.2.6", "uuid": ">=11.1.1", "rollup": ">=4.59.0", - "postcss": ">=8.5.10", + "postcss": ">=8.5.23", + "nanoid": ">=3.3.17", "vite>picomatch": "^4.0.4", "micromatch>picomatch": "^2.3.2", "glob>minimatch": "^3.1.4", "readdir-glob>minimatch": "^5.1.8", "@vitejs/plugin-react>@babel/core": "^7.29.6", - "@changesets/parse>js-yaml": "^3.15.0", - "read-yaml-file>js-yaml": "^4.2.0", + "@changesets/parse>js-yaml": "^3.15.1", + "read-yaml-file>js-yaml": "^4.3.1", "brace-expansion": ">=5.0.8" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1ba4bee..57bc8d5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,14 +9,15 @@ overrides: tmp: '>=0.2.6' uuid: '>=11.1.1' rollup: '>=4.59.0' - postcss: '>=8.5.10' + postcss: '>=8.5.23' + nanoid: '>=3.3.17' vite>picomatch: ^4.0.4 micromatch>picomatch: ^2.3.2 glob>minimatch: ^3.1.4 readdir-glob>minimatch: ^5.1.8 '@vitejs/plugin-react>@babel/core': ^7.29.6 - '@changesets/parse>js-yaml': ^3.15.0 - read-yaml-file>js-yaml: ^4.2.0 + '@changesets/parse>js-yaml': ^3.15.1 + read-yaml-file>js-yaml: ^4.3.1 brace-expansion: '>=5.0.8' importers: @@ -1230,12 +1231,12 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-yaml@3.15.0: - resolution: {integrity: sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==} + js-yaml@3.15.1: + resolution: {integrity: sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==} hasBin: true - js-yaml@4.3.0: - resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} + js-yaml@4.3.1: + resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} hasBin: true jsesc@3.1.0: @@ -1421,9 +1422,9 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - nanoid@3.3.16: - resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + nanoid@6.0.1: + resolution: {integrity: sha512-3wVS3i51pE2pi1k5FFL/95BGfVS0kSsvDVuGXHOtxox/TywUmtgq+3qiTOTbs9J7KfHaXPiN171k/A6dBnaXFw==} + engines: {node: ^22 || ^24 || >=26} hasBin: true node-fetch@2.7.0: @@ -1517,8 +1518,8 @@ packages: engines: {node: '>=20'} hasBin: true - postcss@8.5.19: - resolution: {integrity: sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==} + postcss@8.5.26: + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} engines: {node: ^10 || ^12 || >=14} prettier@2.8.8: @@ -2075,7 +2076,7 @@ snapshots: '@changesets/parse@0.4.1': dependencies: '@changesets/types': 6.1.0 - js-yaml: 3.15.0 + js-yaml: 3.15.1 '@changesets/pre@2.0.2': dependencies: @@ -2915,12 +2916,12 @@ snapshots: js-tokens@4.0.0: {} - js-yaml@3.15.0: + js-yaml@3.15.1: dependencies: argparse: 1.0.10 esprima: 4.0.1 - js-yaml@4.3.0: + js-yaml@4.3.1: dependencies: argparse: 2.0.1 @@ -3067,7 +3068,7 @@ snapshots: ms@2.1.3: {} - nanoid@3.3.16: {} + nanoid@6.0.1: {} node-fetch@2.7.0: dependencies: @@ -3133,9 +3134,9 @@ snapshots: optionalDependencies: fsevents: 2.3.2 - postcss@8.5.19: + postcss@8.5.26: dependencies: - nanoid: 3.3.16 + nanoid: 6.0.1 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -3158,7 +3159,7 @@ snapshots: read-yaml-file@2.1.0: dependencies: - js-yaml: 4.3.0 + js-yaml: 4.3.1 strip-bom: 4.0.0 readable-stream@2.3.8: @@ -3352,7 +3353,7 @@ snapshots: esbuild: 0.28.1 fdir: 6.5.0(picomatch@4.0.5) picomatch: 4.0.5 - postcss: 8.5.19 + postcss: 8.5.26 rollup: 4.62.2 tinyglobby: 0.2.15 optionalDependencies: diff --git a/src/components/export-options.tsx b/src/components/export-options.tsx index bd1cfff..c6aeb59 100644 --- a/src/components/export-options.tsx +++ b/src/components/export-options.tsx @@ -68,6 +68,12 @@ const SHEET_GROUPS = [ name: "Charges & Fees", description: "All M-PESA transaction charges and fees", }, + { + key: "includeReversalsSheet" as keyof ExportOptionsType, + name: "Reversals", + description: + "Reversed payments linked back to the original transaction where possible", + }, ], }, { diff --git a/src/services/exports/index.ts b/src/services/exports/index.ts index 9b85d82..114c155 100644 --- a/src/services/exports/index.ts +++ b/src/services/exports/index.ts @@ -8,3 +8,4 @@ export { addMoneyInSheet } from "./moneyInSheet"; export { addMoneyOutSheet } from "./moneyOutSheet"; export { addRecurringTransactionsSheet } from "./recurringTransactionsSheet"; export { addTimeOfDayActivitySheet } from "./timeOfDayActivitySheet"; +export { addReversalsSheet } from "./reversalsSheet"; diff --git a/src/services/exports/reversalsSheet.ts b/src/services/exports/reversalsSheet.ts new file mode 100644 index 0000000..79ae59c --- /dev/null +++ b/src/services/exports/reversalsSheet.ts @@ -0,0 +1,402 @@ +import { MPesaStatement, Transaction } from "../../types"; +import * as ExcelJS from "exceljs"; + +export type ReversalKind = "send_money" | "globalpay" | "generic"; + +export interface LinkedReversal { + reversal: Transaction; + kind: ReversalKind; + kindLabel: string; + amount: number; + direction: "In" | "Out"; + counterparty: string | null; + linked: Transaction | null; + linkMethod: string | null; +} + +/** Collapse PDF line-breaks so regexes see a single details line. */ +export function normalizeDetails(details: string): string { + return details.replace(/[\r\n]+/g, " ").replace(/\s+/g, " ").trim(); +} + +export function isReversalTransaction(transaction: Transaction): boolean { + return /reversal/i.test(transaction.details); +} + +export function classifyReversal(details: string): ReversalKind { + const d = normalizeDetails(details); + if (/send money reversal/i.test(d)) return "send_money"; + if (/globalpay reversal/i.test(d)) return "globalpay"; + return "generic"; +} + +function kindLabel(kind: ReversalKind): string { + switch (kind) { + case "send_money": + return "Send Money Reversal"; + case "globalpay": + return "GlobalPay Reversal"; + default: + return "Reversal"; + } +} + +function transactionAmount(transaction: Transaction): number { + if (transaction.paidIn != null && transaction.paidIn !== 0) { + return Math.abs(transaction.paidIn); + } + if (transaction.withdrawn != null && transaction.withdrawn !== 0) { + return Math.abs(transaction.withdrawn); + } + return 0; +} + +function transactionDirection(transaction: Transaction): "In" | "Out" { + if (transaction.paidIn != null && Math.abs(transaction.paidIn) > 0) { + return "In"; + } + return "Out"; +} + +/** + * Extract a stable party key for P2P matching across mask variants + * (e.g. 0740***636 vs 07******636) plus the visible name. + */ +export function partyMatchKey(text: string): string { + const d = normalizeDetails(text); + const digits = d.replace(/\D/g, ""); + const lastDigits = digits.slice(-3); + const name = d + .replace(/[\d*]+/g, " ") + .replace(/-/g, " ") + .replace(/\s+/g, " ") + .trim() + .toUpperCase(); + return `${lastDigits}|${name}`; +} + +export function extractReversalCounterparty( + details: string, + kind: ReversalKind +): string | null { + const d = normalizeDetails(details); + + if (kind === "send_money") { + const match = d.match(/Send Money Reversal via API to\s*-?\s*(.+)$/i); + return match?.[1]?.trim() || null; + } + + if (kind === "globalpay") { + const match = d.match( + /GlobalPay reversal from\s+(\d+)\s*-\s*(.+?)(?:\s+Acc\.\s*(.+))?$/i + ); + if (!match) return null; + const paybill = match[1]; + const name = match[2].trim(); + const acc = match[3]?.trim(); + return acc ? `${paybill} - ${name} Acc. ${acc}` : `${paybill} - ${name}`; + } + + const forReceipt = d.match(/Reversal for\s+(\S+)/i); + if (forReceipt) return forReceipt[1]; + + return null; +} + +function extractPaybillNumber(details: string): string | null { + const d = normalizeDetails(details); + const match = d.match( + /(?:GlobalPay reversal from|Card Pay Bill(?: Online)? to)\s+(\d+)/i + ); + return match?.[1] || null; +} + +function extractFundsReceivedParty(details: string): string | null { + const d = normalizeDetails(details); + const match = d.match(/Funds received from\s*-?\s*(.+)$/i); + return match?.[1]?.trim() || null; +} + +function parseCompletionTime(value: string): number { + const ts = Date.parse(value.replace(" ", "T")); + return Number.isNaN(ts) ? 0 : ts; +} + +function findLinkedOriginal( + reversal: Transaction, + kind: ReversalKind, + amount: number, + candidates: Transaction[] +): { linked: Transaction | null; linkMethod: string | null } { + const reversalTime = parseCompletionTime(reversal.completionTime); + const prior = candidates.filter((t) => { + if (t === reversal) return false; + if (isReversalTransaction(t)) return false; + if (t.details.toLowerCase().includes("charge")) return false; + const tTime = parseCompletionTime(t.completionTime); + // Prefer earlier rows; allow same-second / slightly later PDF ordering noise + return tTime <= reversalTime + 60_000; + }); + + if (kind === "send_money") { + const party = extractReversalCounterparty(reversal.details, kind); + if (!party) return { linked: null, linkMethod: null }; + const key = partyMatchKey(party); + + const matches = prior + .filter((t) => { + const receivedParty = extractFundsReceivedParty(t.details); + if (!receivedParty) return false; + if (partyMatchKey(receivedParty) !== key) return false; + return Math.abs(transactionAmount(t) - amount) < 0.005; + }) + .sort( + (a, b) => + parseCompletionTime(b.completionTime) - + parseCompletionTime(a.completionTime) + ); + + if (matches[0]) { + return { linked: matches[0], linkMethod: "counterparty + amount" }; + } + } + + if (kind === "globalpay") { + const paybill = extractPaybillNumber(reversal.details); + if (!paybill) return { linked: null, linkMethod: null }; + + const matches = prior + .filter((t) => { + if (!/Card Pay Bill/i.test(t.details)) return false; + if (extractPaybillNumber(t.details) !== paybill) return false; + return Math.abs(transactionAmount(t) - amount) < 0.005; + }) + .sort( + (a, b) => + parseCompletionTime(b.completionTime) - + parseCompletionTime(a.completionTime) + ); + + if (matches[0]) { + return { linked: matches[0], linkMethod: "paybill + amount" }; + } + } + + // Generic: "Reversal for RECEIPT" or same-receipt non-reversal twin + const forReceipt = normalizeDetails(reversal.details).match( + /Reversal for\s+(\S+)/i + ); + if (forReceipt) { + const target = forReceipt[1]; + const byReceipt = prior.find((t) => t.receiptNo === target); + if (byReceipt) { + return { linked: byReceipt, linkMethod: "receipt in details" }; + } + } + + const sameReceipt = prior.filter( + (t) => + t.receiptNo === reversal.receiptNo && + Math.abs(transactionAmount(t) - amount) < 0.005 + ); + if (sameReceipt.length === 1) { + return { linked: sameReceipt[0], linkMethod: "shared receipt" }; + } + + return { linked: null, linkMethod: null }; +} + +export function buildLinkedReversals( + statement: MPesaStatement +): LinkedReversal[] { + const reversals = statement.transactions.filter(isReversalTransaction); + if (reversals.length === 0) return []; + + return reversals + .map((reversal) => { + const kind = classifyReversal(reversal.details); + const amount = transactionAmount(reversal); + const { linked, linkMethod } = findLinkedOriginal( + reversal, + kind, + amount, + statement.transactions + ); + + return { + reversal, + kind, + kindLabel: kindLabel(kind), + amount, + direction: transactionDirection(reversal), + counterparty: extractReversalCounterparty(reversal.details, kind), + linked, + linkMethod, + }; + }) + .sort( + (a, b) => + parseCompletionTime(b.reversal.completionTime) - + parseCompletionTime(a.reversal.completionTime) + ); +} + +export function addReversalsSheet( + workbook: ExcelJS.Workbook, + statement: MPesaStatement +): void { + const rows = buildLinkedReversals(statement); + // Always create the sheet when the option is selected so the toggle is never + // a silent no-op (statements with no reversals get an empty-state message). + const worksheet = workbook.addWorksheet("Reversals"); + + worksheet.columns = [ + { header: "Receipt No", key: "receiptNo", width: 14 }, + { header: "Completion Time", key: "completionTime", width: 20 }, + { header: "Type", key: "type", width: 22 }, + { header: "Direction", key: "direction", width: 10 }, + { header: "Amount (KSh)", key: "amount", width: 14 }, + { header: "Counterparty / Details", key: "counterparty", width: 42 }, + { header: "Linked Receipt", key: "linkedReceipt", width: 14 }, + { header: "Linked Time", key: "linkedTime", width: 20 }, + { header: "Linked Details", key: "linkedDetails", width: 42 }, + { header: "Link Method", key: "linkMethod", width: 20 }, + { header: "Status", key: "status", width: 12 }, + ]; + + let currentRow = 1; + + worksheet.mergeCells(`A${currentRow}:K${currentRow}`); + worksheet.getCell(`A${currentRow}`).value = "REVERSALS"; + worksheet.getCell(`A${currentRow}`).font = { + bold: true, + size: 16, + color: { argb: "FFFFFFFF" }, + }; + worksheet.getCell(`A${currentRow}`).fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFC00000" }, + }; + worksheet.getCell(`A${currentRow}`).alignment = { horizontal: "center" }; + currentRow += 2; + + if (rows.length === 0) { + worksheet.mergeCells(`A${currentRow}:K${currentRow}`); + worksheet.getCell(`A${currentRow}`).value = + "No reversals found in this statement."; + worksheet.getCell(`A${currentRow}`).font = { + italic: true, + color: { argb: "FF666666" }, + }; + return; + } + + const moneyIn = rows + .filter((r) => r.direction === "In") + .reduce((sum, r) => sum + r.amount, 0); + const moneyOut = rows + .filter((r) => r.direction === "Out") + .reduce((sum, r) => sum + r.amount, 0); + const linkedCount = rows.filter((r) => r.linked).length; + + const summaryLines: [string, string | number][] = [ + ["Total reversals", rows.length], + ["Linked to original", `${linkedCount} / ${rows.length}`], + ["Money returned (In)", moneyIn], + ["Money pulled back (Out)", moneyOut], + ["Net from reversals", moneyIn - moneyOut], + ]; + + worksheet.getCell(`A${currentRow}`).value = "SUMMARY"; + worksheet.getCell(`A${currentRow}`).font = { bold: true, size: 12 }; + worksheet.getCell(`A${currentRow}`).fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFFCE4D6" }, + }; + currentRow++; + + summaryLines.forEach(([label, value]) => { + worksheet.getCell(`A${currentRow}`).value = label; + worksheet.getCell(`A${currentRow}`).font = { bold: true }; + worksheet.getCell(`B${currentRow}`).value = value; + if (typeof value === "number") { + worksheet.getCell(`B${currentRow}`).numFmt = "#,##0.00"; + } + currentRow++; + }); + + currentRow += 1; + + const headers = [ + "Receipt No", + "Completion Time", + "Type", + "Direction", + "Amount (KSh)", + "Counterparty / Details", + "Linked Receipt", + "Linked Time", + "Linked Details", + "Link Method", + "Status", + ]; + + headers.forEach((header, index) => { + const cell = worksheet.getCell(currentRow, index + 1); + cell.value = header; + cell.font = { bold: true }; + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFFFC7CE" }, + }; + cell.alignment = { horizontal: "center", wrapText: true }; + cell.border = { + top: { style: "thin" }, + left: { style: "thin" }, + bottom: { style: "thin" }, + right: { style: "thin" }, + }; + }); + currentRow++; + + rows.forEach((row) => { + const values: (string | number)[] = [ + row.reversal.receiptNo, + row.reversal.completionTime, + row.kindLabel, + row.direction, + row.amount, + row.counterparty || normalizeDetails(row.reversal.details), + row.linked?.receiptNo || "", + row.linked?.completionTime || "", + row.linked ? normalizeDetails(row.linked.details) : "", + row.linkMethod || "", + row.reversal.transactionStatus, + ]; + + values.forEach((value, index) => { + const cell = worksheet.getCell(currentRow, index + 1); + cell.value = value; + cell.border = { + top: { style: "thin" }, + left: { style: "thin" }, + bottom: { style: "thin" }, + right: { style: "thin" }, + }; + if (index === 4 && typeof value === "number") { + cell.numFmt = "#,##0.00"; + } + if (index === 3) { + cell.font = { + color: { + argb: row.direction === "In" ? "FF006100" : "FF9C0006", + }, + }; + } + }); + currentRow++; + }); +} diff --git a/src/services/tabulaService.ts b/src/services/tabulaService.ts index 9252d27..2d343bd 100644 --- a/src/services/tabulaService.ts +++ b/src/services/tabulaService.ts @@ -76,7 +76,9 @@ export class TabulaService { */ static parseTabulaCSV(csvContent: string): MPesaStatement { const transactions: Transaction[] = []; - const lines = csvContent.split("\n").filter((line) => line.trim()); + // Tabula often wraps long Details across newlines inside quotes. Splitting + // on \n alone drops those rows (common for reversals / long paybill names). + const lines = this.splitCsvRecords(csvContent); if (lines.length === 0) { return { transactions: [], totalCharges: 0 }; @@ -113,11 +115,14 @@ export class TabulaService { if (fields[0]?.toLowerCase().includes("receipt")) continue; + const normalize = (value: string | undefined) => + (value || "").trim().replace(/[\r\n]+/g, " ").replace(/\s+/g, " "); + const transaction: Transaction = { - receiptNo: fields[0]?.trim().replace(/\r/g, " ") || "", - completionTime: fields[1]?.trim().replace(/\r/g, " ") || "", - details: fields[2]?.trim().replace(/\r/g, " ") || "", - transactionStatus: fields[3]?.trim().replace(/\r/g, " ") || "Unknown", + receiptNo: normalize(fields[0]), + completionTime: normalize(fields[1]), + details: normalize(fields[2]), + transactionStatus: normalize(fields[3]) || "Unknown", paidIn: this.parseAmount(fields[4]), withdrawn: this.parseAmount(fields[5]), balance: this.parseAmount(fields[6]) || 0, @@ -164,6 +169,49 @@ export class TabulaService { }, 0); } + /** + * Split CSV into records without breaking on newlines that sit inside quotes. + */ + private static splitCsvRecords(csvContent: string): string[] { + const records: string[] = []; + let current = ""; + let inQuotes = false; + + for (let i = 0; i < csvContent.length; i++) { + const char = csvContent[i]; + + if (char === '"') { + if (inQuotes && csvContent[i + 1] === '"') { + current += '"'; + i++; + } else { + inQuotes = !inQuotes; + current += char; + } + continue; + } + + if ((char === "\n" || char === "\r") && !inQuotes) { + if (char === "\r" && csvContent[i + 1] === "\n") { + i++; + } + if (current.trim()) { + records.push(current); + } + current = ""; + continue; + } + + current += char; + } + + if (current.trim()) { + records.push(current); + } + + return records; + } + private static parseCSVLine(line: string): string[] { const fields: string[] = []; let currentField = ""; diff --git a/src/services/xlsxService.ts b/src/services/xlsxService.ts index 62063b2..86ec23b 100644 --- a/src/services/xlsxService.ts +++ b/src/services/xlsxService.ts @@ -11,6 +11,7 @@ import { addMoneyOutSheet, addRecurringTransactionsSheet, addTimeOfDayActivitySheet, + addReversalsSheet, } from "./exports"; import { applyTransactionFilters } from "./transactionFilters"; import { formatDate } from "../utils/dateFormatter"; @@ -119,6 +120,12 @@ export class XlsxService { addChargesSheet(workbook, statement); } + // Reversals need the full statement so originals remain linkable even when + // charge filtering (or other filters) change the main sheet. + if (options?.includeReversalsSheet) { + addReversalsSheet(workbook, statement); + } + // All remaining analytics sheets use filteredStatement so active filters // (e.g. exclude charges, sort order) are consistently reflected. if (options?.includeSummarySheet) { diff --git a/src/shots/CaptureApp.tsx b/src/shots/CaptureApp.tsx index 73fdb28..4e5a010 100644 --- a/src/shots/CaptureApp.tsx +++ b/src/shots/CaptureApp.tsx @@ -23,7 +23,15 @@ import { } from "../types"; import { ExportService } from "../services/exportService"; import { cn } from "@/lib/utils"; -import fixture from "./fixture.json"; +import stubFixture from "./fixture.stub.json"; + +// Prefer a locally generated fixture.json (gitignored) when present for +// marketing screenshots; fall back to the committed stub for CI/builds. +const localFixtures = import.meta.glob<{ default: typeof stubFixture }>( + "./fixture.json", + { eager: true } +); +const fixture = localFixtures["./fixture.json"]?.default ?? stubFixture; export type ShotId = | "dropzone" @@ -86,6 +94,7 @@ const ALL_SHEETS: ExportOptionsType = { includeRecurringTransactionsSheet: true, includeAmountDistributionSheet: true, includeTimeOfDaySheet: true, + includeReversalsSheet: true, filterOutCharges: true, }; diff --git a/src/shots/fixture.stub.json b/src/shots/fixture.stub.json new file mode 100644 index 0000000..8c0835c --- /dev/null +++ b/src/shots/fixture.stub.json @@ -0,0 +1,49 @@ +{ + "fileName": "MPESA_Statement_sample.pdf", + "statement": { + "fileName": "MPESA_Statement_sample.pdf", + "transactions": [ + { + "receiptNo": "SAMPLE00001", + "completionTime": "2025-09-01 09:00:00", + "details": "Funds received from 07******000 - SAMPLE PERSON", + "transactionStatus": "Completed", + "paidIn": 1000, + "withdrawn": null, + "balance": 5000, + "raw": "" + }, + { + "receiptNo": "SAMPLE00002", + "completionTime": "2025-09-01 09:05:00", + "details": "Send Money Reversal via API to - 07******000 SAMPLE PERSON", + "transactionStatus": "Completed", + "paidIn": null, + "withdrawn": 1000, + "balance": 4000, + "raw": "" + }, + { + "receiptNo": "SAMPLE00003", + "completionTime": "2025-09-01 10:00:00", + "details": "Merchant Payment Online to 123456 - SAMPLE MERCHANT", + "transactionStatus": "Completed", + "paidIn": null, + "withdrawn": 250, + "balance": 3750, + "raw": "" + }, + { + "receiptNo": "SAMPLE00004", + "completionTime": "2025-09-01 10:00:00", + "details": "Pay Merchant Charge", + "transactionStatus": "Completed", + "paidIn": null, + "withdrawn": 5, + "balance": 3745, + "raw": "" + } + ], + "totalCharges": 5 + } +} diff --git a/src/types/index.ts b/src/types/index.ts index ac23c8f..b80b841 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -57,6 +57,7 @@ export interface ExportOptions { includeMoneyOutSheet?: boolean; includeRecurringTransactionsSheet?: boolean; includeTimeOfDaySheet?: boolean; + includeReversalsSheet?: boolean; // Filter options filterOutCharges?: boolean; sortOrder?: SortOrder;