feat(accounting): supplier-country tax default + configurable default output VAT code

VAT supplier-country reclaim default:
- Migration 134 adds inbound_documents.supplier_country.
- categorizeInbound auto-derives tax_treatment via resolveTaxTreatment:
  explicit treatment wins; else country in the reclaim list → domestic,
  outside it → foreign_vat_non_reclaimable, unknown → domestic. Consumes the
  previously-stored-but-unused accounting_vat_reclaim_countries.
- Triage modal gains a Supplier country dropdown (saved via updateInbound).
  +5 unit tests for resolveTaxTreatment.

Configurable default output VAT code for new invoices:
- New accounting_default_output_vat_code setting (PUT wired; getSettings/type).
- Settings → Accounting dropdown to pick it.
- Invoice + quote editors seed their VAT picker (rate + code) from it on a
  blank new document — skipping edits/conversions, never clobbering a touched
  value. New docs no longer silently start at 0%.

i18n en + de.
This commit is contained in:
Luca
2026-06-18 15:35:58 +02:00
parent 348955b261
commit 267b121d66
11 changed files with 170 additions and 11 deletions
+10
View File
@@ -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
+27 -4
View File
@@ -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,25 @@ 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';
return reclaimCountries.includes(cc) ? 'domestic' : 'foreign_vat_non_reclaimable';
}
// ── Incoming invoices (inbound_documents) ───────────────────────────────────
function transformInbound(row) {
if (!row) return null;
@@ -102,6 +119,7 @@ function transformInbound(row) {
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,
@@ -210,7 +228,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', note: 'note',
paymentReference: 'payment_reference', note: 'note', supplierCountry: 'supplier_country',
};
async function updateInbound(id, payload, adminId) {
@@ -369,6 +387,10 @@ async function categorizeInbound(id, payload, adminId) {
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();
@@ -390,7 +412,8 @@ async function categorizeInbound(id, payload, adminId) {
const patch = {
disposition,
tax_treatment: TAX_TREATMENTS.includes(payload.taxTreatment) ? payload.taxTreatment : 'domestic',
// 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,
@@ -797,5 +820,5 @@ module.exports = {
PAYMENT_METHODS,
EXPENSE_KINDS,
// unit-test surface
_internal: { computeMarkupMinor, resolveMarkup, computeExpenseAmount, buildExpenseInsert, transformExpense, transformInbound, buildInboundLineItem, isInvoiceMutable },
_internal: { computeMarkupMinor, resolveMarkup, computeExpenseAmount, buildExpenseInsert, transformExpense, transformInbound, buildInboundLineItem, isInvoiceMutable, resolveTaxTreatment },
};