diff --git a/api/src/models/user.model.js b/api/src/models/user.model.js index ad5b56669..fccebd25f 100644 --- a/api/src/models/user.model.js +++ b/api/src/models/user.model.js @@ -1040,6 +1040,20 @@ userSchema.statics.userInsertUpdate = async function (data, id, context) { const verify_password = data.check_password; let password = ''; if (verify_password === 'yes' && data.usertype !== 'api') { + if ( + typeof data.password !== 'string' || + data.password.length < 5 || + data.password.length > 20 + ) { + return { + status: false, + data: null, + message: 'Password must contain between 5 and 20 characters', + }; + } + if (data.retype_password !== undefined && data.retype_password !== data.password) { + return { status: false, data: null, message: 'Passwords do not match' }; + } // PHP: 'password' => 'trim|base64_encode' filter applied first const base64Password = Buffer.from(data.password).toString('base64'); password = await bcrypt.hash(base64Password, 12); @@ -1336,6 +1350,13 @@ userSchema.statics.userInsertUpdate = async function (data, id, context) { license: context.license, }; + // updateOne does not run the save hook. Explicit resets must persist the + // hash and invalidate older sessions; ordinary edits preserve credentials. + if (id && verify_password === 'yes' && data.usertype !== 'api') { + updateData.password = password; + updateData.passwordChangedAt = new Date(); + } + // Existing API credentials are write-only: a blank edit keeps the key. if (id && data.usertype === 'api' && !appKey.trim()) delete updateData.apikey; diff --git a/api/src/repositories/sale.repository.js b/api/src/repositories/sale.repository.js index d30dda77c..b27adc198 100644 --- a/api/src/repositories/sale.repository.js +++ b/api/src/repositories/sale.repository.js @@ -1,3 +1,4 @@ +const { recordedUserName } = require('../utils/recorded-user-name'); const { reportableSales, reportSaleTotal } = require('../helpers/reportable-sales'); const pricingAuthority = require('../services/pricing-authority'); const Money = require('../utils/currency'); @@ -4102,6 +4103,7 @@ class SalesRepository { branch_name: 1, user_id: 1, user_name: 1, + 'origin.actor_name': 1, sales_id: 1, date: 1, items: 1, @@ -4175,6 +4177,10 @@ class SalesRepository { .lean(); const list = (Array.isArray(rawList) ? rawList : []).map((row) => { + row = { + ...row, + user_name: recordedUserName(row.user_name, row.created_by, row.origin?.actor_name), + }; if (!row.captain_payments?.length || row.payment_status !== 'Paid') return row; const digits = row.captain_transfer_allocation?.currencyDigits; return { @@ -4459,6 +4465,7 @@ class SalesRepository { branch_name: 1, user_id: 1, user_name: 1, + 'origin.actor_name': 1, sales_id: 1, date: 1, items: 1, @@ -4528,7 +4535,10 @@ class SalesRepository { .limit(limit) .lean(); - const list = Array.isArray(rawList) ? rawList : []; + const list = (Array.isArray(rawList) ? rawList : []).map((row) => ({ + ...row, + user_name: recordedUserName(row.user_name, row.created_by, row.origin?.actor_name), + })); const total = await Model.countDocuments(filters); diff --git a/api/src/services/tax-reporting/decimal.js b/api/src/services/tax-reporting/decimal.js new file mode 100644 index 000000000..171a5d6e7 --- /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 000000000..0f94fa407 --- /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 000000000..f9a74a441 --- /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/src/utils/recorded-user-name.js b/api/src/utils/recorded-user-name.js new file mode 100644 index 000000000..218ce00fd --- /dev/null +++ b/api/src/utils/recorded-user-name.js @@ -0,0 +1,17 @@ +'use strict'; + +// Use the recorded creator, never whoever happens to be viewing/editing a bill. +function recordedUserName(...values) { + return ( + values + .find( + (value) => + typeof value === 'string' && + value.trim() && + !/^(undefined|null|\[object Object\])$/i.test(value.trim()) + ) + ?.trim() || '' + ); +} + +module.exports = { recordedUserName }; diff --git a/api/tests/unit/security/reported-tenant-boundaries.test.js b/api/tests/unit/security/reported-tenant-boundaries.test.js index ae35000ab..0c4a9274e 100644 --- a/api/tests/unit/security/reported-tenant-boundaries.test.js +++ b/api/tests/unit/security/reported-tenant-boundaries.test.js @@ -294,3 +294,156 @@ test('API key validation allows blank edits but rejects blank creates and short ).toBe(invalid); } }); + +test('explicit staff password reset persists the hash and revokes old sessions without changing other tenants', async () => { + const User = require('../../../src/models/user.model'); + const bcrypt = require('bcryptjs'); + const id = new ObjectId(); + const original = await bcrypt.hash(Buffer.from('Old@12345').toString('base64'), 4); + await db.collection('users').insertOne({ + _id: id, + license: licenseA, + username: 'staff', + email: 'staff@example.com', + password: original, + usertype: 'staff', + }); + await db.collection('branches').insertOne({ _id: branchA, license: licenseA, branch_name: 'A' }); + const plan = jest.spyOn(BaseModel.prototype, 'checkPlan').mockResolvedValue(0); + const log = jest.spyOn(BaseModel.prototype, 'changeLog').mockResolvedValue(); + try { + const data = { + usertype: 'staff', + name: 'staff', + email: 'staff@example.com', + firstname: 'Staff', + branch_data: [{ branch_id: String(branchA), branch_name: 'A' }], + user_status: 'active', + check_password: 'no', + }; + const context = { + license: licenseA, + user: { ...request(id).user, username: 'owner', usertype: 'super_admin' }, + }; + expect((await User.userInsertUpdate(data, String(id), context)).status).toBe(true); + expect((await db.collection('users').findOne({ _id: id })).password).toBe(original); + for (const password of ['', 'a']) { + expect( + ( + await User.userInsertUpdate( + { ...data, check_password: 'yes', password }, + String(id), + context + ) + ).status + ).toBe(false); + } + expect( + ( + await User.userInsertUpdate( + { ...data, check_password: 'yes', password: 'New@12345', retype_password: 'Different' }, + String(id), + context + ) + ).status + ).toBe(false); + expect( + ( + await User.userInsertUpdate( + { ...data, check_password: 'yes', password: 'New@12345', retype_password: 'New@12345' }, + String(id), + context + ) + ).status + ).toBe(true); + const stored = await db.collection('users').findOne({ _id: id }); + expect(await bcrypt.compare(Buffer.from('New@12345').toString('base64'), stored.password)).toBe( + true + ); + expect(await bcrypt.compare(Buffer.from('Old@12345').toString('base64'), stored.password)).toBe( + false + ); + expect(User.hydrate(stored).changedPasswordAfter(Math.floor(Date.now() / 1000) - 60)).toBe( + true + ); + await User.userInsertUpdate( + { ...data, check_password: 'yes', password: 'Wrong@12345' }, + String(id), + { ...context, license: licenseB } + ); + expect((await db.collection('users').findOne({ _id: id })).password).toBe(stored.password); + } finally { + plan.mockRestore(); + log.mockRestore(); + } +}); + +test('payment reports recover Captain origin names and preserve saved POS names in tenant scope', async () => { + const sales = require('../../../src/repositories/sale.repository'); + const Model = + mongoose.models.ReportAttribution || + mongoose.model( + 'ReportAttribution', + new mongoose.Schema({}, { strict: false, collection: 'sales' }) + ); + const template = { + license: licenseA, + branch_id: branchA, + date: new Date('2026-10-17T12:00:00Z'), + payment_status: 'Paid', + sale_process: 'Add', + sales_total: 100, + }; + await db.collection('sales').deleteMany({}); + await db.collection('sales').insertMany([ + { + ...template, + sales_id: 'captain', + origin: { actor_name: 'Captain original' }, + updated_by: 'Later cashier', + }, + { ...template, sales_id: 'pos', user_name: 'POS original', origin: { actor_name: 'Other' } }, + { ...template, sales_id: 'legacy', user_name: 'undefined', created_by: 'Legacy original' }, + { ...template, sales_id: 'unknown' }, + { ...template, license: licenseB, sales_id: 'foreign', origin: { actor_name: 'Foreign user' } }, + ]); + const result = await runWithRequestContext({ license: licenseA, currentBranch: branchA }, () => + sales.paymentSalesTransactionReportPage( + { + branchid: [String(branchA)], + starting_date: '2026-10-17T00:00:00Z', + ending_date: '2026-10-17T23:59:59Z', + }, + { limit: 20 }, + { SaleModel: Model } + ) + ); + expect(result.status).toBe(true); + expect(Object.fromEntries(result.data.list.map((row) => [row.sales_id, row.user_name]))).toEqual({ + captain: 'Captain original', + pos: 'POS original', + legacy: 'Legacy original', + unknown: '', + }); + await db.collection('sales').updateMany({}, { $set: { sale_process: 'PartialReturn' } }); + const returned = await runWithRequestContext({ license: licenseA, currentBranch: branchA }, () => + sales.paymentReturnSalesTranscationReportTable( + { + branchid: [String(branchA)], + starting_date: '2026-10-17T00:00:00Z', + ending_date: '2026-10-17T23:59:59Z', + }, + { limit: 20 }, + { SaleModel: Model } + ) + ); + expect(returned.status).toBe(true); + expect( + Object.fromEntries(returned.data.list.map((row) => [row.sales_id, row.user_name])) + ).toEqual({ + captain: 'Captain original', + pos: 'POS original', + legacy: 'Legacy original', + unknown: '', + }); +}); 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 000000000..ff8d9b95b --- /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/PAYMENT_ATTRIBUTION_EDITOR_FOLLOWUP.md b/docs/PAYMENT_ATTRIBUTION_EDITOR_FOLLOWUP.md new file mode 100644 index 000000000..232e0c490 --- /dev/null +++ b/docs/PAYMENT_ATTRIBUTION_EDITOR_FOLLOWUP.md @@ -0,0 +1,28 @@ +# Payment attribution and record-editor follow-up + +Captain orders retain the creator in `origin.actor_name`. Payment reports previously read only `user_name`, producing literal `undefined`. Sale and return transaction endpoints now prefer saved user_name, legacy created_by, then the original Captain actor name. Frontend rows and exports share this fallback, reject placeholder strings and escape names. No later editor or viewer is substituted. Unknown names show an em dash. Existing bills need no backfill. + +Edit User offers an explicit Change Password option. Ordinary edits preserve credentials. Explicit resets validate the password and confirmation, persist the bcrypt hash, and stamp passwordChangedAt to invalidate older sessions. Existing user-write authorization and tenant filters remain. No real account was reset during testing. + +Shared record dialogs fit their contents despite legacy inline height styles. Supplier and Units browser checks at 1200px and 390px measured 17px below the buttons without horizontal overflow. + +## Earlier reports + +Existing regression tests cover branch Save, active-tab preservation, phone binding, saved-state selection, stale country responses, optional country-specific branch tax registration, new branch regional fields, user permission errors, Accountant-role saving, paired passwords and category shape colors. The preceding change restored the desktop menu and table text alignment. Device counts are already unlimited by default. + +State defaults use the saved branch, not IP geolocation. Missing state cannot safely be inferred from city. Legacy Core Settings reminder preferences do not implement a sending scheduler; this change does not send messages. Partner venue commission is under Restaurant > Hotels and other venues, with results under Reports > Money > Commission owed. The separate tax calculation foundation remains internal, not a new report screen. + +## Sync and rollout + +Report attribution is read-only. Branch-scoped sales already carry origin. No migration or new collection is needed. Users already have a global-within-tenant batch lane in POS api/src/sync/collections.json, Gateway apps/sync-gateway/src/collections.js and apps/sync-agent/sync-config.json. Reset uses existing password/passwordChangedAt fields and advances updated_date. The agent transfers whole documents using updated_date/_id checkpoints and last-write-wins. No plaintext password, session or reset command is newly replicated; hashes follow the existing user lane. + +Offline tills retain their last synchronized account until reconnect. They need the updated user document before the password/session cutoff applies locally. There are no new deletion or file behaviors. Physical cross-till credential delivery remains unverified. The installed white-label 1.9.2 still contains old branch validation code and uses its own branded stable feed. Source checks do not mean that installation is updated. + +## Validation + +- Read-only inspection confirmed the original actor on the three reported Captain bills. +- 283 API regressions passed for user model, tenant isolation and Captain transfer; follow-up sale/return attribution checks passed. +- 27 payment/display/export regressions passed. +- Form regressions, frontend production build and offscreen Chromium layout checks passed. + +Full local checks: lint, formatting, API docs, packaging and attribution passed when run directly. The final desktop run passed 4,339 tests, with four skipped; its customer-branding guard found a branded name in this note. The name was removed and all six branding tests passed. An earlier stale server-message catalog was corrected and all 51 i18n tests passed on rerun. diff --git a/docs/TAX_REPORTING_CORE.md b/docs/TAX_REPORTING_CORE.md new file mode 100644 index 000000000..29f4b1167 --- /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 37c5543f7..54f3e993e 100644 --- a/frontend/dashboard.html +++ b/frontend/dashboard.html @@ -1012,7 +1012,160 @@
Do you want remove t } }); - +