Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions apps/api/src/export.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { describe, it, expect } from 'vitest'
import {
tableToCsv,
trialBalanceTable,
buildTallyXml,
tallyParentForType,
type TrialBalance,
type TallyVoucher,
} from '@opensociety/shared'

describe('tableToCsv', () => {
it('serializes rows and escapes commas/quotes/newlines', () => {
const csv = tableToCsv([
['Account', 'Amount'],
['Bank', 1234.5],
['A, B', 'say "hi"'],
[null, ''],
])
expect(csv).toBe('Account,Amount\nBank,1234.5\n"A, B","say ""hi"""\n,')
})
})

describe('trialBalanceTable', () => {
it('renders header + rows + total in rupees', () => {
const tb: TrialBalance = {
rows: [{ code: '1001', name: 'Bank', type: 'ASSET', debit: 218000, credit: 0 }],
totalDebit: 218000,
totalCredit: 218000,
balanced: true,
}
const t = trialBalanceTable(tb)
expect(t.rows[0]).toEqual(['Code', 'Account', 'Type', 'Debit', 'Credit'])
expect(t.rows[1]).toEqual(['1001', 'Bank', 'ASSET', 2180, 0]) // paise -> rupees
expect(t.rows[2]).toEqual(['', 'Total', '', 2180, 2180])
})
})

describe('buildTallyXml', () => {
const vouchers: TallyVoucher[] = [
{
date: '2026-07-05',
type: 'Receipt',
narration: 'Payment received',
entries: [
{ ledger: 'Bank — Primary', debit: 100000, credit: 0 },
{ ledger: 'Member Dues Receivable', debit: 0, credit: 100000 },
],
},
]
const xml = buildTallyXml(vouchers, [{ name: 'Bank — Primary', parent: 'Bank Accounts' }])

it('emits a Tally import envelope with a Receipt voucher', () => {
expect(xml).toContain('<TALLYREQUEST>Import Data</TALLYREQUEST>')
expect(xml).toContain('<VOUCHER VCHTYPE="Receipt" ACTION="Create">')
expect(xml).toContain('<DATE>20260705</DATE>')
})

it('signs debits negative and credits positive (Tally convention)', () => {
expect(xml).toContain('<ISDEEMEDPOSITIVE>Yes</ISDEEMEDPOSITIVE><AMOUNT>-1000.00</AMOUNT>')
expect(xml).toContain('<ISDEEMEDPOSITIVE>No</ISDEEMEDPOSITIVE><AMOUNT>1000.00</AMOUNT>')
})

it('includes ledger masters with a parent group', () => {
expect(xml).toContain('<LEDGER NAME="Bank — Primary" ACTION="Create">')
expect(xml).toContain('<PARENT>Bank Accounts</PARENT>')
})
})

describe('tallyParentForType', () => {
it('maps account types to Tally groups', () => {
expect(tallyParentForType('ASSET')).toBe('Current Assets')
expect(tallyParentForType('INCOME')).toBe('Indirect Incomes')
expect(tallyParentForType('FUND')).toBe('Capital Account')
})
})
27 changes: 27 additions & 0 deletions apps/api/src/lib/report-export.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { tableToCsv, type Table } from '@opensociety/shared'
import { buildXlsx } from './xlsx'

// Return a report Table as an .xlsx or .csv download when ?format is set, else
// null so the caller falls back to its JSON response (#99).
export function tableExport(format: string | undefined, table: Table, baseName: string): Response | null {
if (format === 'xlsx') {
const buf = buildXlsx([table])
const body = new ArrayBuffer(buf.byteLength)
new Uint8Array(body).set(buf)
return new Response(body, {
headers: {
'content-type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'content-disposition': `attachment; filename="${baseName}.xlsx"`,
},
})
}
if (format === 'csv') {
return new Response(tableToCsv(table.rows), {
headers: {
'content-type': 'text/csv; charset=utf-8',
'content-disposition': `attachment; filename="${baseName}.csv"`,
},
})
}
return null
}
168 changes: 168 additions & 0 deletions apps/api/src/lib/xlsx.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
// Dependency-free, Cloudflare-Workers-safe .xlsx writer (#99). Real OOXML
// SpreadsheetML zipped with STORED (uncompressed) entries, so no zip/deflate
// library is needed — just a CRC32. Not a general xlsx lib: inline strings,
// numbers, one style-free sheet per tab. Good enough for report exports.

export type Cell = string | number | null | undefined
export type Sheet = { name: string; rows: Cell[][] }

const enc = new TextEncoder()

// ----- CRC32 (IEEE 802.3, poly 0xEDB88320) -----
const CRC_TABLE = (() => {
const t = new Uint32Array(256)
for (let n = 0; n < 256; n++) {
let c = n
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
t[n] = c >>> 0
}
return t
})()

function crc32(bytes: Uint8Array): number {
let c = 0xffffffff
for (let i = 0; i < bytes.length; i++) c = CRC_TABLE[(c ^ bytes[i]) & 0xff] ^ (c >>> 8)
return (c ^ 0xffffffff) >>> 0
}

function escapeXml(s: string): string {
return s.replace(/[<>&'"]/g, (ch) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', "'": '&apos;', '"': '&quot;' })[ch]!)
}

// 0-based column index -> spreadsheet letter (0 -> A, 26 -> AA).
function colLetter(i: number): string {
let s = ''
let n = i
do {
s = String.fromCharCode(65 + (n % 26)) + s
n = Math.floor(n / 26) - 1
} while (n >= 0)
return s
}

