import { createHash } from 'node:crypto'; import * as mssqlQueries from '../../providers/mssql/queries/ledger.js'; import * as sqliteQueries from '../../providers/sqlite3/queries/ledger.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; import makeRunner from '../runner.js'; export const INIT_HASH = 'TODO_TSE_BASED_INITHASH'; export function shape(row) { return { id: row.id, manifestarticle_id: row.manifestarticle_id, warehouse_id: row.warehouse_id ?? null, transaction_type: row.transaction_type, amount: row.amount, transaction_text: row.transaction_text, hash_version: row.hash_version, prev_hash: row.prev_hash, created_at: row.created_at instanceof Date ? row.created_at.toISOString() : new Date(row.created_at).toISOString(), }; } // Computes the SHA-512 hex hash of a previous ledger entry row // containing all database columns in canonical JSON format. export function computePrevHash(row) { if (!row) { return INIT_HASH; } const createdAt = row.created_at instanceof Date ? row.created_at.toISOString() : new Date(row.created_at).toISOString(); const payload = JSON.stringify({ id: row.id, manifestarticle_id: row.manifestarticle_id, warehouse_id: row.warehouse_id ?? null, transaction_type: row.transaction_type, amount: row.amount, transaction_text: row.transaction_text, hash_version: row.hash_version, prev_hash: row.prev_hash, created_at: createdAt, }); return createHash('sha512').update(payload, 'utf8').digest('hex'); } /** * Appends a new append-only entry to the ledger table. * * @param {number} googleAuthId - The id of the Google auth user. * @param {object} input - Ledger entry payload. * @param {number} input.manifestarticle_id - ID of the referenced manifest line (can also pass manifestarticleId). * @param {number|null} [input.warehouse_id] - Optional warehouse ID (can also pass warehouseId). * @param {string} input.transaction_type - Transaction type (can also pass transactionType). * @param {number} input.amount - Movement amount. * @param {string} input.transaction_text - Transaction memo/details (can also pass transactionText). * @param {object} [tx] - Optional transaction to run within. * @returns {Promise<{id: number, manifestarticle_id: number, warehouse_id: number|null, transaction_type: string, amount: number, transaction_text: string, hash_version: number, prev_hash: string, created_at: string}>} The created ledger row. * @throws {Error} If user not authorized, company not selected, manifest article not found, or input invalid. * * @example * const entry = await store.ledger.add(googleAuthId, { * manifestarticle_id: 1, * warehouse_id: 1, * transaction_type: 'DELIVERY', * amount: 100.5, * transaction_text: 'Invoice paid', * }); */ export default function add(provider) { const access = resolveSelectedCompanyAccess(provider); const isSqlite = provider?.dialect === 'sqlite3'; const queries = isSqlite ? sqliteQueries : mssqlQueries; return async (googleAuthId, input = {}, tx) => { const rawManifestarticleId = input.manifestarticle_id ?? input.manifestarticleId; const rawWarehouseId = input.warehouse_id ?? input.warehouseId; const rawTransactionType = input.transaction_type ?? input.transactionType; const rawAmount = input.amount; const rawTransactionText = input.transaction_text ?? input.transactionText; const manifestarticleId = Number(rawManifestarticleId); if (!Number.isInteger(manifestarticleId) || manifestarticleId <= 0) { throw new Error('Invalid ledger entry'); } let warehouseId = null; if (rawWarehouseId !== undefined && rawWarehouseId !== null) { warehouseId = Number(rawWarehouseId); if (!Number.isInteger(warehouseId) || warehouseId <= 0) { throw new Error('Invalid ledger entry'); } } const transactionType = typeof rawTransactionType === 'string' ? rawTransactionType.trim() : ''; if (!transactionType || transactionType.length > 100) { throw new Error('Invalid ledger entry'); } const amount = Number(rawAmount); if (!Number.isFinite(amount)) { throw new Error('Invalid ledger entry'); } if (rawTransactionText == null) { throw new Error('Invalid ledger entry'); } const transactionText = String(rawTransactionText); const doAdd = async (runner) => { const { companyId, companyuserId, rootCategoryId } = await access(googleAuthId, runner); const check = await runner.query(queries.CHECK_MANIFESTARTICLE_ACCESS, { manifestarticleId, companyId, }); const manifestArticle = check.rows[0]; if (!manifestArticle) { throw new Error('Manifest article not found'); } // Stock guard: warehouse-scoped ledger entries affect article stock // (stock is SUM(ledger.amount) GROUP BY warehouse). Reject bookings for // articles with stock tracking disabled before anything is written. if (warehouseId != null) { const sku = manifestArticle.sku ? String(manifestArticle.sku).trim() : ''; if (sku) { const trackRes = await runner.query( `SELECT a.sku, a.track_stock FROM article a WHERE a.company_id = @companyId AND (a.sku = @sku OR EXISTS (SELECT 1 FROM articlesupplier asup WHERE asup.article_id = a.id AND asup.sku = @sku))`, { companyId, sku }, ); const disabled = (trackRes.rows ?? []).find((r) => Number(r.track_stock ?? 1) === 0); if (disabled) { throw new Error(`Stock tracking is disabled for article (${sku})`); } } } let warehouseName = null; if (warehouseId != null) { const whCheck = await runner.query(queries.CHECK_WAREHOUSE_ACCESS, { warehouseId, companyId, }); if (!whCheck.rows[0]) { throw new Error('Warehouse not found'); } warehouseName = whCheck.rows[0].name; } const latest = await runner.query(queries.GET_LATEST_ROW); const prevRow = latest.rows[0]; const prevHash = computePrevHash(prevRow); const inserted = await runner.query(queries.INSERT_ROW, { manifestarticleId, warehouseId, transactionType, amount, transactionText, hashVersion: 1, prevHash, }); const row = inserted.rows[0]; if (!row) { throw new Error('Failed to insert ledger entry'); } const sku = manifestArticle.sku ? String(manifestArticle.sku).trim() : ''; if (sku) { const articleMatches = await runner.query(queries.FIND_ARTICLES_FOR_HISTORY, { sku, companyId, }); const upperType = String(transactionType).toUpperCase(); const isDelivery = upperType === 'DELIVERY' || upperType === 'GOODS_RECEIPT' || (manifestArticle.manifestType === 'delivery' && !upperType.includes('ADJUST') && !upperType.includes('LOSS') && !upperType.includes('WRITE_OFF')); const isShipping = upperType === 'SHIPPING' || upperType === 'ORDER_SHIPPED' || (manifestArticle.manifestType === 'order' && !upperType.includes('ADJUST') && !upperType.includes('LOSS') && !upperType.includes('WRITE_OFF')); let historyText = transactionText; if (isDelivery) { const details = []; if (manifestArticle.supplierName) { details.push(`Lieferant: "${manifestArticle.supplierName}"`); } if (warehouseName) { details.push(`Lager: "${warehouseName}"`); } if (manifestArticle.net != null && Number.isFinite(Number(manifestArticle.net))) { const net = Number(manifestArticle.net); const rebate = Number(manifestArticle.rebate ?? 0); const taxrate = Number(manifestArticle.taxrate ?? 0); const netAfterRebate = net * (1 - rebate / 100); const brutto = netAfterRebate * (1 + taxrate / 100); const ekBrutto = (Math.round(brutto * 100) / 100).toFixed(2); details.push(`EK brutto: ${ekBrutto}`); } if (amount != null && Number.isFinite(Number(amount))) { details.push(`Menge: ${amount}`); } const suffix = details.length ? ` (${details.join(', ')})` : ''; const base = transactionText?.trim() || (manifestArticle.manifestNumber ? `Lieferung ${manifestArticle.manifestNumber}` : 'Lieferung'); historyText = `${base}${suffix}`; } else if (warehouseName && !transactionText.includes(warehouseName)) { historyText = `${transactionText} (Lager: "${warehouseName}")`; } if (!isShipping) { for (const match of articleMatches.rows) { await runner.query(queries.INSERT_ARTICLE_HISTORY, { articleId: match.articleId, companyuserId, text: historyText, }); } } } return shape(row); }; if (tx) { return doAdd(makeRunner(provider, tx)); } if (typeof provider?.transaction === 'function') { return provider.transaction(doAdd); } return doAdd(makeRunner(provider)); }; }