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
21 changes: 21 additions & 0 deletions api/src/models/user.model.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;

Expand Down
12 changes: 11 additions & 1 deletion api/src/repositories/sale.repository.js
Original file line number Diff line number Diff line change
@@ -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');
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);

Expand Down
41 changes: 41 additions & 0 deletions api/src/services/tax-reporting/decimal.js
Original file line number Diff line number Diff line change
@@ -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 };
177 changes: 177 additions & 0 deletions api/src/services/tax-reporting/ledger.js
Original file line number Diff line number Diff line change
@@ -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 };
22 changes: 22 additions & 0 deletions api/src/services/tax-reporting/period.js
Original file line number Diff line number Diff line change
@@ -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 };
17 changes: 17 additions & 0 deletions api/src/utils/recorded-user-name.js
Original file line number Diff line number Diff line change
@@ -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 };
Loading
Loading