Merge pull request #636 from Luca-Timo/feat/accounting-inbound-invoices
feat(accounting): incoming-invoice workflow v2 + VAT/financial settings consolidation
This commit is contained in:
@@ -101,6 +101,17 @@ router.get('/inbound', requireIncoming, requirePermission('accounting.view'),
|
||||
[query('status').optional().isString(), query('page').optional().isInt({ min: 1 }), query('pageSize').optional().isInt({ min: 1, max: 100 })],
|
||||
handleAsync(async (req, res) => { validateRequest(req); return successResponse(res, await expenseService.listInbound(req.query)); }));
|
||||
|
||||
// Pending re-bills grouped by customer (per-event customers with categorised
|
||||
// but not-yet-billed rebill/passthrough docs). Registered BEFORE /inbound/:id
|
||||
// so the literal path isn't swallowed by the :id param matcher.
|
||||
router.get('/inbound/pending-summary', requireIncoming, requirePermission('accounting.view'),
|
||||
handleAsync(async (_req, res) => successResponse(res, { items: await expenseService.listPendingRebillSummary() })));
|
||||
|
||||
// Bundle a customer's pending re-bills into one invoice (per-event only).
|
||||
router.post('/inbound/bill-pending', requireIncoming, requirePermission('accounting.manage'),
|
||||
[body('customerAccountId').isInt({ min: 1 })],
|
||||
handleAsync(async (req, res) => { validateRequest(req); return successResponse(res, await expenseService.billPendingRebills(toInt(req.body.customerAccountId), req.admin.id), 201, 'Re-billed'); }));
|
||||
|
||||
router.get('/inbound/:id/file', requireIncoming, requirePermission('accounting.view'),
|
||||
[param('id').isInt({ min: 1 })],
|
||||
handleAsync(async (req, res) => {
|
||||
@@ -144,7 +155,11 @@ router.patch('/inbound/:id', requireIncoming, requirePermission('accounting.mana
|
||||
handleAsync(async (req, res) => { validateRequest(req); return successResponse(res, { document: await expenseService.updateInbound(toInt(req.params.id), req.body, req.admin.id) }); }));
|
||||
|
||||
router.post('/inbound/:id/categorize', requireIncoming, requirePermission('accounting.manage'),
|
||||
[param('id').isInt({ min: 1 }), body('disposition').isIn(expenseService.DISPOSITIONS)],
|
||||
[param('id').isInt({ min: 1 }), body('disposition').isIn(expenseService.DISPOSITIONS),
|
||||
body('customerAccountId').optional({ nullable: true }).isInt({ min: 1 }),
|
||||
body('eventId').optional({ nullable: true }).isInt({ min: 1 }),
|
||||
body('categoryId').optional({ nullable: true }).isInt({ min: 1 }),
|
||||
body('markupType').optional().isIn(expenseService.MARKUP_TYPES)],
|
||||
handleAsync(async (req, res) => { validateRequest(req); return successResponse(res, { document: await expenseService.categorizeInbound(toInt(req.params.id), req.body, req.admin.id) }, 200, 'Categorized'); }));
|
||||
|
||||
router.post('/inbound/:id/rebill', requireIncoming, requirePermission('accounting.manage'),
|
||||
|
||||
@@ -127,6 +127,11 @@ function applyDependencyRules(flags) {
|
||||
// Sub-features can't outlive their parents.
|
||||
if (out.quotes === false) out.bills = false;
|
||||
if (out.calendar === false) out.calendarBooking = false;
|
||||
// Invoices (Bills) force-enable the Accounting master: invoice VAT config
|
||||
// (codes + label) and the hourly rate live under Settings → Accounting, so
|
||||
// an install with invoices must have Accounting available. Runs BEFORE the
|
||||
// accounting→children rule so the sub-features keep their own stored state.
|
||||
if (out.bills === true) out.accounting = true;
|
||||
// Accounting is a top-level MASTER; its sub-features can't outlive it.
|
||||
// Tax export is now independent of Bills — it relocated permanently
|
||||
// into the Accounting section (its own master gate).
|
||||
|
||||
@@ -130,6 +130,10 @@ function transformInvoice(i) {
|
||||
sentAt: i.sent_at,
|
||||
netAmountMinor: i.net_amount_minor,
|
||||
vatRate: i.vat_rate == null ? null : Number(i.vat_rate),
|
||||
// Snapshotted VAT code (migration 130) — the editor needs it to repopulate
|
||||
// VatRateSelect on edit; without it the dropdown falls back to rate-matching
|
||||
// and a custom-rate code is silently lost.
|
||||
vatCode: i.vat_code || null,
|
||||
vatAmountMinor: i.vat_amount_minor,
|
||||
shippingAmountMinor: i.shipping_amount_minor,
|
||||
totalAmountMinor: i.total_amount_minor,
|
||||
|
||||
@@ -258,6 +258,16 @@ router.put('/accounting', adminAuth, requirePermission('settings.edit'), async (
|
||||
setting_type: 'accounting',
|
||||
});
|
||||
}
|
||||
// Default OUTPUT VAT code stamped onto NEW invoices/quotes (the editor
|
||||
// seeds its VAT picker from it). Stored as the code string; '' clears it.
|
||||
if (Object.prototype.hasOwnProperty.call(req.body, 'accounting_default_output_vat_code')) {
|
||||
const code = String(req.body.accounting_default_output_vat_code || '').trim().slice(0, 16);
|
||||
updates.push({
|
||||
setting_key: 'accounting_default_output_vat_code',
|
||||
setting_value: JSON.stringify(code),
|
||||
setting_type: 'accounting',
|
||||
});
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(req.body, 'accounting_vat_reclaim_countries')) {
|
||||
const arr = Array.isArray(req.body.accounting_vat_reclaim_countries)
|
||||
? req.body.accounting_vat_reclaim_countries
|
||||
|
||||
@@ -73,6 +73,10 @@ function buildIssuerBlock(profile, logoPath, options = {}) {
|
||||
// PDF issuer block — §14 UStG requires one or both on every
|
||||
// invoice. Kleinunternehmer without a USt-IdNr. carry only this.
|
||||
taxId: profile.tax_id || null,
|
||||
// VAT-line label on the totals block (e.g. "MwSt.", "VAT"). Falls back to
|
||||
// the per-locale default in pdfService when blank. Configured under
|
||||
// Settings → Accounting.
|
||||
vatLabel: profile.vat_label || null,
|
||||
// pre-resolved absolute path; renderer never re-resolves.
|
||||
logoPath,
|
||||
pdfFontTtfPath: profile.pdf_font_ttf_path,
|
||||
|
||||
@@ -785,6 +785,18 @@ async function eraseCustomer(id, erasedByAdminId) {
|
||||
|
||||
// Active reset tokens for this customer should be invalidated.
|
||||
await trx('customer_password_resets').where('customer_account_id', id).del();
|
||||
|
||||
// Pending re-bills (incoming invoices, migration 132) attached to this
|
||||
// customer would otherwise stay billable to the now-anonymized account —
|
||||
// return the not-yet-billed ones to the inbox for re-triage so they're not
|
||||
// silently lost or billed to a ghost (PR #636 review #2). Guarded for
|
||||
// schema drift on installs that predate migration 132.
|
||||
if (await trx.schema.hasColumn('inbound_documents', 'customer_account_id')) {
|
||||
await trx('inbound_documents')
|
||||
.where({ customer_account_id: id })
|
||||
.whereNull('billed_invoice_id')
|
||||
.update({ customer_account_id: null, disposition: null, status: 'unsorted', updated_at: new Date() });
|
||||
}
|
||||
});
|
||||
|
||||
await logActivity('customer_erased',
|
||||
|
||||
@@ -127,9 +127,14 @@ function isEntryLocked(entry, invoice) {
|
||||
*/
|
||||
function buildLineItemFromEntry(entry, rateMinor) {
|
||||
const hours = (entry.duration_minutes / 60).toFixed(2);
|
||||
// ISO date input is already YYYY-MM-DD; admin's locale formatting
|
||||
// happens at PDF render time, so keep the entry description portable.
|
||||
const datePart = String(entry.entry_date).slice(0, 10);
|
||||
// Keep the entry description portable (admin's locale formatting happens at
|
||||
// PDF render time). `entry_date` is a `date` column: Postgres hands it back as
|
||||
// a JS Date, SQLite as a 'YYYY-MM-DD' string — so `String(dateObj).slice(0,10)`
|
||||
// would bake "Wed Apr 06" into the invoice line on PG. Normalise via the Date
|
||||
// branch (see feedback_pg_date_columns_serialize).
|
||||
const datePart = entry.entry_date instanceof Date
|
||||
? entry.entry_date.toISOString().slice(0, 10)
|
||||
: String(entry.entry_date).slice(0, 10);
|
||||
const note = (entry.description || '').trim();
|
||||
const description = `${datePart} ${entry.start_time}–${entry.end_time} (${hours}h)${note ? ': ' + note : ''}`;
|
||||
const qty = Number(hours);
|
||||
@@ -219,7 +224,14 @@ async function createEntry(customerId, payload, adminId) {
|
||||
// if neither override, customer default, nor install default is set.
|
||||
resolveEffectiveRate({ hourly_rate_minor_override: override }, customer, installDefaultMinor);
|
||||
|
||||
return await db.transaction(async (trx) => {
|
||||
// logActivity writes via the GLOBAL db; calling it inside the transaction
|
||||
// below deadlocks against the held write lock on a SQLite-backed install (a
|
||||
// second write connection blocks). Stage it here, fire it AFTER commit.
|
||||
// (The monthly/billing paths additionally route through createInvoice, whose
|
||||
// OWN internal logActivity still runs in-trx — that shared root limitation is
|
||||
// tracked in feedback_sqlite_global_write_in_transaction.)
|
||||
let logInfo = null;
|
||||
const result = await db.transaction(async (trx) => {
|
||||
const row = {
|
||||
customer_account_id: customer.id,
|
||||
entry_date: entryDate,
|
||||
@@ -268,21 +280,15 @@ async function createEntry(customerId, payload, adminId) {
|
||||
billed_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
});
|
||||
try {
|
||||
await logActivity('hour_entry_logged_to_monthly_draft',
|
||||
{ entryId, customerId: customer.id, invoiceId },
|
||||
null, `admin:${adminId}`);
|
||||
} catch (_) {}
|
||||
logInfo = { type: 'hour_entry_logged_to_monthly_draft', meta: { entryId, customerId: customer.id, invoiceId } };
|
||||
return { id: entryId, status: 'billed', invoiceId };
|
||||
}
|
||||
|
||||
try {
|
||||
await logActivity('hour_entry_logged',
|
||||
{ entryId, customerId: customer.id },
|
||||
null, `admin:${adminId}`);
|
||||
} catch (_) {}
|
||||
logInfo = { type: 'hour_entry_logged', meta: { entryId, customerId: customer.id } };
|
||||
return { id: entryId, status: 'unbilled' };
|
||||
});
|
||||
if (logInfo) { try { await logActivity(logInfo.type, logInfo.meta, null, `admin:${adminId}`); } catch (_) {} }
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -293,7 +299,8 @@ async function createEntry(customerId, payload, adminId) {
|
||||
* stay accurate.
|
||||
*/
|
||||
async function updateEntry(entryId, payload, adminId) {
|
||||
return await db.transaction(async (trx) => {
|
||||
let logInfo = null; // logged after commit — see createEntry note.
|
||||
const result = await db.transaction(async (trx) => {
|
||||
const entry = await trx('customer_hour_entries').where({ id: entryId }).first();
|
||||
if (!entry) throw new AppError('Entry not found', 404);
|
||||
const invoice = entry.invoice_id
|
||||
@@ -375,13 +382,11 @@ async function updateEntry(entryId, payload, adminId) {
|
||||
updated_at: next.updated_at,
|
||||
});
|
||||
|
||||
try {
|
||||
await logActivity('hour_entry_updated',
|
||||
{ entryId, customerId: entry.customer_account_id },
|
||||
null, `admin:${adminId}`);
|
||||
} catch (_) {}
|
||||
logInfo = { type: 'hour_entry_updated', meta: { entryId, customerId: entry.customer_account_id } };
|
||||
return { id: entryId };
|
||||
});
|
||||
if (logInfo) { try { await logActivity(logInfo.type, logInfo.meta, null, `admin:${adminId}`); } catch (_) {} }
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -390,7 +395,8 @@ async function updateEntry(entryId, payload, adminId) {
|
||||
* recomputes invoice totals before deleting the entry row itself.
|
||||
*/
|
||||
async function deleteEntry(entryId, adminId) {
|
||||
return await db.transaction(async (trx) => {
|
||||
let logInfo = null; // logged after commit — see createEntry note.
|
||||
const result = await db.transaction(async (trx) => {
|
||||
const entry = await trx('customer_hour_entries').where({ id: entryId }).first();
|
||||
if (!entry) throw new AppError('Entry not found', 404);
|
||||
const invoice = entry.invoice_id
|
||||
@@ -427,13 +433,11 @@ async function deleteEntry(entryId, adminId) {
|
||||
|
||||
await trx('customer_hour_entries').where({ id: entryId }).del();
|
||||
|
||||
try {
|
||||
await logActivity('hour_entry_deleted',
|
||||
{ entryId, customerId: entry.customer_account_id, hadInvoice: !!entry.invoice_id },
|
||||
null, `admin:${adminId}`);
|
||||
} catch (_) {}
|
||||
logInfo = { type: 'hour_entry_deleted', meta: { entryId, customerId: entry.customer_account_id, hadInvoice: !!entry.invoice_id } };
|
||||
return { deleted: true };
|
||||
});
|
||||
if (logInfo) { try { await logActivity(logInfo.type, logInfo.meta, null, `admin:${adminId}`); } catch (_) {} }
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -454,7 +458,8 @@ async function billUnbilledEntries(customerId, adminId) {
|
||||
);
|
||||
}
|
||||
|
||||
return await db.transaction(async (trx) => {
|
||||
let logInfo = null; // logged after commit — see createEntry note.
|
||||
const result = await db.transaction(async (trx) => {
|
||||
const unbilled = await trx('customer_hour_entries')
|
||||
.where({ customer_account_id: customer.id, status: 'unbilled' })
|
||||
.orderBy('entry_date', 'asc').orderBy('start_time', 'asc');
|
||||
@@ -500,14 +505,11 @@ async function billUnbilledEntries(customerId, adminId) {
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await logActivity('hour_entries_billed',
|
||||
{ customerId: customer.id, invoiceId, entryCount: unbilled.length },
|
||||
null, `admin:${adminId}`);
|
||||
} catch (_) {}
|
||||
|
||||
logInfo = { type: 'hour_entries_billed', meta: { customerId: customer.id, invoiceId, entryCount: unbilled.length } };
|
||||
return { invoiceId, entriesBilled: unbilled.length };
|
||||
});
|
||||
if (logInfo) { try { await logActivity(logInfo.type, logInfo.meta, null, `admin:${adminId}`); } catch (_) {} }
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -44,7 +44,8 @@ function toIsoDate(v) {
|
||||
|
||||
// ── Accounting settings (app_settings, type 'accounting') ───────────────────
|
||||
async function getAccountingSettings() {
|
||||
const keys = ['accounting_km_rate_minor', 'accounting_per_diem_rate_minor', 'accounting_require_proof'];
|
||||
const keys = ['accounting_km_rate_minor', 'accounting_per_diem_rate_minor', 'accounting_require_proof',
|
||||
'accounting_vat_reclaim_countries'];
|
||||
let rows = [];
|
||||
try {
|
||||
rows = await db('app_settings').whereIn('setting_key', keys).select('setting_key', 'setting_value');
|
||||
@@ -59,9 +60,29 @@ async function getAccountingSettings() {
|
||||
kmRateMinor: Number.isFinite(Number(map.accounting_km_rate_minor)) ? Number(map.accounting_km_rate_minor) : 0,
|
||||
perDiemRateMinor: Number.isFinite(Number(map.accounting_per_diem_rate_minor)) ? Number(map.accounting_per_diem_rate_minor) : 0,
|
||||
requireProof: map.accounting_require_proof === true || map.accounting_require_proof === 1 || map.accounting_require_proof === '1',
|
||||
vatReclaimCountries: Array.isArray(map.accounting_vat_reclaim_countries)
|
||||
? map.accounting_vat_reclaim_countries.map((c) => String(c || '').toUpperCase()) : [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Default tax treatment from the supplier country: explicit payload wins; else
|
||||
* a country in the reclaim list (typically CH / LI) is `domestic` (input VAT
|
||||
* reclaimable), an out-of-list country is `foreign_vat_non_reclaimable`, and an
|
||||
* unknown country falls back to `domestic`. reverse_charge / import_goods stay
|
||||
* admin-set (can't be auto-detected).
|
||||
*/
|
||||
function resolveTaxTreatment(payloadTreatment, supplierCountry, reclaimCountries) {
|
||||
if (TAX_TREATMENTS.includes(payloadTreatment)) return payloadTreatment;
|
||||
const cc = String(supplierCountry || '').toUpperCase();
|
||||
if (!cc) return 'domestic';
|
||||
// Don't auto-classify until the admin has actually configured their reclaim
|
||||
// countries — an unset (empty) list must not make every supplier, including
|
||||
// the admin's own domestic one, "foreign". (PR #636 review #1.)
|
||||
if (!reclaimCountries || reclaimCountries.length === 0) return 'domestic';
|
||||
return reclaimCountries.includes(cc) ? 'domestic' : 'foreign_vat_non_reclaimable';
|
||||
}
|
||||
|
||||
// ── Incoming invoices (inbound_documents) ───────────────────────────────────
|
||||
function transformInbound(row) {
|
||||
if (!row) return null;
|
||||
@@ -96,6 +117,14 @@ function transformInbound(row) {
|
||||
markupFlatMinor: row.markup_flat_minor,
|
||||
billedInvoiceId: row.billed_invoice_id,
|
||||
billedInvoiceLineItemId: row.billed_invoice_line_item_id,
|
||||
// re-bill customer linkage (migration 132) — the client a rebill/passthrough
|
||||
// is attached to. customerName/Email are denormalised from a LEFT JOIN in
|
||||
// list/get (null when the row came from a query without the join).
|
||||
customerAccountId: row.customer_account_id || null,
|
||||
customerName: row.customer_display_name || row.customer_company_name || null,
|
||||
customerEmail: row.customer_email || null,
|
||||
supplierCountry: row.supplier_country || null,
|
||||
note: row.note || null,
|
||||
// supplier payment (paid on the incoming invoice itself)
|
||||
supplierPaid: !!row.supplier_paid,
|
||||
supplierPaidAt: row.supplier_paid_at,
|
||||
@@ -168,19 +197,34 @@ async function recordInboundDocument({ source, filePath, originalFilename, mimeT
|
||||
return getInbound(id);
|
||||
}
|
||||
|
||||
// Denormalise the attached customer's name/email for the inbox UI (re-bill
|
||||
// chip + pending-pool grouping). LEFT JOIN so docs without a customer still
|
||||
// return. Selected explicitly to avoid colliding with inbound_documents.*.
|
||||
const INBOUND_CUSTOMER_SELECT = [
|
||||
'inbound_documents.*',
|
||||
'c.display_name as customer_display_name',
|
||||
'c.company_name as customer_company_name',
|
||||
'c.email as customer_email',
|
||||
];
|
||||
function inboundWithCustomer() {
|
||||
return db('inbound_documents')
|
||||
.leftJoin('customer_accounts as c', 'inbound_documents.customer_account_id', 'c.id');
|
||||
}
|
||||
|
||||
async function getInbound(id) {
|
||||
const row = await db('inbound_documents').where({ id }).first();
|
||||
const row = await inboundWithCustomer().where('inbound_documents.id', id).first(INBOUND_CUSTOMER_SELECT);
|
||||
if (!row) throw new AppError('Incoming invoice not found', 404, 'INBOUND_NOT_FOUND');
|
||||
return transformInbound(row);
|
||||
}
|
||||
|
||||
async function listInbound({ status, page, pageSize } = {}) {
|
||||
const { p, ps } = clampPage(page, pageSize);
|
||||
const base = db('inbound_documents');
|
||||
if (status) base.where({ status });
|
||||
const countRow = await base.clone().count({ count: '*' }).first();
|
||||
const base = inboundWithCustomer();
|
||||
if (status) base.where('inbound_documents.status', status);
|
||||
const countRow = await base.clone().clearSelect().count({ count: 'inbound_documents.id' }).first();
|
||||
const total = parseInt(countRow?.count || 0, 10);
|
||||
const rows = await base.clone().orderBy('created_at', 'desc').limit(ps).offset((p - 1) * ps);
|
||||
const rows = await base.clone().orderBy('inbound_documents.created_at', 'desc').limit(ps).offset((p - 1) * ps)
|
||||
.select(INBOUND_CUSTOMER_SELECT);
|
||||
return { items: rows.map(transformInbound), pagination: { page: p, pageSize: ps, total, totalPages: Math.ceil(total / ps) } };
|
||||
}
|
||||
|
||||
@@ -188,7 +232,7 @@ const INBOUND_EDITABLE = {
|
||||
supplierName: 'supplier_name', invoiceNumber: 'invoice_number', invoiceDate: 'invoice_date',
|
||||
dueDate: 'due_date', currency: 'currency', netAmountMinor: 'net_amount_minor',
|
||||
vatAmountMinor: 'vat_amount_minor', totalAmountMinor: 'total_amount_minor', iban: 'iban',
|
||||
paymentReference: 'payment_reference',
|
||||
paymentReference: 'payment_reference', note: 'note', supplierCountry: 'supplier_country',
|
||||
};
|
||||
|
||||
async function updateInbound(id, payload, adminId) {
|
||||
@@ -232,79 +276,339 @@ function computeMarkupMinor(baseMinor, markup) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Re-bill an incoming invoice to a client (mints an editable scheduled invoice). */
|
||||
async function rebillInbound(id, payload, adminId, trx0) {
|
||||
const run = async (trx) => {
|
||||
const row = await trx('inbound_documents').where({ id }).first();
|
||||
if (!row) throw new AppError('Incoming invoice not found', 404, 'INBOUND_NOT_FOUND');
|
||||
const doc = transformInbound(row);
|
||||
if (doc.billedInvoiceId) throw new AppError('Already re-billed', 409, 'ALREADY_BILLED');
|
||||
if (!payload.customerAccountId) throw new AppError('customerAccountId is required to re-bill', 400, 'CUSTOMER_REQUIRED');
|
||||
const base = doc.totalAmountMinor != null ? doc.totalAmountMinor : doc.netAmountMinor;
|
||||
if (base == null) throw new AppError('Incoming invoice has no amount to re-bill', 400, 'AMOUNT_REQUIRED');
|
||||
// Dispositions that can be billed to a client. 'rebill' always carries a
|
||||
// customer; 'durchlaufend' (passthrough) may now ALSO attach to a customer
|
||||
// (with optional markup) so it can be re-billed like a rebill.
|
||||
const CUSTOMER_DISPOSITIONS = ['rebill', 'durchlaufend'];
|
||||
const BOOKING_DISPOSITIONS = ['rebill', 'durchlaufend'];
|
||||
|
||||
const markup = await resolveMarkup(
|
||||
{ markupType: doc.markupType, markupPercent: doc.markupPercent, markupFlatMinor: doc.markupFlatMinor },
|
||||
payload, payload.contractId, trx,
|
||||
);
|
||||
const lineTotal = base + computeMarkupMinor(base, markup);
|
||||
const label = doc.supplierName || 'Weiterverrechnete Auslage';
|
||||
const { invoiceIds } = await invoiceService.createInvoice({
|
||||
customerAccountId: payload.customerAccountId,
|
||||
eventId: payload.eventId || doc.eventId || null,
|
||||
lineItems: [{ description: `${label} (Weiterverrechnung)`, quantity: 1, unit_price_minor: lineTotal, discount_percent: 0, line_total_minor: lineTotal }],
|
||||
}, adminId, trx);
|
||||
const invoiceId = Array.isArray(invoiceIds) ? invoiceIds[0] : null;
|
||||
if (!invoiceId) throw new AppError('Failed to create the re-bill invoice', 500, 'REBILL_FAILED');
|
||||
const line = await trx('invoice_line_items').where({ invoice_id: invoiceId }).orderBy('id', 'desc').first('id');
|
||||
|
||||
await trx('inbound_documents').where({ id }).update({
|
||||
disposition: 'rebill',
|
||||
status: 'categorized',
|
||||
event_id: payload.eventId || doc.eventId || null,
|
||||
markup_type: markup.type,
|
||||
markup_percent: markup.type === 'percent' ? markup.percent : null,
|
||||
markup_flat_minor: markup.type === 'flat' ? markup.flatMinor : null,
|
||||
billed_invoice_id: invoiceId,
|
||||
billed_invoice_line_item_id: line ? line.id : null,
|
||||
updated_at: new Date(),
|
||||
});
|
||||
await logActivity('incoming_invoice_rebilled', { inboundDocumentId: id, invoiceId }, adminId);
|
||||
return invoiceId;
|
||||
};
|
||||
const invoiceId = trx0 ? await run(trx0) : await db.transaction(run);
|
||||
return { document: await getInbound(id), invoiceId };
|
||||
/**
|
||||
* Can this invoice still be edited (line removed / appended)? Mirrors the
|
||||
* hour-entry lock rules (customerHoursService.isEntryLocked, inverted):
|
||||
* monthly drafts and not-yet-armed scheduled invoices are mutable; anything
|
||||
* sent/paid/overdue/cancelled or past its scheduled_send_at is locked.
|
||||
*/
|
||||
function isInvoiceMutable(invoice) {
|
||||
if (!invoice) return true; // referenced invoice gone — treat as not billed
|
||||
if (invoice.is_monthly_draft === true || invoice.is_monthly_draft === 1) return true;
|
||||
// NB: invoices have no 'draft' status (only quotes do). The editable,
|
||||
// not-yet-sent invoice state IS 'scheduled' with no scheduled_send_at (or a
|
||||
// future one), handled below — so there is no plain-'draft' case to slot in
|
||||
// here (PR #636 review #6).
|
||||
if (invoice.status !== 'scheduled') return false;
|
||||
if (!invoice.scheduled_send_at) return true;
|
||||
return new Date(invoice.scheduled_send_at).getTime() > Date.now();
|
||||
}
|
||||
|
||||
/** Give an incoming invoice a disposition (updates the document, no expense row). */
|
||||
/**
|
||||
* Re-categorisation unwind: remove this document's billed line item from its
|
||||
* invoice and recompute the invoice totals, so the disposition can change.
|
||||
* Refuses when the invoice is already issued (Storno required instead).
|
||||
*/
|
||||
async function unwindBilledLine(trx, doc) {
|
||||
const invoice = doc.billedInvoiceId
|
||||
? await trx('invoices').where({ id: doc.billedInvoiceId }).first()
|
||||
: null;
|
||||
if (invoice && !isInvoiceMutable(invoice)) {
|
||||
throw new AppError(
|
||||
'This re-bill is on an invoice that has already been issued — Storno it before re-categorising.',
|
||||
409, 'INVOICE_LOCKED',
|
||||
);
|
||||
}
|
||||
if (doc.billedInvoiceLineItemId) {
|
||||
await trx('invoice_line_items').where({ id: doc.billedInvoiceLineItemId }).del();
|
||||
}
|
||||
if (invoice) {
|
||||
const allItems = await trx('invoice_line_items').where({ invoice_id: invoice.id });
|
||||
if (allItems.length === 0) {
|
||||
// The unwound re-bill was the only line — a net-zero invoice has no reason
|
||||
// to survive, and these would otherwise pile up over re-categorisations.
|
||||
// It's mutable (checked above) and never issued, so delete it outright
|
||||
// (PR #636 review #5). For a monthly draft this just means the next append
|
||||
// re-creates one.
|
||||
await trx('invoices').where({ id: invoice.id }).del();
|
||||
return;
|
||||
}
|
||||
let netMinor = 0;
|
||||
for (const li of allItems) {
|
||||
if (li.parent_line_item_id == null) netMinor += Number(li.line_total_minor || 0);
|
||||
}
|
||||
const vatRate = Number(invoice.vat_rate || 0);
|
||||
const vatMinor = Math.round(netMinor * vatRate / 100);
|
||||
const shippingMinor = Number(invoice.shipping_amount_minor || 0);
|
||||
await trx('invoices').where({ id: invoice.id }).update({
|
||||
net_amount_minor: netMinor,
|
||||
vat_amount_minor: vatMinor,
|
||||
total_amount_minor: netMinor + vatMinor + shippingMinor,
|
||||
updated_at: new Date(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** The single invoice line that re-bills one incoming invoice (base + markup). */
|
||||
function buildInboundLineItem(doc, disposition, markup) {
|
||||
const base = doc.totalAmountMinor != null ? doc.totalAmountMinor : doc.netAmountMinor;
|
||||
if (base == null) throw new AppError('Incoming invoice has no amount to re-bill', 400, 'AMOUNT_REQUIRED');
|
||||
const lineTotal = base + computeMarkupMinor(base, markup);
|
||||
const label = doc.supplierName || 'Weiterverrechnete Auslage';
|
||||
const suffix = disposition === 'durchlaufend' ? ' (Durchlaufende Position)' : ' (Weiterverrechnung)';
|
||||
return { description: `${label}${suffix}`, quantity: 1, unit_price_minor: lineTotal, discount_percent: 0, line_total_minor: lineTotal };
|
||||
}
|
||||
|
||||
/**
|
||||
* Immediately bill ONE incoming invoice to its customer. createInvoice routes
|
||||
* monthly/manual customers onto the running draft (consolidated, like hours)
|
||||
* and mints a standalone invoice for per-event customers. Stamps the document
|
||||
* with the resulting invoice + line.
|
||||
*/
|
||||
async function billInboundNow(trx, id, customerAccountId, eventId, disposition, markup, adminId) {
|
||||
const row = await trx('inbound_documents').where({ id }).first();
|
||||
const doc = transformInbound(row);
|
||||
const lineItem = buildInboundLineItem(doc, disposition, markup);
|
||||
const { invoiceIds } = await invoiceService.createInvoice({
|
||||
customerAccountId,
|
||||
eventId: eventId || doc.eventId || null,
|
||||
lineItems: [lineItem],
|
||||
}, adminId, trx);
|
||||
const invoiceId = Array.isArray(invoiceIds) ? invoiceIds[0] : null;
|
||||
if (!invoiceId) throw new AppError('Failed to create the re-bill invoice', 500, 'REBILL_FAILED');
|
||||
const line = await trx('invoice_line_items').where({ invoice_id: invoiceId }).orderBy('id', 'desc').first('id');
|
||||
await trx('inbound_documents').where({ id }).update({
|
||||
billed_invoice_id: invoiceId,
|
||||
billed_invoice_line_item_id: line ? line.id : null,
|
||||
updated_at: new Date(),
|
||||
});
|
||||
// NOTE: no logActivity here — it writes via the GLOBAL db, which deadlocks
|
||||
// when called inside this transaction on a SQLite-backed install (a second
|
||||
// write connection blocks on the held write lock). Callers log AFTER commit.
|
||||
return invoiceId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Give an incoming invoice a disposition (updates the document, no expense
|
||||
* row). Re-runnable: re-categorising an already-billed document first unwinds
|
||||
* its prior re-bill line. For rebill/passthrough with a customer, monthly &
|
||||
* manual customers are billed immediately onto the running draft (like hours);
|
||||
* per-event customers stay PENDING in the customer's pool until "Bill these".
|
||||
*/
|
||||
async function categorizeInbound(id, payload, adminId) {
|
||||
const doc = await getInbound(id);
|
||||
const disposition = payload.disposition;
|
||||
if (!DISPOSITIONS.includes(disposition)) {
|
||||
throw new AppError(`disposition must be one of ${DISPOSITIONS.join(', ')}`, 400, 'BAD_DISPOSITION');
|
||||
}
|
||||
if (disposition === 'rebill') {
|
||||
const { document } = await rebillInbound(id, payload, adminId);
|
||||
// also stamp tax_treatment/category/event from payload
|
||||
await db('inbound_documents').where({ id }).update({
|
||||
tax_treatment: TAX_TREATMENTS.includes(payload.taxTreatment) ? payload.taxTreatment : (document.taxTreatment || 'domestic'),
|
||||
category_id: payload.categoryId || null,
|
||||
const billsToCustomer = CUSTOMER_DISPOSITIONS.includes(disposition);
|
||||
const customerAccountId = billsToCustomer && payload.customerAccountId ? payload.customerAccountId : null;
|
||||
// rebill REQUIRES a customer; passthrough may omit one (then it's only booked
|
||||
// to an event/company and never re-billed).
|
||||
if (disposition === 'rebill' && !customerAccountId) {
|
||||
throw new AppError('customerAccountId is required to re-bill', 400, 'CUSTOMER_REQUIRED');
|
||||
}
|
||||
|
||||
// Reclaim-country list for the tax-treatment auto-default (loaded before the
|
||||
// transaction — a global-db read).
|
||||
const { vatReclaimCountries } = await getAccountingSettings();
|
||||
|
||||
let billedInvoiceId = null;
|
||||
await db.transaction(async (trx) => {
|
||||
const row = await trx('inbound_documents').where({ id }).first();
|
||||
if (!row) throw new AppError('Incoming invoice not found', 404, 'INBOUND_NOT_FOUND');
|
||||
const doc = transformInbound(row);
|
||||
|
||||
// #1: unwind any prior re-bill so the disposition can change.
|
||||
if (doc.billedInvoiceId) await unwindBilledLine(trx, doc);
|
||||
|
||||
// Markup is a re-bill concept only. A pass-through (durchlaufender Posten)
|
||||
// is invoiced at cost / VAT-neutral, so it never carries a markup.
|
||||
const appliesMarkup = disposition === 'rebill';
|
||||
const markup = appliesMarkup
|
||||
? await resolveMarkup(
|
||||
{ markupType: payload.markupType, markupPercent: payload.markupPercent, markupFlatMinor: payload.markupFlatMinor },
|
||||
payload, payload.contractId, trx,
|
||||
)
|
||||
: { type: 'none', percent: null, flatMinor: null };
|
||||
|
||||
const patch = {
|
||||
disposition,
|
||||
// Explicit treatment wins; else auto-default from the supplier country.
|
||||
tax_treatment: resolveTaxTreatment(payload.taxTreatment, doc.supplierCountry, vatReclaimCountries),
|
||||
event_id: BOOKING_DISPOSITIONS.includes(disposition) ? (payload.eventId || null) : null,
|
||||
category_id: disposition === 'eigener_aufwand' ? (payload.categoryId || null) : null,
|
||||
customer_account_id: customerAccountId,
|
||||
markup_type: appliesMarkup ? markup.type : 'none',
|
||||
markup_percent: appliesMarkup && markup.type === 'percent' ? markup.percent : null,
|
||||
markup_flat_minor: appliesMarkup && markup.type === 'flat' ? markup.flatMinor : null,
|
||||
// Cleared here; re-set by billInboundNow when we bill immediately.
|
||||
billed_invoice_id: null,
|
||||
billed_invoice_line_item_id: null,
|
||||
status: DISPOSITION_DOC_STATUS[disposition] || 'categorized',
|
||||
updated_at: new Date(),
|
||||
};
|
||||
if (disposition === 'duplikat' && payload.duplicateOfId) patch.duplicate_of_id = payload.duplicateOfId;
|
||||
await trx('inbound_documents').where({ id }).update(patch);
|
||||
|
||||
if (customerAccountId) {
|
||||
const customer = await trx('customer_accounts').where({ id: customerAccountId }).first();
|
||||
if (!customer) throw new AppError('Customer not found', 404, 'CUSTOMER_NOT_FOUND');
|
||||
// Monthly/manual = accumulator → bill now onto the running draft.
|
||||
// Per-event → leave PENDING for bundling via billPendingRebills.
|
||||
if (customer.billing_cadence === 'monthly' || customer.billing_cadence === 'manual') {
|
||||
billedInvoiceId = await billInboundNow(trx, id, customerAccountId, payload.eventId || null, disposition, markup, adminId);
|
||||
}
|
||||
}
|
||||
});
|
||||
// Audit logging AFTER commit — logActivity writes via the global db and would
|
||||
// deadlock if run inside the transaction above on a SQLite-backed install.
|
||||
await logActivity('incoming_invoice_categorized', { inboundDocumentId: id, disposition }, adminId);
|
||||
if (billedInvoiceId) await logActivity('incoming_invoice_rebilled', { inboundDocumentId: id, invoiceId: billedInvoiceId }, adminId);
|
||||
return getInbound(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicit "re-bill this one now" endpoint (legacy /inbound/:id/rebill). Forces
|
||||
* an immediate single-document bill regardless of cadence. Re-runnable: unwinds
|
||||
* a prior re-bill first.
|
||||
*/
|
||||
async function rebillInbound(id, payload, adminId, trx0) {
|
||||
if (!payload.customerAccountId) throw new AppError('customerAccountId is required to re-bill', 400, 'CUSTOMER_REQUIRED');
|
||||
const run = async (trx) => {
|
||||
const row = await trx('inbound_documents').where({ id }).first();
|
||||
if (!row) throw new AppError('Incoming invoice not found', 404, 'INBOUND_NOT_FOUND');
|
||||
const doc = transformInbound(row);
|
||||
if (doc.billedInvoiceId) await unwindBilledLine(trx, doc);
|
||||
const markup = await resolveMarkup(
|
||||
{ markupType: doc.markupType, markupPercent: doc.markupPercent, markupFlatMinor: doc.markupFlatMinor },
|
||||
payload, payload.contractId, trx,
|
||||
);
|
||||
await trx('inbound_documents').where({ id }).update({
|
||||
disposition: 'rebill',
|
||||
status: 'categorized',
|
||||
customer_account_id: payload.customerAccountId,
|
||||
event_id: payload.eventId || doc.eventId || null,
|
||||
markup_type: markup.type,
|
||||
markup_percent: markup.type === 'percent' ? markup.percent : null,
|
||||
markup_flat_minor: markup.type === 'flat' ? markup.flatMinor : null,
|
||||
updated_at: new Date(),
|
||||
});
|
||||
return getInbound(id);
|
||||
}
|
||||
const patch = {
|
||||
disposition,
|
||||
tax_treatment: TAX_TREATMENTS.includes(payload.taxTreatment) ? payload.taxTreatment : 'domestic',
|
||||
event_id: payload.eventId || null, // null = company
|
||||
category_id: disposition === 'eigener_aufwand' ? (payload.categoryId || null) : null,
|
||||
status: DISPOSITION_DOC_STATUS[disposition] || 'categorized',
|
||||
updated_at: new Date(),
|
||||
return billInboundNow(trx, id, payload.customerAccountId, payload.eventId || doc.eventId || null, 'rebill', markup, adminId);
|
||||
};
|
||||
if (disposition === 'duplikat' && payload.duplicateOfId) patch.duplicate_of_id = payload.duplicateOfId;
|
||||
await db('inbound_documents').where({ id }).update(patch);
|
||||
await logActivity('incoming_invoice_categorized', { inboundDocumentId: id, disposition }, adminId);
|
||||
return getInbound(id);
|
||||
const invoiceId = trx0 ? await run(trx0) : await db.transaction(run);
|
||||
// Log after commit (global-db write — see billInboundNow). When a caller
|
||||
// supplied trx0, that outer transaction owns the audit log instead.
|
||||
if (!trx0) await logActivity('incoming_invoice_rebilled', { inboundDocumentId: id, invoiceId }, adminId);
|
||||
return { document: await getInbound(id), invoiceId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Landing aggregate for the inbox "pending re-bills" card: one row per customer
|
||||
* that carries categorised-but-unbilled rebill/passthrough documents, with the
|
||||
* count + open amount (base + markup). In practice only per-event customers
|
||||
* surface here — monthly/manual cadences bill immediately on categorise.
|
||||
*/
|
||||
async function listPendingRebillSummary() {
|
||||
const rows = await db('inbound_documents as d')
|
||||
.join('customer_accounts as c', 'd.customer_account_id', 'c.id')
|
||||
.whereNotNull('d.customer_account_id')
|
||||
.whereNull('d.billed_invoice_id')
|
||||
.whereIn('d.disposition', CUSTOMER_DISPOSITIONS)
|
||||
.where('d.status', 'categorized')
|
||||
.select(
|
||||
'd.customer_account_id', 'd.total_amount_minor', 'd.net_amount_minor',
|
||||
'd.markup_type', 'd.markup_percent', 'd.markup_flat_minor',
|
||||
'c.company_name', 'c.display_name', 'c.first_name', 'c.last_name',
|
||||
'c.email', 'c.password_hash', 'c.billing_cadence',
|
||||
);
|
||||
|
||||
const byCustomer = new Map();
|
||||
for (const r of rows) {
|
||||
let agg = byCustomer.get(r.customer_account_id);
|
||||
if (!agg) {
|
||||
agg = {
|
||||
customerAccountId: r.customer_account_id,
|
||||
companyName: r.company_name || null,
|
||||
displayName: r.display_name || null,
|
||||
firstName: r.first_name || null,
|
||||
lastName: r.last_name || null,
|
||||
email: r.email || null,
|
||||
isPassive: r.password_hash == null,
|
||||
billingCadence: r.billing_cadence || null,
|
||||
itemCount: 0,
|
||||
openAmountMinor: 0,
|
||||
};
|
||||
byCustomer.set(r.customer_account_id, agg);
|
||||
}
|
||||
agg.itemCount += 1;
|
||||
const base = r.total_amount_minor != null ? Number(r.total_amount_minor)
|
||||
: (r.net_amount_minor != null ? Number(r.net_amount_minor) : 0);
|
||||
const markup = {
|
||||
type: MARKUP_TYPES.includes(r.markup_type) ? r.markup_type : 'none',
|
||||
percent: r.markup_percent != null ? Number(r.markup_percent) : null,
|
||||
flatMinor: Number.isInteger(r.markup_flat_minor) ? r.markup_flat_minor : null,
|
||||
};
|
||||
agg.openAmountMinor += base + computeMarkupMinor(base, markup);
|
||||
}
|
||||
|
||||
return Array.from(byCustomer.values()).sort((a, b) => b.openAmountMinor - a.openAmountMinor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-event flow: bundle all pending rebill/passthrough documents for a
|
||||
* customer into ONE invoice, one line per document. Refuses for monthly/manual
|
||||
* customers (those bill immediately on categorise). Mirrors
|
||||
* customerHoursService.billUnbilledEntries.
|
||||
*/
|
||||
async function billPendingRebills(customerId, adminId) {
|
||||
const customer = await db('customer_accounts').where({ id: customerId }).first();
|
||||
if (!customer) throw new AppError('Customer not found', 404);
|
||||
if (customer.billing_cadence === 'monthly' || customer.billing_cadence === 'manual') {
|
||||
throw new AppError(
|
||||
'Monthly/manual customers consolidate automatically on categorise; bundling is for per-event customers.',
|
||||
409, 'CADENCE_MISMATCH',
|
||||
);
|
||||
}
|
||||
|
||||
const result = await db.transaction(async (trx) => {
|
||||
const pending = await trx('inbound_documents')
|
||||
.where({ customer_account_id: customer.id })
|
||||
.whereNull('billed_invoice_id')
|
||||
.whereIn('disposition', CUSTOMER_DISPOSITIONS)
|
||||
.where('status', 'categorized')
|
||||
.orderBy('invoice_date', 'asc').orderBy('id', 'asc');
|
||||
if (pending.length === 0) throw new AppError('No pending re-bills to bill', 409, 'NO_PENDING');
|
||||
|
||||
const lineItems = [];
|
||||
for (let i = 0; i < pending.length; i += 1) {
|
||||
const doc = transformInbound(pending[i]);
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const markup = await resolveMarkup(
|
||||
{ markupType: doc.markupType, markupPercent: doc.markupPercent, markupFlatMinor: doc.markupFlatMinor },
|
||||
null, null, trx,
|
||||
);
|
||||
lineItems.push({ ...buildInboundLineItem(doc, doc.disposition, markup), position: i + 1 });
|
||||
}
|
||||
|
||||
const { invoiceIds } = await invoiceService.createInvoice({
|
||||
customerAccountId: customer.id,
|
||||
lineItems,
|
||||
}, adminId, trx);
|
||||
const invoiceId = invoiceIds[0];
|
||||
|
||||
const insertedLines = await trx('invoice_line_items').where({ invoice_id: invoiceId }).orderBy('position', 'asc');
|
||||
const lineByPos = new Map(insertedLines.map((li) => [li.position, li.id]));
|
||||
const now = new Date();
|
||||
for (let i = 0; i < pending.length; i += 1) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await trx('inbound_documents').where({ id: pending[i].id }).update({
|
||||
billed_invoice_id: invoiceId,
|
||||
billed_invoice_line_item_id: lineByPos.get(i + 1) || null,
|
||||
updated_at: now,
|
||||
});
|
||||
}
|
||||
|
||||
return { invoiceId, count: pending.length };
|
||||
});
|
||||
// Audit log after commit (global-db write — see billInboundNow).
|
||||
await logActivity('incoming_invoices_rebilled_bundle', { customerId: customer.id, invoiceId: result.invoiceId, count: result.count }, adminId);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Mark the supplier paid on the incoming invoice (the payable lives here). */
|
||||
@@ -518,6 +822,8 @@ module.exports = {
|
||||
updateInbound,
|
||||
categorizeInbound,
|
||||
rebillInbound,
|
||||
listPendingRebillSummary,
|
||||
billPendingRebills,
|
||||
markInboundSupplierPayment,
|
||||
// expenses
|
||||
createExpense,
|
||||
@@ -531,5 +837,5 @@ module.exports = {
|
||||
PAYMENT_METHODS,
|
||||
EXPENSE_KINDS,
|
||||
// unit-test surface
|
||||
_internal: { computeMarkupMinor, resolveMarkup, computeExpenseAmount, buildExpenseInsert, transformExpense, transformInbound },
|
||||
_internal: { computeMarkupMinor, resolveMarkup, computeExpenseAmount, buildExpenseInsert, transformExpense, transformInbound, buildInboundLineItem, isInvoiceMutable, resolveTaxTreatment },
|
||||
};
|
||||
|
||||
@@ -844,7 +844,9 @@ function drawTotals(doc, ctx, x, y, width) {
|
||||
doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).text(formatMinor(totals.shippingAmountMinor, currency, intlLocale), valueX, y, { width: valueCol, align: 'right' });
|
||||
y = doc.y + 4;
|
||||
|
||||
doc.font(doc._fonts ? doc._fonts.bold : FONT_BOLD).text(t(locale, 'totals_vat'), labelX, y, { width: labelCol });
|
||||
// Custom VAT label (Settings → Accounting) overrides the per-locale default.
|
||||
const vatLabel = (ctx.issuer && ctx.issuer.vatLabel) || t(locale, 'totals_vat');
|
||||
doc.font(doc._fonts ? doc._fonts.bold : FONT_BOLD).text(vatLabel, labelX, y, { width: labelCol });
|
||||
doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).text(`${stripTrailingZeros(totals.vatRate)}%`, rateX, y, { width: rateCol, align: 'right' });
|
||||
doc.text(formatMinor(totals.vatAmountMinor, currency, intlLocale), valueX, y, { width: valueCol, align: 'right' });
|
||||
y = doc.y + 4;
|
||||
|
||||
@@ -433,9 +433,15 @@ async function getTaxReport({ from, to, currency, includeCosts = true } = {}) {
|
||||
const rows = dbRows.map((r) => {
|
||||
const reported = computeReportedAmounts(r);
|
||||
const isCancelled = r.status === 'cancelled';
|
||||
if (isCancelled) {
|
||||
cancelledCount += 1;
|
||||
} else {
|
||||
if (isCancelled) cancelledCount += 1;
|
||||
// Exclude BOTH the cancelled original AND its negative Storno row from the
|
||||
// totals. Both stay visible in the row list for the gap-free audit trail,
|
||||
// but a Storno (kind='storno', status='sent', amounts stored negative)
|
||||
// would otherwise double-subtract: the cancelled original is already
|
||||
// netted out by exclusion, so adding the negative storno on top deducts
|
||||
// the revenue a second time — making a cancel-and-reissue read as 0 income
|
||||
// instead of the reissued amount. See feedback_storno_filter_everywhere.
|
||||
if (!isCancelled && r.kind !== 'storno') {
|
||||
grandTotalNet += reported.netMinor;
|
||||
grandTotalVat += reported.vatMinor;
|
||||
grandTotal += reported.totalMinor;
|
||||
|
||||
Reference in New Issue
Block a user