UNPKG

@accounter/client

Version:
172 lines (159 loc) 7.33 kB
import { format } from 'date-fns'; import { MissingChargeInfo, TableDocumentsRowFieldsFragmentDoc, TransactionForTransactionsTableFieldsFragmentDoc, type ChargeForCsvExportFieldsFragment, type TableDocumentsRowFieldsFragment, type TransactionForTransactionsTableFieldsFragment, } from '../../gql/graphql.js'; import { getFragmentData } from '../../gql/index.js'; // Pure CSV-building logic for the charges export, kept free of React/urql/barrel imports so it can // be unit-tested in isolation (the component barrel drags heavy deps like pdfjs into the test env). type ColumnDef = { key: string; valueFn: (charge: ChargeForCsvExportFieldsFragment) => string }; const columns: ColumnDef[] = [ { key: 'Charge ID', valueFn: charge => charge.id }, { key: 'Type', valueFn: charge => charge.__typename ?? '' }, { key: 'Date', valueFn: charge => formatDate(charge.minDebitDate ?? charge.minEventDate ?? charge.minDocumentsDate), }, { key: 'Event Date', valueFn: charge => formatDate(charge.minEventDate) }, { key: 'Documents Date', valueFn: charge => formatDate(charge.minDocumentsDate) }, { key: 'Counterparty', valueFn: charge => charge.counterparty?.name ?? '' }, { key: 'Description', valueFn: charge => charge.userDescription?.trim() ?? '' }, { key: 'Tags', valueFn: charge => (charge.tags ?? []).map(tag => tag.name).join('; ') }, { key: 'Tax Category', valueFn: charge => charge.taxCategory?.name ?? '' }, { key: 'Business Trip', valueFn: charge => 'businessTrip' in charge && charge.businessTrip ? charge.businessTrip.name : '', }, { key: 'Accountant Approval', valueFn: charge => charge.accountantApproval ?? '' }, { key: 'Amount', valueFn: charge => formatNumber(charge.totalAmount?.raw) }, { key: 'Currency', valueFn: charge => charge.totalAmount?.currency ?? '' }, { key: 'VAT', valueFn: charge => formatNumber(charge.vat?.raw) }, { key: 'Is Valid', valueFn: charge => formatBoolean(charge.validationData?.isValid) }, ...missingInfoColumns(), { key: 'Valid Credit Card Amount', valueFn: charge => 'validCreditCardAmount' in charge ? formatBoolean(charge.validCreditCardAmount) : '', }, { key: 'Ledger Status', valueFn: charge => charge.metadata?.invalidLedger ?? '' }, { key: 'Ledger Balanced', valueFn: charge => formatBoolean(charge.ledger?.balance?.isBalanced) }, { key: 'Ledger Errors', valueFn: charge => (charge.ledger?.validate?.errors ?? []).join('; ') }, { key: 'Transactions Count', valueFn: charge => formatCount(charge.metadata?.transactionsCount) }, { key: 'Documents Count', valueFn: charge => formatCount(charge.metadata?.documentsCount) }, { key: 'Invoices Count', valueFn: charge => formatCount(charge.metadata?.invoicesCount) }, { key: 'Receipts Count', valueFn: charge => formatCount(charge.metadata?.receiptsCount) }, { key: 'Ledger Count', valueFn: charge => formatCount(charge.metadata?.ledgerCount) }, { key: 'Misc Expenses Count', valueFn: charge => formatCount(charge.metadata?.miscExpensesCount), }, { key: 'Has Open Documents', valueFn: charge => formatBoolean(charge.metadata?.openDocuments) }, { key: 'Suggested Description', valueFn: charge => charge.missingInfoSuggestions?.description?.trim() ?? '', }, { key: 'Suggested Tags', valueFn: charge => (charge.missingInfoSuggestions?.tags ?? []).map(tag => tag.name).join('; '), }, { key: 'Transactions Detail', valueFn: charge => transactionsDetail(charge) }, { key: 'Documents Detail', valueFn: charge => documentsDetail(charge) }, ]; // One boolean column per missing-info type so a reviewer can filter/scan each dimension. function missingInfoColumns(): ColumnDef[] { const entries: Array<[string, MissingChargeInfo]> = [ ['Missing Counterparty', MissingChargeInfo.Counterparty], ['Missing Description', MissingChargeInfo.Description], ['Missing Documents', MissingChargeInfo.Documents], ['Missing Tags', MissingChargeInfo.Tags], ['Missing Transactions', MissingChargeInfo.Transactions], ['Missing VAT', MissingChargeInfo.Vat], ['Missing Tax Category', MissingChargeInfo.TaxCategory], ]; return entries.map(([key, info]) => ({ key, valueFn: charge => charge.validationData ? formatBoolean(charge.validationData.missingInfo.includes(info)) : '', })); } function transactionsDetail(charge: ChargeForCsvExportFieldsFragment): string { return (charge.transactions ?? []) .map(rawTransaction => { const transaction: TransactionForTransactionsTableFieldsFragment = getFragmentData( TransactionForTransactionsTableFieldsFragmentDoc, rawTransaction, ); return [ formatDate(transaction.eventDate), transaction.amount.formatted ?? formatNumber(transaction.amount.raw), transaction.account?.name ?? '', transaction.sourceDescription ?? '', transaction.referenceKey ?? '', transaction.counterparty?.name ?? '', ].join(' | '); }) .join('\n'); } function documentsDetail(charge: ChargeForCsvExportFieldsFragment): string { return (charge.additionalDocuments ?? []) .map(rawDocument => { const document: TableDocumentsRowFieldsFragment = getFragmentData( TableDocumentsRowFieldsFragmentDoc, rawDocument, ); const isFinancial = 'amount' in document; return [ document.documentType ?? '', isFinancial ? formatDate(document.date) : '', isFinancial ? `${document.amount?.formatted ?? formatNumber(document.amount?.raw)} ${document.amount?.currency ?? ''}`.trim() : '', isFinancial ? (document.vat?.formatted ?? formatNumber(document.vat?.raw)) : '', isFinancial ? (document.serialNumber ?? '') : '', isFinancial ? `${document.creditor?.name ?? ''}${document.debtor?.name ?? ''}` : '', ].join(' | '); }) .join('\n'); } export function convertChargesToCsv(charges: ChargeForCsvExportFieldsFragment[]): string { const header = columns.map(column => escapeCsvField(column.key)).join(','); const rows = charges.map(charge => columns.map(column => escapeCsvField(column.valueFn(charge))).join(','), ); return [header, ...rows].join('\r\n'); } export function escapeCsvField(value: string): string { if (value === '') { return ''; } // RFC-4180 quoting: wrap in quotes and double any internal quotes when the field carries a // comma, quote, or newline (the flattened transactions/documents cells are multiline). if (/[",\r\n]/.test(value)) { return `"${value.replace(/"/g, '""')}"`; } return value; } function formatDate(date?: string | Date | null): string { if (!date) { return ''; } // Guard against malformed values: `format` throws `RangeError: Invalid time value` on an Invalid // Date, which would abort the whole CSV export. const parsedDate = new Date(date); return Number.isNaN(parsedDate.getTime()) ? '' : format(parsedDate, 'yyyy-MM-dd'); } function formatNumber(value?: number | null): string { return value == null ? '' : value.toFixed(2); } function formatCount(value?: number | null): string { return value == null ? '' : String(value); } function formatBoolean(value?: boolean | null): string { return value == null ? '' : value ? 'Yes' : 'No'; }