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:
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user