From c52e297ef1736cf2198593bb68f2fce105af01d8 Mon Sep 17 00:00:00 2001 From: Sridhar Bala Date: Sat, 17 Oct 2026 08:31:56 +0530 Subject: [PATCH 1/2] Restore desktop shortcuts and add tax reporting calculation foundation --- api/src/services/tax-reporting/decimal.js | 41 ++++ api/src/services/tax-reporting/ledger.js | 177 +++++++++++++++ api/src/services/tax-reporting/period.js | 22 ++ .../services/tax-reporting-ledger.test.js | 203 ++++++++++++++++++ docs/TAX_REPORTING_CORE.md | 88 ++++++++ frontend/dashboard.html | 155 ++++++++++++- frontend/modals/tableorder.html | 2 +- tests/desktop-menu-actions.test.js | 77 +++++-- 8 files changed, 749 insertions(+), 16 deletions(-) create mode 100644 api/src/services/tax-reporting/decimal.js create mode 100644 api/src/services/tax-reporting/ledger.js create mode 100644 api/src/services/tax-reporting/period.js create mode 100644 api/tests/unit/services/tax-reporting-ledger.test.js create mode 100644 docs/TAX_REPORTING_CORE.md diff --git a/api/src/services/tax-reporting/decimal.js b/api/src/services/tax-reporting/decimal.js new file mode 100644 index 00000000..171a5d6e --- /dev/null +++ b/api/src/services/tax-reporting/decimal.js @@ -0,0 +1,41 @@ +'use strict'; + +// Reporting uses exact decimal strings. Never silently round a source value or +// coerce missing/invalid evidence to zero. JSON output must not contain BigInt. +function decimal(value) { + if (typeof value !== 'string' || !/^-?\d{1,30}(?:\.\d{1,12})?$/.test(value)) { + throw new TypeError('Expected a decimal string with at most 12 decimal places'); + } + const [whole, fraction = ''] = value.split('.'); + return { units: BigInt(whole + fraction), scale: fraction.length }; +} + +function format(units, scale) { + const sign = units < 0n ? '-' : ''; + const digits = (units < 0n ? -units : units).toString().padStart(scale + 1, '0'); + if (!scale) return sign + digits; + return (sign + digits.slice(0, -scale) + '.' + digits.slice(-scale)) + .replace(/0+$/, '') + .replace(/\.$/, ''); +} + +function sum(values) { + const parsed = values.map(decimal); + const scale = parsed.reduce((maximum, n) => Math.max(maximum, n.scale), 0); + return format( + parsed.reduce((total, n) => total + n.units * 10n ** BigInt(scale - n.scale), 0n), + scale + ); +} + +function negate(value) { + const n = decimal(value); + return format(-n.units, n.scale); +} + +function nonNegative(value) { + if (decimal(value).units < 0n) throw new RangeError('Expected a non-negative source amount'); + return sum([value]); +} + +module.exports = { sum, negate, nonNegative }; diff --git a/api/src/services/tax-reporting/ledger.js b/api/src/services/tax-reporting/ledger.js new file mode 100644 index 00000000..0f94fa40 --- /dev/null +++ b/api/src/services/tax-reporting/ledger.js @@ -0,0 +1,177 @@ +'use strict'; + +const { sum, negate, nonNegative } = require('./decimal'); +const { calendarPeriod } = require('./period'); +const moment = require('moment-timezone'); + +const SOURCES = ['sales', 'purchases', 'salesAdjustments', 'purchaseAdjustments', 'expenses']; +const TYPES = { + sale: ['sales', 'sales', 1], + purchase: ['purchases', 'purchases', 1], + saleCredit: ['salesAdjustments', 'sales', -1], + saleDebit: ['salesAdjustments', 'sales', 1], + purchaseCredit: ['purchaseAdjustments', 'purchases', -1], + purchaseDebit: ['purchaseAdjustments', 'purchases', 1], + expense: ['expenses', 'expenses', 1], +}; +const NATURES = ['taxable', 'exempt', 'nilRated', 'nonTax', 'zeroRated', 'unknown']; + +function required(value, name) { + if (typeof value !== 'string' || !value.trim()) throw new TypeError(name + ' is required'); + return value; +} + +function instant(value) { + if (typeof value !== 'string' || !/^\d{4}-\d\d-\d\dT.*(?:Z|[+-]\d\d:\d\d)$/.test(value)) { + throw new TypeError('Document date requires an explicit UTC offset'); + } + const result = moment.parseZone(value, moment.ISO_8601, true); + if (!result.isValid()) throw new TypeError('Invalid document date'); + return result.toDate(); +} + +/** + * Pure aggregation boundary for source adapters. Scope comes from an authorised + * server context; callers must also scope their database queries. Input records + * are immutable document snapshots, not unwound rows or current item masters. + * This function neither grants credit nor prepares a statutory return. + */ +function buildLedger({ scope, period: requestedPeriod, sources, documents }) { + for (const key of ['tenantId', 'registrationId', 'currency']) required(scope[key], key); + if (!Array.isArray(scope.branchIds) || !scope.branchIds.length) { + throw new TypeError('At least one authorised branch is required'); + } + scope.branchIds.forEach((id) => required(id, 'branchId')); + const period = calendarPeriod(requestedPeriod); + const exceptions = []; + const coverage = SOURCES.map((name) => { + const source = sources?.[name]; + const complete = + source?.status === 'complete' && typeof source.watermark === 'string' && !!source.watermark; + if (!complete) exceptions.push({ code: 'SOURCE_INCOMPLETE', source: name }); + return { source: name, complete, watermark: source?.watermark || null }; + }); + const registers = { sales: [], purchases: [], expenses: [] }; + const seen = new Set(); + const excluded = []; + + for (const document of documents) { + const type = TYPES[document.type]; + if (!type) throw new TypeError('Unsupported tax document type'); + for (const key of ['tenantId', 'registrationId', 'currency']) { + if (document[key] !== scope[key]) throw new Error('Document is outside report scope: ' + key); + } + if (!scope.branchIds.includes(document.branchId)) + throw new Error('Unauthorised document branch'); + const sourceId = required(document.sourceId, 'sourceId'); + required(document.revision, 'revision'); + const identity = JSON.stringify([type[0], document.branchId, sourceId]); + if (seen.has(identity)) throw new Error('Duplicate source document: ' + sourceId); + seen.add(identity); + const date = instant(document.documentDate); + if (date < period.start || date >= period.endExclusive) { + excluded.push({ sourceId, source: type[0], reason: 'OUTSIDE_PERIOD' }); + continue; + } + // Source adapters must explicitly resolve holds, cancellations and deletes. + // An unknown status cannot silently become either an invoice or a reversal. + if (document.status !== 'posted') throw new Error('Unresolved document status: ' + sourceId); + if (type[0].endsWith('Adjustments')) required(document.originalSourceId, 'originalSourceId'); + const sign = (value) => (type[2] < 0 ? negate(nonNegative(value)) : nonNegative(value)); + const gross = sign(document.gross); + const row = { + sourceId, + source: type[0], + type: document.type, + revision: document.revision, + branchId: document.branchId, + documentDate: document.documentDate, + originalSourceId: document.originalSourceId || null, + gross, + lines: [], + }; + if (document.type === 'expense' && document.taxEvidence === 'notRecorded') { + row.net = null; + row.tax = null; + row.taxEvidence = 'notRecorded'; + exceptions.push({ code: 'EXPENSE_TAX_NOT_RECORDED', sourceId }); + } else { + if (!Array.isArray(document.lines) || !document.lines.length) { + throw new TypeError('Document lines are required: ' + sourceId); + } + const lineIds = new Set(); + row.lines = document.lines.map((line) => { + const lineId = required(line.lineId, 'lineId'); + if (lineIds.has(lineId)) throw new Error('Duplicate line identity: ' + sourceId); + lineIds.add(lineId); + const net = sign(line.net); + const tax = sign(line.tax); + const quantity = sign(line.quantity); + if (!NATURES.includes(line.nature)) throw new TypeError('Supply nature must be explicit'); + if (line.nature === 'unknown') + exceptions.push({ code: 'SUPPLY_NATURE_UNKNOWN', sourceId, lineId }); + // Missing classification is retained. Never fetch a current HSN/rate. + return { + lineId, + net, + tax, + quantity, + nature: line.nature, + hsn: line.hsn || null, + unit: line.unit || null, + }; + }); + row.net = sum(row.lines.map((line) => line.net)); + row.tax = sum(row.lines.map((line) => line.tax)); + // Round-off is a signed source value, unlike gross and line magnitudes. + row.adjustment = + type[2] < 0 ? negate(document.adjustment ?? '0') : sum([document.adjustment ?? '0']); + const difference = sum([gross, negate(row.net), negate(row.tax), negate(row.adjustment)]); + row.difference = difference; + if (difference !== '0') + exceptions.push({ code: 'DOCUMENT_TOTAL_MISMATCH', sourceId, difference }); + } + registers[type[1]].push(row); + } + + const observedTotals = Object.fromEntries( + Object.entries(registers).map(([name, rows]) => [ + name, + { + documents: rows.length, + gross: sum(rows.map((row) => row.gross)), + net: rows.some((row) => row.net === null) ? null : sum(rows.map((row) => row.net)), + tax: rows.some((row) => row.tax === null) ? null : sum(rows.map((row) => row.tax)), + }, + ]) + ); + const totals = Object.fromEntries( + Object.entries(observedTotals).map(([name, values]) => { + const dependencies = + name === 'expenses' + ? ['expenses'] + : [name, name === 'sales' ? 'salesAdjustments' : 'purchaseAdjustments']; + const complete = dependencies.every( + (source) => coverage.find((entry) => entry.source === source).complete + ); + return [name, complete ? values : { documents: null, gross: null, net: null, tax: null }]; + }) + ); + return { + schemaVersion: 1, + scope: { ...scope, branchIds: [...scope.branchIds] }, + period, + status: exceptions.length ? 'needsAttention' : 'readyForReview', + coverage, + registers, + totals, + observedTotals, + exceptions, + excluded, + recordedPurchaseTax: totals.purchases.tax, + reviewedEligibleCredit: null, + filingReady: false, + }; +} + +module.exports = { buildLedger, SOURCES }; diff --git a/api/src/services/tax-reporting/period.js b/api/src/services/tax-reporting/period.js new file mode 100644 index 00000000..f9a74a44 --- /dev/null +++ b/api/src/services/tax-reporting/period.js @@ -0,0 +1,22 @@ +'use strict'; + +const moment = require('moment-timezone'); + +// A calendar period belongs to the registration timezone, not the server's. +function calendarPeriod({ from, through, timezone }) { + if (!moment.tz.zone(timezone)) throw new RangeError('A valid reporting timezone is required'); + const start = moment.tz(from, 'YYYY-MM-DD', true, timezone); + const last = moment.tz(through, 'YYYY-MM-DD', true, timezone); + if (!start.isValid() || !last.isValid() || last.isBefore(start)) { + throw new RangeError('A valid inclusive calendar date range is required'); + } + return { + from, + through, + timezone, + start: start.toDate(), + endExclusive: last.add(1, 'day').startOf('day').toDate(), + }; +} + +module.exports = { calendarPeriod }; diff --git a/api/tests/unit/services/tax-reporting-ledger.test.js b/api/tests/unit/services/tax-reporting-ledger.test.js new file mode 100644 index 00000000..ff8d9b95 --- /dev/null +++ b/api/tests/unit/services/tax-reporting-ledger.test.js @@ -0,0 +1,203 @@ +'use strict'; + +const { buildLedger, SOURCES } = require('../../../src/services/tax-reporting/ledger'); +const { calendarPeriod } = require('../../../src/services/tax-reporting/period'); +const { sum, negate } = require('../../../src/services/tax-reporting/decimal'); + +const scope = { + tenantId: 'tenant-a', + registrationId: 'registration-a', + currency: 'INR', + branchIds: ['branch-a'], +}; +const period = { from: '2024-02-01', through: '2024-02-29', timezone: 'Asia/Kolkata' }; +function document(overrides = {}) { + return { + ...scope, + branchId: 'branch-a', + sourceId: 'invoice-1', + revision: 'revision-1', + type: 'sale', + status: 'posted', + documentDate: '2024-02-15T10:00:00+05:30', + gross: '118', + lines: [ + { lineId: '1', quantity: '0.125', net: '100', tax: '18', nature: 'taxable', unit: 'kg' }, + ], + ...overrides, + }; +} +function run(documents, overrides = {}) { + return buildLedger({ + scope, + period, + sources: Object.fromEntries( + SOURCES.map((name) => [name, { status: 'complete', watermark: 'cutoff-1' }]) + ), + documents, + ...overrides, + }); +} + +describe('tax reporting exact decimals', () => { + test('keeps source precision and does not use floating point summation', () => { + expect(sum(['0.1', '0.2', '0.000001'])).toBe('0.300001'); + expect(sum(['999999999999999999.99', '0.01'])).toBe('1000000000000000000'); + expect(negate('-0.125')).toBe('0.125'); + }); + test.each([null, undefined, '', 'NaN', '1,000', '1e3', 1, '0.0000000000001'])( + 'rejects invalid evidence %s', + (value) => { + expect(() => sum([value])).toThrow(); + } + ); + test('sums more than a spreadsheet page without an argument-stack limit', () => { + expect(sum(Array(150000).fill('0.01'))).toBe('1500'); + }); +}); + +describe('tax calendar boundaries', () => { + test('leap February ends at local March midnight, exclusively', () => { + const p = calendarPeriod(period); + expect(p.start.toISOString()).toBe('2024-01-31T18:30:00.000Z'); + expect(p.endExclusive.toISOString()).toBe('2024-02-29T18:30:00.000Z'); + }); + test('daylight saving is a calendar day, not an added 24 hours', () => { + const p = calendarPeriod({ + from: '2024-03-10', + through: '2024-03-10', + timezone: 'America/New_York', + }); + expect(p.endExclusive - p.start).toBe(23 * 60 * 60 * 1000); + }); + test.each([{ through: '2024-02-31' }, { timezone: 'invalid' }, { from: '2024-03-01' }])( + 'rejects invalid period %j', + (change) => { + expect(() => calendarPeriod({ ...period, ...change })).toThrow(); + } + ); +}); + +describe('canonical tax ledger contract', () => { + test('equal-valued purchases remain separate documents and both count', () => { + const result = run([ + document({ type: 'purchase' }), + document({ type: 'purchase', sourceId: 'invoice-2' }), + ]); + expect(result.totals.purchases).toEqual({ documents: 2, net: '200', tax: '36', gross: '236' }); + expect(result.recordedPurchaseTax).toBe('36'); + expect(result.reviewedEligibleCredit).toBeNull(); + expect(result.filingReady).toBe(false); + }); + test('mixed rate invoice gross counts once; fractional units survive', () => { + const first = document(); + const result = run([ + document({ + gross: '223', + lines: [ + ...first.lines, + { lineId: '2', quantity: '1.75', unit: 'meter', net: '100', tax: '5', nature: 'taxable' }, + ], + }), + ]); + expect(result.totals.sales).toEqual({ documents: 1, net: '200', tax: '23', gross: '223' }); + expect(result.registers.sales[0].lines.map((line) => line.quantity)).toEqual(['0.125', '1.75']); + }); + test('source edit date never moves an invoice across reporting periods', () => { + expect(run([document({ updated_date: '2024-06-01' })]).totals.sales.gross).toBe('118'); + }); + test('includes start, excludes next period start', () => { + const result = run([ + document({ documentDate: '2024-01-31T18:30:00Z' }), + document({ sourceId: 'next', documentDate: '2024-02-29T18:30:00Z' }), + ]); + expect(result.totals.sales.documents).toBe(1); + expect(result.excluded).toEqual([ + { sourceId: 'next', source: 'sales', reason: 'OUTSIDE_PERIOD' }, + ]); + }); + test('later credit note reverses in its own period with original reference', () => { + const result = run([ + document({ documentDate: '2024-01-15T10:00:00Z' }), + document({ type: 'saleCredit', sourceId: 'credit-1', originalSourceId: 'invoice-1' }), + ]); + expect(result.totals.sales).toEqual({ documents: 1, net: '-100', tax: '-18', gross: '-118' }); + expect(result.registers.sales[0].originalSourceId).toBe('invoice-1'); + }); + test('negative source round-off reconciles without changing tax', () => { + const result = run([document({ gross: '117.99', adjustment: '-0.01' })]); + expect(result.registers.sales[0].difference).toBe('0'); + expect(result.status).toBe('readyForReview'); + }); + test('missing expense tax stays unknown, never becomes credit', () => { + const result = run([ + document({ type: 'expense', taxEvidence: 'notRecorded', lines: undefined }), + ]); + expect(result.totals.expenses).toEqual({ documents: 1, gross: '118', net: null, tax: null }); + expect(result.status).toBe('needsAttention'); + expect(result.reviewedEligibleCredit).toBeNull(); + }); + test('a failed or absent source cannot produce a complete report', () => { + const result = run([], { sources: { sales: { status: 'failed' } } }); + expect(result.coverage.every((entry) => !entry.complete)).toBe(true); + expect(result.exceptions).toHaveLength(5); + expect(result.status).toBe('needsAttention'); + expect(result.totals.purchases.tax).toBeNull(); + expect(result.recordedPurchaseTax).toBeNull(); + }); + test('missing watermark is incomplete even if the query says complete', () => { + expect(run([], { sources: { sales: { status: 'complete' } } }).coverage[0].complete).toBe( + false + ); + }); + test('totals mismatch is exposed and retained for review', () => { + const result = run([document({ gross: '120' })]); + expect(result.exceptions).toContainEqual({ + code: 'DOCUMENT_TOTAL_MISMATCH', + sourceId: 'invoice-1', + difference: '2', + }); + expect(result.totals.sales.gross).toBe('120'); + }); + test.each(['tenantId', 'registrationId', 'currency', 'branchId'])( + 'rejects out-of-scope %s', + (key) => { + expect(() => run([document({ [key]: 'other' })])).toThrow(); + } + ); + test('rejects duplicate documents instead of silently double counting', () => { + expect(() => run([document(), document()])).toThrow('Duplicate source document'); + }); + test('rejects duplicate line identities without collapsing equal-valued distinct lines', () => { + const doc = document(); + expect(() => run([document({ lines: [...doc.lines, ...doc.lines] })])).toThrow( + 'Duplicate line' + ); + }); + test.each([ + { type: 'saleCredit' }, + { status: 'cancelled' }, + { documentDate: '2024-02-01' }, + { documentDate: '2024-02-31T00:00:00Z' }, + { gross: null }, + { lines: [] }, + ])('rejects unresolved source contract %j', (change) => { + expect(() => run([document(change)])).toThrow(); + }); + test('unknown supply nature stays visible and never guesses exemption', () => { + const doc = document(); + doc.lines[0].nature = 'unknown'; + const result = run([doc]); + expect(result.exceptions).toContainEqual({ + code: 'SUPPLY_NATURE_UNKNOWN', + sourceId: 'invoice-1', + lineId: '1', + }); + }); + test('does not modify source documents and produces JSON-safe output', () => { + const doc = document(); + const before = JSON.stringify(doc); + expect(() => JSON.stringify(run([doc]))).not.toThrow(); + expect(JSON.stringify(doc)).toBe(before); + }); +}); diff --git a/docs/TAX_REPORTING_CORE.md b/docs/TAX_REPORTING_CORE.md new file mode 100644 index 00000000..29f4b116 --- /dev/null +++ b/docs/TAX_REPORTING_CORE.md @@ -0,0 +1,88 @@ +# Tax reporting core: first implementation increment + +17 October 2026. Implements the first calculation boundary from +[Tax & Compliance roadmap](TAX_COMPLIANCE_ROADMAP.md). This is an internal, +tested module, not yet a customer report, endpoint, accountant pack or filing +export. The legacy reports remain in place until replacement reconciliation +passes. No customer records were changed to develop or test this increment. + +## Implemented + +`api/src/services/tax-reporting/ledger.js` accepts canonical document snapshots +and a server-authorised tenant, registration, branch and currency scope. It +rejects mismatched scope, duplicate document/line identities, unresolved status, +missing amounts and adjustments without original-document references. + +- Exact decimal strings preserve up to 12 fractional places. No floating-point + summation, missing-to-zero conversion or rounding at every row. +- Inclusive local calendar dates become UTC start / exclusive next-day end. + The registration's timezone controls boundaries, including daylight saving. +- Document dates determine the period; modification dates are not consulted. +- Gross values count once per document; line net/tax are summed independently. +- Credits reverse magnitudes once; debits add them. Source adapters must resolve + cancellation/deletion/return relationships before passing posted documents. +- Signed round-off is separate from line tax. Differences remain visible. +- Cash Book expenses without recorded tax retain gross amounts and null net/tax. +- Purchase tax is recorded tax. Reviewed eligible credit remains null. +- Source failure, absent source or missing watermark marks coverage incomplete. + Affected totals are null. `observedTotals` separately describes only the rows + that arrived, and must never be presented as complete-period totals. +- Unknown supply nature remains an exception; HSN and units are never fetched + from today's product master. Quantities remain decimal strings. + +`readyForReview` means only that this boundary found no exceptions. It does not +mean accountant-reviewed, filed, statutory-export-ready or independently verified +against an official statement. `filingReady` is always false in this increment. + +## Source adapter inventory and unresolved evidence + +| Source | Current implementation | Required next step | +| ---------------- | ---------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| Sales | `sale.model.js`, `sale.service.js`, `sale.repository.js`; stored `date`, invoice totals and item snapshots | Map actual legacy/current line formats; document treatment of holds, voids, deletion and discounts; preserve component tax | +| Purchases | `receiving.model.js`, receiving controller/model write paths | Support both legacy PHP fields and current schema without treating one layout's missing values as zero | +| Sales credits | Nested return structures on sales | Stable event identity, return date and original-invoice link; verify partial/repeated returns | +| Purchase credits | `receiving-return.model.js` mutates/removes original purchase lines and writes return evidence | Reconstruct only from verifiable evidence; flag unrecoverable originals; never reverse both the residual purchase and its original full amount | +| Expenses | `expense.model.js`: date, amount, type, category, recipient, description; attachments added separately | Preserve legacy tax as not recorded; distinguish movements and purchase settlements; do not infer classification from category text | +| Registration | Branch settings contain tax/regional data | Effective-dated registration-to-branch membership and authorised scope resolver still required | + +Canonical input IDs, revision, status, dates and scope are mandatory. Monetary +fields and quantities are decimal strings, not formatted amounts. Credits carry +positive source magnitudes; the document type supplies their sign. Round-off +may be signed. Distinct invoices with equal amounts must keep distinct IDs. +Do not split one document into repeated header rows before invoking this core. + +Source completeness and watermark assertions must be produced by trusted +adapters after reading the full bounded source, not accepted from a browser. +Adapters must query within authorised tenant/branch scope as well as use the +defensive scope check in this core. The core does not establish user permissions. + +## Synchronisation decision + +This increment is pure computation: no collections, persisted fields, write or +delete paths, jobs, or external messages. Consequently it requires no new sync +lane or migration. Existing `sales`, `receivings` and `expenses` are branch-scoped +in POS `api/src/sync/collections.json`, Gateway +`apps/sync-gateway/src/collections.js`, and the agent +`apps/sync-agent/sync-config.json`; `branches` is global in the POS/Gateway maps. +These definitions were inspected, not treated as proof of live delivery. + +Cloud, originating till and other authorised tills can calculate the same +result only from the same source snapshots and cutoff. Offline or late sources +must remain incomplete until reconciled. The future run/export persistence needs +its own scope, file storage, sync and recovery design before it is shipped. +No physical till sync or reconnection test has been performed for this core. + +## Remaining before the first usable accountant pack + +1. Implement and test actual source adapters, component tax summaries, + cancellations/returns and source cutoffs; reproduce defects against database + fixtures, not just canonical inputs. +2. Add registration settings and server-side permissions for view/export/review. +3. Persist immutable report runs and resumable export jobs with sync decisions. +4. Build the approved Overview, Sales, Purchases, Adjustments, Expenses, + Exceptions and Exports screens; Excel/PDF pack with manifest and safe cells. +5. Reconcile source, report and export totals, including 100,000-line fixtures, + failure/retry, branch isolation, offline/late sync and accountant review. + +Official statement import and country-specific statutory mappings remain later +roadmap increments. This core introduces no statutory tax rule or current rate. diff --git a/frontend/dashboard.html b/frontend/dashboard.html index 37c5543f..4d15e0b9 100644 --- a/frontend/dashboard.html +++ b/frontend/dashboard.html @@ -1012,7 +1012,160 @@
Do you want remove t } }); - +