import * as React from "react"; import { cn } from "@/lib/utils"; import { formatCurrency } from "@/lib/format-currency"; import { formatDateShort } from "@/lib/format-date"; import { STATEMENT_RULE, StatementComplianceFooter, StatementDocumentHeader, StatementField, StatementHolderBlock, StatementPage, StatementPageFooter, type StatementComplianceInfo, type StatementHolderBusinessFields, } from "@/components/ui/statement-primitives"; import { StatementTransactionTable, type StatementTransaction, } from "@/components/ui/statement-transaction-table"; /** * StatementDocument - WealthX DS (L4 Template) * * The generated bank statement a broker downloads from Reports & Statements. * Which fields appear is driven by the account type - the field sets below * are transcribed from the `Sample_Open_Banking_statement_.pdf` * references on COMPANY-501 and reviewed against them (2026-08-09). * * Page grammar, shared by every account type: * header → holder | identity fields → balances | detail fields * → linked offset block (mortgage with offset only) → ledger → footer * * What varies per type is only the right-hand columns: `DETAIL_FIELDS` and * the extra balance rows in `BALANCE_EXTRAS`. The ledger is identical for * every type - Debit/Credit/Balance with signed, negative balances on loans * and cards. * * Layer: L4 Template */ // --------------------------------------------------------------------------- // Account types & layout mapping // --------------------------------------------------------------------------- export type StatementAccountType = | "transaction" | "savings" | "business-transaction" | "mortgage" | "mortgage-offset" | "personal-loan" | "credit-card"; export type StatementLayout = "deposit" | "lending" | "credit"; /** Account type → layout family. Add a new account type here and in the field maps below. */ export const ACCOUNT_TYPE_LAYOUT: Record< StatementAccountType, StatementLayout > = { transaction: "deposit", savings: "deposit", "business-transaction": "deposit", mortgage: "lending", "mortgage-offset": "lending", "personal-loan": "lending", "credit-card": "credit", }; export const ACCOUNT_TYPE_LABELS: Record = { transaction: "Transaction account", savings: "Savings account", "business-transaction": "Business transaction account", mortgage: "Mortgage", "mortgage-offset": "Mortgage with offset", "personal-loan": "Personal loan", "credit-card": "Credit card", }; /** CDR product categories as the samples print them. */ const PRODUCT_CATEGORY: Record = { transaction: "Transactions/Savings", savings: "Transactions/Savings", "business-transaction": "Transactions/Savings", mortgage: "Residential Mortgage", "mortgage-offset": "Residential Mortgage", "personal-loan": "Personal Loan", "credit-card": "Credit/Charge Card", }; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export interface StatementOffsetAccount { institutionName?: string; accountName?: string; bsb?: string; accountNo?: string; /** CDR product category of the offset account, e.g. "Transactions/Savings". */ category?: string; } export interface StatementAccount { accountType: StatementAccountType; /** Product name as the institution markets it, e.g. "Complete Home Loan". */ productName?: string; bsb?: string; /** Account number, or masked card number for a credit card. */ accountNo?: string; accountStatus?: string; institutionName?: string; /** * Institution logo, sourced from Basiq's institutions API: pass * `logo.links.full` from `GET https://au-api.basiq.io/public/institutions`, * which serves per-institution SVGs from its own CDN. Some of those files * are drawn on a padded square canvas, so the header sizes by height and * lets the width run. */ institutionLogo?: string; /** ISO date the account was opened. */ dateOpened?: string; /** * Balances are signed as the institution reports them: positive funds on a * deposit account, negative amounts owing on a loan or card. */ openingBalance?: number; closingBalance?: number; availableFunds?: number; // Deposit /** Credit interest rate on a deposit account, as a percentage (e.g. 4.35). */ interestRate?: number; // Lending repaymentType?: string; /** ISO date the loan started. */ loanStartDate?: string; /** ISO date the loan matures. */ loanMaturityDate?: string; /** Lending rate as a percentage (e.g. 6.14). */ lendingRate?: number; /** e.g. "Variable" or "Fixed". */ rateType?: string; /** ISO date a fixed rate expires. */ fixedRateExpiry?: string; minRepaymentAmount?: number; repaymentFrequency?: string; availableRedraw?: number; offsetAccount?: StatementOffsetAccount; // Credit creditLimit?: number; /** Purchase rate as a percentage (e.g. 20.99). */ purchaseRate?: number; /** Cash advance rate as a percentage (e.g. 21.99). */ cashAdvanceRate?: number; } export interface StatementPagination { /** Ledger rows printed on page one, below the summary blocks. */ firstPageRows: number; /** Ledger rows printed on each continuation page. */ rowsPerPage: number; } export interface StatementDocumentProps { account: StatementAccount; transactions: StatementTransaction[]; /** ISO date the statement was generated. */ statementDate: string; statementPeriod: { from: string; to: string }; holder?: { name?: string; address?: string; business?: StatementHolderBusinessFields; }; /** * CDR provenance printed at the foot of the last page. `consentProvidedBy` * defaults to the holder name. */ compliance?: StatementComplianceInfo; /** * Split the ledger across multiple A4 pages. Page one carries the summary * blocks plus `firstPageRows` rows; continuation pages open with a balance * brought forward row; the closing balance and compliance block print on * the last page. */ paginate?: StatementPagination; /** Tenant logo - the white-label slot on the printed page. */ companyLogo?: string; companyName?: string; className?: string; } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- const percent = (rate?: number) => rate === undefined ? undefined : `${rate.toFixed(2)}%`; const money = (value?: number) => value === undefined ? undefined : formatCurrency(value, { decimals: 2 }); const isoDate = (value?: string) => value === undefined ? undefined : formatDateShort(value); const formatBsb = (bsb?: string) => { if (!bsb) return undefined; return bsb.length >= 6 ? `${bsb.slice(0, 3)}-${bsb.slice(3)}` : bsb; }; /** "1 year, 6 months" between the statement date and loan maturity. */ const remainingLoanTerm = (statementDate: string, maturity?: string) => { if (!maturity) return undefined; const from = new Date(statementDate); const to = new Date(maturity); const months = (to.getFullYear() - from.getFullYear()) * 12 + (to.getMonth() - from.getMonth()); if (months <= 0) return undefined; const years = Math.floor(months / 12); const rest = months % 12; const parts = [ years > 0 && `${years} ${years === 1 ? "year" : "years"}`, rest > 0 && `${rest} ${rest === 1 ? "month" : "months"}`, ].filter(Boolean); return parts.join(", "); }; // --------------------------------------------------------------------------- // Field sets - one array per layout family, transcribed from the samples // --------------------------------------------------------------------------- interface FieldContext { account: StatementAccount; statementDate: string; } interface StatementFieldSpec { label: string; value: (ctx: FieldContext) => React.ReactNode; } const DEPOSIT_DETAIL_FIELDS: StatementFieldSpec[] = [ { label: "Interest rate", value: ({ account }) => percent(account.interestRate) }, ]; const LOAN_DETAIL_FIELDS: StatementFieldSpec[] = [ { label: "Repayment type", value: ({ account }) => account.repaymentType }, { label: "Loan start date", value: ({ account }) => isoDate(account.loanStartDate) }, { label: "Loan end date", value: ({ account }) => isoDate(account.loanMaturityDate) }, { label: "Remaining loan term", value: ({ account, statementDate }) => remainingLoanTerm(statementDate, account.loanMaturityDate), }, { label: "Interest rate", value: ({ account }) => percent(account.lendingRate) }, { label: "Rate type", value: ({ account }) => account.rateType }, { label: "Fixed rate expiry", value: ({ account }) => isoDate(account.fixedRateExpiry) }, { label: "Minimum repayment", value: ({ account }) => money(account.minRepaymentAmount) }, { label: "Repayment frequency", value: ({ account }) => account.repaymentFrequency }, ]; const CREDIT_DETAIL_FIELDS: StatementFieldSpec[] = [ { label: "Interest on purchases", value: ({ account }) => percent(account.purchaseRate) }, { label: "Interest on cash advances", value: ({ account }) => percent(account.cashAdvanceRate) }, { label: "Minimum repayment", value: ({ account }) => money(account.minRepaymentAmount) }, { label: "Repayment frequency", value: ({ account }) => account.repaymentFrequency }, ]; /** * Right-hand detail column per account type. A personal loan prints no detail * column in the sample statements - only the balance block. */ const DETAIL_FIELDS: Record = { transaction: DEPOSIT_DETAIL_FIELDS, savings: DEPOSIT_DETAIL_FIELDS, "business-transaction": DEPOSIT_DETAIL_FIELDS, mortgage: LOAN_DETAIL_FIELDS, "mortgage-offset": LOAN_DETAIL_FIELDS, "personal-loan": [], "credit-card": CREDIT_DETAIL_FIELDS, }; /** Rows appended to the shared balance block per account type. */ const BALANCE_EXTRAS: Record = { transaction: [], savings: [], "business-transaction": [], mortgage: [ { label: "Max redraw", value: ({ account }) => money(account.availableRedraw) }, ], "mortgage-offset": [ { label: "Max redraw", value: ({ account }) => money(account.availableRedraw) }, ], "personal-loan": [], "credit-card": [ { label: "Credit limit", value: ({ account }) => money(account.creditLimit) }, ], }; // --------------------------------------------------------------------------- // Sections // --------------------------------------------------------------------------- function FieldColumn({ fields, ctx, }: { fields: StatementFieldSpec[]; ctx: FieldContext; }) { return (
{fields.map((field) => ( ))}
); } function OffsetAccountBlock({ offset }: { offset: StatementOffsetAccount }) { const cells: Array<{ label: string; value?: React.ReactNode }> = [ { label: "Financial institution", value: offset.institutionName }, { label: "Account BSB", value: formatBsb(offset.bsb) }, { label: "Account category", value: offset.category }, { label: "Account name", value: offset.accountName }, { label: "Account number", value: offset.accountNo }, ]; return (
Offset account
{cells.map((cell) => (
{cell.label} {cell.value ?? "Not available"}
))}
); } // --------------------------------------------------------------------------- // StatementDocument // --------------------------------------------------------------------------- export function StatementDocument({ account, transactions, statementDate, statementPeriod, holder, compliance, paginate, companyLogo, companyName, className, }: StatementDocumentProps) { const isCredit = account.accountType === "credit-card"; const isLending = ACCOUNT_TYPE_LAYOUT[account.accountType] === "lending"; const ctx: FieldContext = { account, statementDate }; const period = `${formatDateShort(statementPeriod.from)} - ${formatDateShort( statementPeriod.to, )}`; const totalCredits = transactions .filter((t) => t.amount > 0) .reduce((sum, t) => sum + t.amount, 0); const totalDebits = transactions .filter((t) => t.amount < 0) .reduce((sum, t) => sum + Math.abs(t.amount), 0); const identityFields: Array<{ label: string; value?: React.ReactNode }> = [ { label: "Product name", value: account.productName }, ...(isCredit ? [] : [{ label: "Account BSB", value: formatBsb(account.bsb) }]), { label: "Account/card number", value: account.accountNo }, { label: "Account type", value: ACCOUNT_TYPE_LABELS[account.accountType] }, { label: "Product category", value: PRODUCT_CATEGORY[account.accountType] }, { label: "Account status", value: account.accountStatus ?? "Open" }, { label: "Account opened date", value: isoDate(account.dateOpened) }, { label: "Statement period", value: period }, ]; const balanceFields: Array<{ label: string; value?: React.ReactNode; emphasis?: boolean; }> = [ { label: "Opening balance", value: money(account.openingBalance) }, { label: "Total credits", value: money(totalCredits) }, { label: "Total debits", value: money(totalDebits) }, { label: "Closing balance", value: money(account.closingBalance), emphasis: true }, { label: "Available balance", value: money(account.availableFunds) }, ...BALANCE_EXTRAS[account.accountType].map((field) => ({ label: field.label, value: field.value(ctx), })), ]; const detailFields = DETAIL_FIELDS[account.accountType]; const reference = [holder?.name, account.productName] .filter(Boolean) .join(" - "); const complianceInfo: StatementComplianceInfo | undefined = compliance && { consentProvidedBy: holder?.name, ...compliance, }; const emptyMessage = isLending ? "No repayments or interest charges for this statement period." : "No transactions for this statement period."; const chunks: StatementTransaction[][] = []; if (paginate && transactions.length > paginate.firstPageRows) { chunks.push(transactions.slice(0, paginate.firstPageRows)); for ( let i = paginate.firstPageRows; i < transactions.length; i += paginate.rowsPerPage ) { chunks.push(transactions.slice(i, i + paginate.rowsPerPage)); } } else { chunks.push(transactions); } const pageCount = chunks.length; const firstPageSections = ( <>
{identityFields.map((field) => ( ))}
{balanceFields.map((field) => ( ))}
{detailFields.length > 0 && }
{account.offsetAccount && (
)} ); if (pageCount === 1) { return ( {firstPageSections}
{complianceInfo && }
); } return (
{chunks.map((chunk, index) => { const isFirst = index === 0; const isLast = index === pageCount - 1; const broughtForward = isFirst ? account.openingBalance : chunks[index - 1][chunks[index - 1].length - 1]?.balance; return ( {isFirst && firstPageSections}
{isLast && complianceInfo && ( )}
); })}
); }