function sheetXml(rows: Cell[][]): string {
const body = rows
.map((row, r) => {
const cells = row
.map((val, c) => {
const ref = `${colLetter(c)}${r + 1}`
if (val === null || val === undefined || val === '') return `<c r="${ref}"/>`
if (typeof val === 'number' && Number.isFinite(val)) return `<c r="${ref}"><v>${val}</v></c>`
return `<c r="${ref}" t="inlineStr"><is><t xml:space="preserve">${escapeXml(String(val))}</t></is></c>`
})
.join('')
return `<row r="${r + 1}">${cells}</row>`
})
.join('')
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData>${body}</sheetData></worksheet>`
}

function contentTypesXml(n: number): string {
const overrides = Array.from(
{ length: n },
(_, i) =>
`<Override PartName="/xl/worksheets/sheet${i + 1}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>`,
).join('')
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>${overrides}</Types>`
}

function workbookXml(sheets: Sheet[]): string {
const els = sheets
.map((s, i) => `<sheet name="${escapeXml(s.name).slice(0, 31)}" sheetId="${i + 1}" r:id="rId${i + 1}"/>`)
.join('')
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets>${els}</sheets></workbook>`
}

function workbookRelsXml(n: number): string {
const rels = Array.from(
{ length: n },
(_, i) =>
`<Relationship Id="rId${i + 1}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet${i + 1}.xml"/>`,
).join('')
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">${rels}</Relationships>`
}

const ROOT_RELS = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>`

// ----- Minimal STORED zip -----
type ZipFile = { name: string; data: Uint8Array }

function u16(n: number): number[] {
return [n & 0xff, (n >>> 8) & 0xff]
}
function u32(n: number): number[] {
return [n & 0xff, (n >>> 8) & 0xff, (n >>> 16) & 0xff, (n >>> 24) & 0xff]
}

function zipStored(files: ZipFile[]): Uint8Array {
const chunks: number[] = []
const central: number[] = []
let offset = 0
for (const f of files) {
const nameBytes = enc.encode(f.name)
const crc = crc32(f.data)
const size = f.data.length
// Local file header
const local = [
...u32(0x04034b50),
...u16(20),
...u16(0),
...u16(0), // method 0 = stored
...u16(0),
...u16(0), // mod time/date
...u32(crc),
...u32(size),
...u32(size),
...u16(nameBytes.length),
...u16(0),
]
chunks.push(...local, ...nameBytes, ...f.data)
// Central directory record
central.push(
...u32(0x02014b50),
...u16(20),
...u16(20),
...u16(0),
...u16(0),
...u16(0),
...u16(0),
...u32(crc),
...u32(size),
...u32(size),
...u16(nameBytes.length),
...u16(0),
...u16(0),
...u16(0),
...u16(0),
...u32(0),
...u32(offset),
...Array.from(nameBytes),
)
offset += local.length + nameBytes.length + size
}
const centralOffset = offset
const eocd = [
...u32(0x06054b50),
...u16(0),
...u16(0),
...u16(files.length),
...u16(files.length),
...u32(central.length),
...u32(centralOffset),
...u16(0),
]
return Uint8Array.from([...chunks, ...central, ...eocd])
}

// Build a real .xlsx workbook from one or more named sheets of cell rows.
export function buildXlsx(sheets: Sheet[]): Uint8Array {
const tabs = sheets.length ? sheets : [{ name: 'Sheet1', rows: [] }]
const files: ZipFile[] = [
{ name: '[Content_Types].xml', data: enc.encode(contentTypesXml(tabs.length)) },
{ name: '_rels/.rels', data: enc.encode(ROOT_RELS) },
{ name: 'xl/workbook.xml', data: enc.encode(workbookXml(tabs)) },
{ name: 'xl/_rels/workbook.xml.rels', data: enc.encode(workbookRelsXml(tabs.length)) },
...tabs.map((s, i) => ({ name: `xl/worksheets/sheet${i + 1}.xml`, data: enc.encode(sheetXml(s.rows)) })),
]
return zipStored(files)
}
5 changes: 4 additions & 1 deletion apps/api/src/routes/bills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,12 @@ import {
periodMonthOf,
ACCOUNT_CODES,
INTEREST_BILL_TITLE_PREFIX as INTEREST_TITLE_PREFIX,
duesTable,
} from '@opensociety/shared'
import { withDb, withAuth, requireAuth, requireRole, actingUserId } from '../middleware'
import { generateMonthlyBills } from '../lib/generate-bills'
import { safePostBill, safeReverseBill, resolveAccounts } from '../lib/ledger-posting'
import { tableExport } from '../lib/report-export'
import { renderInvoicePdf } from '../lib/invoice-pdf'
import type { AppEnv } from '../types'

Expand Down Expand Up @@ -133,7 +135,8 @@ billRoutes.get('/dues', requireRole('ADMIN'), async (c) => {
})
.filter((r) => r.outstanding > 0)
.sort((a, b) => b.outstanding - a.outstanding)
return c.json(rows)
const exp = tableExport(c.req.query('format'), duesTable(rows), 'outstanding-dues')
return exp ?? c.json(rows)
})

// Per-apartment accrued simple interest on overdue, unpaid, non-interest bills
Expand Down
Loading
Loading