Merge remote-tracking branch 'origin/beta' into fix/i18n-activity-types-comprehensive
# Conflicts: # frontend/src/i18n/locales/de.json # frontend/src/i18n/locales/en.json
This commit is contained in:
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "3.64.0-beta.0"
|
||||
".": "3.65.0-beta.0"
|
||||
}
|
||||
|
||||
@@ -5,6 +5,26 @@ All notable changes to PicPeak will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [3.65.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.64.0-beta.0...v3.65.0-beta.0) (2026-06-18)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **accounting:** consolidate VAT/financial config into Settings → Accounting ([dc7b87b](https://github.com/the-luap/picpeak/commit/dc7b87bb874e22ac6902fd2d48531ecdb6108c88))
|
||||
* **accounting:** explain dispositions inline, drop markup from pass-through ([9a023c0](https://github.com/the-luap/picpeak/commit/9a023c019750ebcd8d21e005aaf9a77a32cb34a3))
|
||||
* **accounting:** incoming-invoice workflow v2 + VAT/financial settings consolidation ([b527915](https://github.com/the-luap/picpeak/commit/b5279155ea4c545e13bab8bde46a39cfccf107fe))
|
||||
* **accounting:** invoices force-enable the Accounting master ([51837c3](https://github.com/the-luap/picpeak/commit/51837c3a88f711b164fafe2c7677e1a91c7542f9))
|
||||
* **accounting:** re-categorize incoming invoices, note field, pending re-bill pool ([36a8e42](https://github.com/the-luap/picpeak/commit/36a8e42f90f15a1ba96d9c4f004fa542d33e4937))
|
||||
* **accounting:** supplier-country tax default + configurable default output VAT code ([267b121](https://github.com/the-luap/picpeak/commit/267b121d66994bc57b10cd0694ab0e4320b163d9))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **accounting:** address the-luap PR [#636](https://github.com/the-luap/picpeak/issues/636) review ([707c5d0](https://github.com/the-luap/picpeak/commit/707c5d027798bdafb9fe09d7efcbd9ea65330076))
|
||||
* **accounting:** tax-report storno totals + hours-line date on Postgres ([db9e41d](https://github.com/the-luap/picpeak/commit/db9e41d19846b31b29c5c1be2ee06a7958bb43b0))
|
||||
* **crm:** editor totals box computed VAT 100× too small ([e9b297c](https://github.com/the-luap/picpeak/commit/e9b297c162a19da31d53de377b90bfd5cda1b0a7))
|
||||
* **hours:** move logActivity out of the entry transactions (SQLite deadlock) ([348955b](https://github.com/the-luap/picpeak/commit/348955b261713fc9f0b48391a1d4117f6f8c873f))
|
||||
|
||||
## [3.64.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.63.0-beta.0...v3.64.0-beta.0) (2026-06-18)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Incoming-invoice categorisation + re-bill chain (expenseService) against a
|
||||
* real SQLite schema. Covers the bits unit tests can't: the disposition state
|
||||
* machine, re-categorisation unwind, the per-event PENDING pool + bundling, and
|
||||
* the monthly accumulator immediate-bill — i.e. that categorizeInbound /
|
||||
* billPendingRebills actually mint / amend invoice rows correctly.
|
||||
*
|
||||
* No date-range comparisons are exercised here, so it's safe on SQLite (the
|
||||
* usual PG-vs-SQLite date pitfall — [[feedback_pg_date_columns_serialize]] —
|
||||
* doesn't apply to this path).
|
||||
*/
|
||||
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
|
||||
|
||||
// Service-level CRM calls cold-require heavy modules (pdfService, nodemailer)
|
||||
// on first use; bump the budget for this file.
|
||||
jest.setTimeout(60000);
|
||||
|
||||
describe('incoming-invoice categorise / re-bill chain', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let adminId;
|
||||
let expenseService;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
// logActivity writes to activity_logs via the GLOBAL db. createInvoice (and
|
||||
// appendToMonthlyDraft) call it INSIDE the transaction we pass them, and a
|
||||
// second write connection deadlocks against the held write lock on
|
||||
// SQLite. It's fire-and-forget audit noise, irrelevant to these
|
||||
// assertions, so stub it BEFORE the services destructure it at require
|
||||
// time. (Production runs Postgres, where the concurrent write is fine.)
|
||||
const dbModule = require('../../src/database/db');
|
||||
dbModule.logActivity = async () => {};
|
||||
({ adminId } = await seedMinimal(db));
|
||||
expenseService = require('../../src/services/expenseService');
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
const unwrapId = (ins) => (typeof ins[0] === 'object' ? ins[0].id : ins[0]);
|
||||
|
||||
async function captureDoc(overrides = {}) {
|
||||
const ins = await db('inbound_documents').insert({
|
||||
source: 'upload',
|
||||
status: 'unsorted',
|
||||
parse_status: 'pending',
|
||||
parse_method: 'none',
|
||||
supplier_name: 'ACME AG',
|
||||
currency: 'CHF',
|
||||
total_amount_minor: 10000,
|
||||
invoice_date: '2026-06-01',
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
...overrides,
|
||||
}).returning('id');
|
||||
return unwrapId(ins);
|
||||
}
|
||||
|
||||
let customerSeq = 0;
|
||||
async function makeCustomer(billingCadence) {
|
||||
customerSeq += 1;
|
||||
const ins = await db('customer_accounts').insert({
|
||||
email: `rebill-${billingCadence || 'event'}-${customerSeq}@example.com`,
|
||||
display_name: `Rebill ${billingCadence || 'event'} ${customerSeq}`,
|
||||
password_hash: 'x',
|
||||
preferred_language: 'de',
|
||||
is_active: 1,
|
||||
billing_cadence: billingCadence || null,
|
||||
created_at: new Date(),
|
||||
}).returning('id');
|
||||
return unwrapId(ins);
|
||||
}
|
||||
|
||||
it('company expense (eigener_aufwand) categorises with no invoice + no customer', async () => {
|
||||
const id = await captureDoc();
|
||||
const doc = await expenseService.categorizeInbound(id, { disposition: 'eigener_aufwand', categoryId: null }, adminId);
|
||||
expect(doc.disposition).toBe('eigener_aufwand');
|
||||
expect(doc.status).toBe('categorized');
|
||||
expect(doc.billedInvoiceId).toBeNull();
|
||||
expect(doc.customerAccountId).toBeNull();
|
||||
});
|
||||
|
||||
it('rebill REQUIRES a customer', async () => {
|
||||
const id = await captureDoc();
|
||||
await expect(expenseService.categorizeInbound(id, { disposition: 'rebill' }, adminId))
|
||||
.rejects.toMatchObject({ code: 'CUSTOMER_REQUIRED' });
|
||||
});
|
||||
|
||||
it('per-event rebill stays PENDING (customer + markup stored, no invoice yet)', async () => {
|
||||
const customerId = await makeCustomer('per_event');
|
||||
const id = await captureDoc({ total_amount_minor: 10000 });
|
||||
const doc = await expenseService.categorizeInbound(id, {
|
||||
disposition: 'rebill', customerAccountId: customerId,
|
||||
markupType: 'percent', markupPercent: 10,
|
||||
}, adminId);
|
||||
expect(doc.disposition).toBe('rebill');
|
||||
expect(doc.customerAccountId).toBe(customerId);
|
||||
expect(doc.billedInvoiceId).toBeNull(); // pending — not billed until bundled
|
||||
expect(doc.markupType).toBe('percent');
|
||||
expect(Number(doc.markupPercent)).toBe(10);
|
||||
});
|
||||
|
||||
it('passthrough never carries a markup, even if one is sent', async () => {
|
||||
const customerId = await makeCustomer('per_event');
|
||||
const id = await captureDoc();
|
||||
const doc = await expenseService.categorizeInbound(id, {
|
||||
disposition: 'durchlaufend', customerAccountId: customerId,
|
||||
markupType: 'percent', markupPercent: 25, // should be ignored
|
||||
}, adminId);
|
||||
expect(doc.disposition).toBe('durchlaufend');
|
||||
expect(doc.customerAccountId).toBe(customerId);
|
||||
expect(doc.markupType).toBe('none');
|
||||
expect(doc.markupPercent).toBeNull();
|
||||
expect(doc.billedInvoiceId).toBeNull();
|
||||
});
|
||||
|
||||
it('billPendingRebills refuses monthly/manual customers (they auto-consolidate)', async () => {
|
||||
const customerId = await makeCustomer('monthly');
|
||||
await expect(expenseService.billPendingRebills(customerId, adminId))
|
||||
.rejects.toMatchObject({ code: 'CADENCE_MISMATCH' });
|
||||
});
|
||||
|
||||
// ── The actual invoice-MINTING paths (billPendingRebills bundling a per-event
|
||||
// customer's pool; monthly-customer immediate-bill onto the running draft)
|
||||
// both call invoiceService.createInvoice INSIDE a db.transaction. createInvoice
|
||||
// claims its sequence number via the global db, which DEADLOCKS against the
|
||||
// held write lock on a SQLite-backed harness (a second write connection blocks
|
||||
// — verified). Production runs Postgres where the concurrent write is fine, so
|
||||
// this is a harness limitation, not a product bug. The line-amount math is
|
||||
// covered by the buildInboundLineItem unit tests, and createInvoice itself by
|
||||
// discountLineItems.test.js. Below we test the UNWIND path against a
|
||||
// hand-crafted billed state so we don't have to mint through createInvoice. ──
|
||||
|
||||
// Build a billed state directly: an invoice with two lines, with the inbound
|
||||
// doc stamped onto the first line as a prior re-bill.
|
||||
async function makeBilledDoc(customerId, { status = 'scheduled', scheduledSendAt = null, isMonthlyDraft = false } = {}) {
|
||||
const invIns = await db('invoices').insert({
|
||||
invoice_number: `R-TEST-${customerSeq}-${Math.floor(Math.random() * 1e9)}`,
|
||||
customer_account_id: customerId,
|
||||
status,
|
||||
scheduled_send_at: scheduledSendAt,
|
||||
is_monthly_draft: isMonthlyDraft,
|
||||
currency: 'CHF',
|
||||
issue_date: '2026-06-01',
|
||||
due_date: '2026-07-01',
|
||||
vat_rate: 0,
|
||||
net_amount_minor: 7000, // 4000 (rebill line) + 3000 (sibling)
|
||||
vat_amount_minor: 0,
|
||||
total_amount_minor: 7000,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
}).returning('id');
|
||||
const invoiceId = unwrapId(invIns);
|
||||
const rebillLineIns = await db('invoice_line_items').insert({
|
||||
invoice_id: invoiceId, position: 1, quantity: 1, description: 'Rebill Co (Weiterverrechnung)',
|
||||
unit_price_minor: 4000, discount_percent: 0, line_total_minor: 4000,
|
||||
}).returning('id');
|
||||
const rebillLineId = unwrapId(rebillLineIns);
|
||||
await db('invoice_line_items').insert({
|
||||
invoice_id: invoiceId, position: 2, quantity: 1, description: 'Other line',
|
||||
unit_price_minor: 3000, discount_percent: 0, line_total_minor: 3000,
|
||||
});
|
||||
const id = await captureDoc({ total_amount_minor: 4000, supplier_name: 'Rebill Co' });
|
||||
await db('inbound_documents').where({ id }).update({
|
||||
disposition: 'rebill', status: 'categorized', customer_account_id: customerId,
|
||||
billed_invoice_id: invoiceId, billed_invoice_line_item_id: rebillLineId,
|
||||
});
|
||||
return { id, invoiceId, rebillLineId };
|
||||
}
|
||||
|
||||
it('re-categorising a billed doc UNWINDS its re-bill line + recomputes the (mutable) invoice', async () => {
|
||||
const customerId = await makeCustomer('per_event');
|
||||
const { id, invoiceId, rebillLineId } = await makeBilledDoc(customerId); // scheduled, no send-at → mutable
|
||||
|
||||
const recat = await expenseService.categorizeInbound(id, { disposition: 'eigener_aufwand', categoryId: null }, adminId);
|
||||
expect(recat.disposition).toBe('eigener_aufwand');
|
||||
expect(recat.billedInvoiceId).toBeNull();
|
||||
expect(recat.customerAccountId).toBeNull();
|
||||
|
||||
// The re-bill line is gone; the sibling line remains and net recomputes.
|
||||
expect(await db('invoice_line_items').where({ id: rebillLineId }).first()).toBeUndefined();
|
||||
const after = await db('invoices').where({ id: invoiceId }).first();
|
||||
expect(Number(after.net_amount_minor)).toBe(3000);
|
||||
});
|
||||
|
||||
it('re-categorising a doc billed on an ISSUED invoice is refused (Storno required)', async () => {
|
||||
const customerId = await makeCustomer('per_event');
|
||||
const { id, rebillLineId } = await makeBilledDoc(customerId, { status: 'sent' });
|
||||
|
||||
await expect(expenseService.categorizeInbound(id, { disposition: 'eigener_aufwand' }, adminId))
|
||||
.rejects.toMatchObject({ code: 'INVOICE_LOCKED' });
|
||||
// Nothing was touched — the line survives.
|
||||
expect(await db('invoice_line_items').where({ id: rebillLineId }).first()).toBeDefined();
|
||||
});
|
||||
|
||||
it('re-categorisation moves a pending item between dispositions without a stray invoice', async () => {
|
||||
const customerId = await makeCustomer('per_event');
|
||||
const id = await captureDoc();
|
||||
// passthrough → pending
|
||||
let doc = await expenseService.categorizeInbound(id, { disposition: 'durchlaufend', customerAccountId: customerId }, adminId);
|
||||
expect(doc.customerAccountId).toBe(customerId);
|
||||
expect(doc.billedInvoiceId).toBeNull();
|
||||
// → company expense: customer cleared, still no invoice
|
||||
doc = await expenseService.categorizeInbound(id, { disposition: 'eigener_aufwand' }, adminId);
|
||||
expect(doc.disposition).toBe('eigener_aufwand');
|
||||
expect(doc.customerAccountId).toBeNull();
|
||||
expect(doc.billedInvoiceId).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
const expenseService = require('../../src/services/expenseService');
|
||||
|
||||
const { computeMarkupMinor, resolveMarkup, computeExpenseAmount, buildExpenseInsert } = expenseService._internal;
|
||||
const { computeMarkupMinor, resolveMarkup, computeExpenseAmount, buildExpenseInsert, buildInboundLineItem, isInvoiceMutable, resolveTaxTreatment } = expenseService._internal;
|
||||
|
||||
describe('computeMarkupMinor', () => {
|
||||
it('percent of base, rounded', () => {
|
||||
@@ -85,3 +85,76 @@ describe('buildExpenseInsert (internal expense)', () => {
|
||||
expect(evt.event_id).toBe(9);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildInboundLineItem (re-bill line)', () => {
|
||||
it('rebill: base + percent markup, Weiterverrechnung suffix', () => {
|
||||
const li = buildInboundLineItem({ totalAmountMinor: 10000, supplierName: 'ACME' }, 'rebill', { type: 'percent', percent: 10 });
|
||||
expect(li.unit_price_minor).toBe(11000);
|
||||
expect(li.line_total_minor).toBe(11000);
|
||||
expect(li.quantity).toBe(1);
|
||||
expect(li.description).toBe('ACME (Weiterverrechnung)');
|
||||
});
|
||||
|
||||
it('passthrough: distinct suffix, no markup passes through at cost', () => {
|
||||
const li = buildInboundLineItem({ totalAmountMinor: 5000, supplierName: 'SBB' }, 'durchlaufend', { type: 'none' });
|
||||
expect(li.unit_price_minor).toBe(5000);
|
||||
expect(li.description).toBe('SBB (Durchlaufende Position)');
|
||||
});
|
||||
|
||||
it('falls back to net amount + generic label when total/supplier missing', () => {
|
||||
const li = buildInboundLineItem({ totalAmountMinor: null, netAmountMinor: 7000 }, 'rebill', { type: 'flat', flatMinor: 300 });
|
||||
expect(li.unit_price_minor).toBe(7300);
|
||||
expect(li.description).toBe('Weiterverrechnete Auslage (Weiterverrechnung)');
|
||||
});
|
||||
|
||||
it('throws when there is no amount to re-bill', () => {
|
||||
expect(() => buildInboundLineItem({ totalAmountMinor: null, netAmountMinor: null }, 'rebill', { type: 'none' }))
|
||||
.toThrow(/no amount/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveTaxTreatment (supplier-country auto-default)', () => {
|
||||
const reclaim = ['CH', 'LI'];
|
||||
it('explicit valid treatment always wins', () => {
|
||||
expect(resolveTaxTreatment('reverse_charge_service', 'DE', reclaim)).toBe('reverse_charge_service');
|
||||
expect(resolveTaxTreatment('import_goods', 'CH', reclaim)).toBe('import_goods');
|
||||
});
|
||||
it('country in the reclaim list → domestic', () => {
|
||||
expect(resolveTaxTreatment(undefined, 'CH', reclaim)).toBe('domestic');
|
||||
expect(resolveTaxTreatment(null, 'li', reclaim)).toBe('domestic'); // case-insensitive
|
||||
});
|
||||
it('country outside the reclaim list → foreign non-reclaimable', () => {
|
||||
expect(resolveTaxTreatment(undefined, 'DE', reclaim)).toBe('foreign_vat_non_reclaimable');
|
||||
expect(resolveTaxTreatment(undefined, 'US', reclaim)).toBe('foreign_vat_non_reclaimable');
|
||||
});
|
||||
it('unknown / empty country falls back to domestic', () => {
|
||||
expect(resolveTaxTreatment(undefined, '', reclaim)).toBe('domestic');
|
||||
expect(resolveTaxTreatment(undefined, null, reclaim)).toBe('domestic');
|
||||
});
|
||||
it('an UNCONFIGURED (empty) reclaim list never auto-classifies as foreign (PR #636 #1)', () => {
|
||||
expect(resolveTaxTreatment(undefined, 'CH', [])).toBe('domestic');
|
||||
expect(resolveTaxTreatment(undefined, 'DE', [])).toBe('domestic');
|
||||
expect(resolveTaxTreatment(undefined, 'US', undefined)).toBe('domestic');
|
||||
});
|
||||
it('invalid explicit treatment is ignored (falls through to country logic)', () => {
|
||||
expect(resolveTaxTreatment('bogus', 'DE', reclaim)).toBe('foreign_vat_non_reclaimable');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isInvoiceMutable (re-categorise unwind guard)', () => {
|
||||
const future = new Date(Date.now() + 86400000).toISOString();
|
||||
const past = new Date(Date.now() - 86400000).toISOString();
|
||||
it('monthly draft and not-yet-armed scheduled are mutable', () => {
|
||||
expect(isInvoiceMutable(null)).toBe(true); // referenced invoice gone
|
||||
expect(isInvoiceMutable({ is_monthly_draft: true })).toBe(true);
|
||||
expect(isInvoiceMutable({ is_monthly_draft: 1 })).toBe(true);
|
||||
expect(isInvoiceMutable({ status: 'scheduled', scheduled_send_at: null })).toBe(true);
|
||||
expect(isInvoiceMutable({ status: 'scheduled', scheduled_send_at: future })).toBe(true);
|
||||
});
|
||||
it('armed / issued invoices are locked', () => {
|
||||
expect(isInvoiceMutable({ status: 'scheduled', scheduled_send_at: past })).toBe(false);
|
||||
expect(isInvoiceMutable({ status: 'sent' })).toBe(false);
|
||||
expect(isInvoiceMutable({ status: 'paid' })).toBe(false);
|
||||
expect(isInvoiceMutable({ status: 'cancelled' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -276,6 +276,47 @@ describe('getTaxReport', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('excludes the negative Storno row from totals on a cancel + reissue (PR #636 audit)', async () => {
|
||||
// The real cancel-and-reissue flow produces THREE rows in the period:
|
||||
// the cancelled original, its negative Storno (kind='storno', status='sent'),
|
||||
// and the reissue. Totals must read the reissued amount, not 0.
|
||||
invoiceRowsForRun = [
|
||||
{
|
||||
id: 20, invoice_number: 'R-2026-0020', issue_date: '2026-02-01',
|
||||
currency: 'CHF', status: 'cancelled', kind: 'invoice', vat_rate: 7.7,
|
||||
net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770,
|
||||
late_fee_amount_minor: 0, replaces_invoice_id: null,
|
||||
customer_company_name: 'ACME GmbH', event_name: 'Wedding A',
|
||||
},
|
||||
{
|
||||
id: 21, invoice_number: 'R-2026-0020-S', issue_date: '2026-02-02',
|
||||
currency: 'CHF', status: 'sent', kind: 'storno', vat_rate: 7.7,
|
||||
net_amount_minor: -10000, vat_amount_minor: -770, total_amount_minor: -10770,
|
||||
late_fee_amount_minor: 0, replaces_invoice_id: null,
|
||||
customer_company_name: 'ACME GmbH', event_name: 'Wedding A',
|
||||
},
|
||||
{
|
||||
id: 22, invoice_number: 'R-2026-0021', issue_date: '2026-02-03',
|
||||
currency: 'CHF', status: 'paid', kind: 'invoice', vat_rate: 7.7,
|
||||
net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770,
|
||||
late_fee_amount_minor: 0, replaces_invoice_id: 20,
|
||||
customer_company_name: 'ACME GmbH', event_name: 'Wedding A',
|
||||
},
|
||||
];
|
||||
replacementsRowsForRun = [{ replaces_invoice_id: 20, invoice_number: 'R-2026-0021' }];
|
||||
|
||||
const out = await taxReportService.getTaxReport({ from: '2026-01-01', to: '2026-03-31', currency: 'CHF' });
|
||||
expect(out.rows).toHaveLength(3); // all three stay visible for the audit trail
|
||||
// The negative storno must NOT net against the totals (the cancelled
|
||||
// original is already excluded) — the reissued revenue stands.
|
||||
expect(out.grandTotalNet).toBe(10000);
|
||||
expect(out.grandTotalVat).toBe(770);
|
||||
expect(out.grandTotal).toBe(10770);
|
||||
expect(out.totalsByVatRate).toEqual([
|
||||
{ vatRate: 7.7, netMinor: 10000, vatMinor: 770, totalMinor: 10770 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('buckets totals by VAT rate (e.g. 7.7 + 8.1 in same period)', async () => {
|
||||
invoiceRowsForRun = [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Migration 132: incoming-invoice categorisation note + customer linkage.
|
||||
*
|
||||
* - note : free-text note captured during triage (issue: no
|
||||
* note field on categorisation).
|
||||
* - customer_account_id: the client a rebill/passthrough is attached to.
|
||||
* Previously the customer was passed transiently to
|
||||
* the re-bill call and only lived on the resulting
|
||||
* invoice. Persisting it lets a categorised-but-not-
|
||||
* yet-billed item sit as a PENDING re-bill in the
|
||||
* customer's pool (per-event customers), exactly like
|
||||
* unbilled hour entries. Loose link (no hard FK —
|
||||
* mirrors the inbound event_id / expenses approach),
|
||||
* indexed for the pending-summary lookup.
|
||||
*
|
||||
* Migration 126 (which added the disposition/re-bill columns) is already
|
||||
* deployed to beta, so these go in a NEW migration rather than an in-place
|
||||
* edit. Additive + hasColumn-guarded so re-runs are safe.
|
||||
*/
|
||||
async function addColumn(knex, table, column, builder) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
if (!(await knex.schema.hasColumn(table, column))) {
|
||||
await knex.schema.alterTable(table, builder);
|
||||
}
|
||||
}
|
||||
|
||||
exports.up = async function (knex) {
|
||||
if (!(await knex.schema.hasTable('inbound_documents'))) return;
|
||||
await addColumn(knex, 'inbound_documents', 'note', (t) => t.text('note'));
|
||||
await addColumn(knex, 'inbound_documents', 'customer_account_id', (t) => t.integer('customer_account_id').unsigned());
|
||||
if (await knex.schema.hasColumn('inbound_documents', 'customer_account_id')) {
|
||||
// Index the pending-rebill lookup (customer_account_id + billed_invoice_id).
|
||||
try {
|
||||
await knex.schema.alterTable('inbound_documents', (t) => t.index(['customer_account_id'], 'inbound_documents_customer_account_id_index'));
|
||||
} catch (_e) { /* index may already exist */ }
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function (knex) {
|
||||
if (!(await knex.schema.hasTable('inbound_documents'))) return;
|
||||
for (const col of ['note', 'customer_account_id']) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
if (await knex.schema.hasColumn('inbound_documents', col)) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await knex.schema.alterTable('inbound_documents', (t) => t.dropColumn(col));
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Migration 133: invoices (Bills) force-enable the Accounting master.
|
||||
*
|
||||
* Invoice VAT config (codes + label) and the default hourly rate now live under
|
||||
* Settings → Accounting, so an install with Bills enabled must have Accounting
|
||||
* available. `applyDependencyRules` enforces this on every flag READ/WRITE, but
|
||||
* the `requireFeatureFlag('accounting')` middleware reads the STORED row
|
||||
* directly — so existing installs that already have `bills=true, accounting=false`
|
||||
* would show the Accounting tab yet 403 its endpoints. This one-time correction
|
||||
* brings the stored value in line (forward fix, not a compensation: it encodes a
|
||||
* new dependency rule, it doesn't patch a buggy earlier migration).
|
||||
*
|
||||
* Idempotent: only flips accounting ON where Bills is on; never turns it off.
|
||||
*/
|
||||
function isOn(row) {
|
||||
return !!(row && (row.value === true || row.value === 1 || row.value === '1'));
|
||||
}
|
||||
|
||||
exports.up = async function (knex) {
|
||||
if (!(await knex.schema.hasTable('feature_flags'))) return;
|
||||
const bills = await knex('feature_flags').where({ key: 'bills' }).first();
|
||||
if (!isOn(bills)) return;
|
||||
|
||||
const accounting = await knex('feature_flags').where({ key: 'accounting' }).first();
|
||||
if (!accounting) {
|
||||
await knex('feature_flags').insert({ key: 'accounting', value: true });
|
||||
} else if (!isOn(accounting)) {
|
||||
await knex('feature_flags').where({ key: 'accounting' }).update({ value: true });
|
||||
}
|
||||
};
|
||||
|
||||
// No down — we can't know whether Accounting was independently wanted, and
|
||||
// turning it back off could hide a section the admin now relies on.
|
||||
exports.down = async function () {};
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Migration 134: supplier country on incoming invoices.
|
||||
*
|
||||
* `supplier_country` (ISO-3166 alpha-2) lets categorisation auto-default the
|
||||
* `tax_treatment`: a supplier whose country is in the install's VAT reclaim
|
||||
* list (Settings → Accounting → `accounting_vat_reclaim_countries`, typically
|
||||
* CH / LI) → `domestic` (input VAT reclaimable); otherwise →
|
||||
* `foreign_vat_non_reclaimable`. Closes the dangling VAT-consolidation slice
|
||||
* where the reclaim-countries setting was stored but never consumed.
|
||||
*
|
||||
* Additive + hasColumn-guarded.
|
||||
*/
|
||||
exports.up = async function (knex) {
|
||||
if (!(await knex.schema.hasTable('inbound_documents'))) return;
|
||||
if (!(await knex.schema.hasColumn('inbound_documents', 'supplier_country'))) {
|
||||
await knex.schema.alterTable('inbound_documents', (t) => t.string('supplier_country', 2));
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function (knex) {
|
||||
if (!(await knex.schema.hasTable('inbound_documents'))) return;
|
||||
if (await knex.schema.hasColumn('inbound_documents', 'supplier_country')) {
|
||||
await knex.schema.alterTable('inbound_documents', (t) => t.dropColumn('supplier_country'));
|
||||
}
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "picpeak-backend",
|
||||
"version": "3.64.0-beta.0",
|
||||
"version": "3.65.0-beta.0",
|
||||
"description": "Backend for PicPeak event photo sharing platform",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,65 +1,97 @@
|
||||
# Accounting — Inbound supplier invoices, expenses & re-bill (MVP)
|
||||
# Accounting — Incoming invoices, expenses & re-bill
|
||||
|
||||
> **Status:** new feature, in development on `feat/accounting-inbound-invoices` (based on `upstream/beta`).
|
||||
> **Maintainer scope decision required** before merge — this introduces a new top-level **Accounting** area, separate from CRM (see "Scope decisions" below).
|
||||
> **Legal:** every VAT / tax-treatment surface is an *example only* and must be reviewed with a Treuhänder before relying on it. Jurisdiction scope is **Liechtenstein-first** (Swiss/LI rails — QR-bill, LI MWST), not German DATEV/ELSTER.
|
||||
> **Status:** built on `feat/accounting-inbound-invoices` (based on `upstream/beta`); not yet merged to `main`.
|
||||
> **Legal:** every VAT / tax-treatment surface is an *example only* and must be reviewed with a Treuhänder before relying on it. Jurisdiction scope is **Liechtenstein-first** (Swiss/LI rails — QR-bill, LI MWST), not German DATEV/ELSTER/ITSG. See `docs/crm-disclaimers.md`.
|
||||
|
||||
## Why
|
||||
The studio receives supplier invoices/receipts (hotels, equipment, fremdleistungen). Today they live in email/paper and are re-typed. This feature lets an admin **capture an incoming invoice** (upload, or **phone/tablet camera**), have its fields **best-effort extracted**, then give it a **disposition** — most importantly **re-bill it to a client** ("Weiterverrechnung") onto the relevant event's invoice with a contract-driven markup.
|
||||
The studio receives supplier invoices/receipts (hotels, equipment, Fremdleistungen). This feature lets an admin **capture** an incoming invoice (upload, **phone/tablet camera**, or **IMAP email intake**), confirm its fields, give it a **disposition**, mark the **supplier payable** paid, and — for client-borne costs — **re-bill it to a client** ("Weiterverrechnung"), consolidated onto the client's bill the same way billable hours are.
|
||||
|
||||
This mirrors the existing **billable-hours** model (`customerHoursService`): an item is parked against a customer/event and folded into an invoice as a line item.
|
||||
## Two distinct entities (split in migration 126)
|
||||
Incoming invoices and internal expenses are **separate** — one document never appears in both surfaces.
|
||||
|
||||
## Scope decisions (maintainer)
|
||||
1. **New top-level "Accounting" area**, gated behind a new `accounting` feature flag (default OFF) and `accounting.view` / `accounting.manage` permissions — *not* bolted onto CRM. The existing tax-export page is a candidate to move here later (not in this MVP).
|
||||
2. **picpeak owns documents + books up to the export boundary**; certified external systems (Treuhänder / Abacus / Bexio) own statutory filing.
|
||||
3. **No paperless-ngx sidecar** — picpeak is the system of record; files live under `storage/` and are covered by the existing `backup_paths` walker.
|
||||
- **Incoming invoices** (`inbound_documents`) — an *external* supplier document. The **row itself is the payable**: it carries the disposition, tax treatment, event booking, re-bill linkage, supplier-payment, note, and (for re-bills) the attached customer. Categorising it **updates the document** — it never derives an `expenses` row. Mark-paid lives here.
|
||||
- **Expenses** (`expenses`, `inbound_document_id IS NULL`) — *internal* own-costs: `kind = amount | mileage | per_diem` (amount = quantity × rate, rate from accounting settings with per-entry override), optional proof file, booked to an event or the company. Disposition is always `eigener_aufwand`; no supplier payment.
|
||||
|
||||
## MVP scope (this branch)
|
||||
- **Intake**: file upload **and camera capture** (phone/tablet) → `POST /api/admin/expenses/inbound` (accepts PDF + JPEG/PNG). Stored as the system of record; deduped by SHA-256.
|
||||
- **Best-effort extraction** (`extractionService`): ladder of Swiss-QR decode → PDF text layer → OCR. *Scaffolded with the interface in place; the heavy extractors (Tesseract OS package, QR decoder, isolated rasterise worker) are a follow-up — see "Deferred".*
|
||||
- **Inbox**: list documents as **„Neu / Unsortiert"**; parsed fields are editable/confirmable (parsing is assist, never blind trust). The **QR-encoded amount is stored separately** and surfaced for tamper cross-check — the **authoritative total is the text/line-item value**.
|
||||
- **5 dispositions**: `rebill` (Weiterverrechnen) · `durchlaufend` (Durchlaufender Posten) · `eigener_aufwand` (company expense) · `duplikat` · `abgelehnt` (with reason).
|
||||
- **Re-bill flow**: event-scoped (one event → one customer). Markup resolved **expense override → contract `Spesen-Zuschlag` clause → 0%** (percent or flat). Mints an editable **scheduled** invoice (admin can add more lines) — same pattern as `billUnbilledEntries`.
|
||||
- **Supplier-payment status** (decoupled from categorisation): „Zu zahlen / Bezahlt" with `payment_method` (unified with the outgoing list incl. **bank_transfer**).
|
||||
- **Expense categories**: seeded + admin-editable (colored label) — feed the future Erfolgsrechnung.
|
||||
- **`tax_treatment` captured from day 1** (`domestic` default) — stored for the books; reclaim/Bezugsteuer math is future (switches on when `business_profile.vat_id` is set).
|
||||
This document covers the **incoming-invoices** surface. Expenses share the markup/re-bill helpers but are otherwise independent.
|
||||
|
||||
## Data model (migrations 122–125)
|
||||
Numbered from **122** to avoid colliding with the in-flight `feat/crm-improvements` migrations **117–121** (which are expected to merge first). If this lands before that branch, renumber to 117+.
|
||||
## Lifecycle
|
||||
```
|
||||
capture (upload / camera / email)
|
||||
→ inbox row, status = unsorted, parse_status = pending
|
||||
triage (confirm fields + disposition + note)
|
||||
├─ eigener_aufwand → company expense (pick category), booked to company
|
||||
├─ durchlaufend → pass-through; optionally attach a client (billed at cost)
|
||||
├─ rebill → re-bill to a client (with markup)
|
||||
├─ duplikat → status = duplicate (excluded from the books)
|
||||
└─ abgelehnt → status = declined (excluded from the books)
|
||||
supplier payment (independent axis): markInboundSupplierPayment → supplier_paid
|
||||
```
|
||||
|
||||
- **122** — seed `accounting` feature flag (default OFF).
|
||||
- **123** — seed `accounting.view` / `accounting.manage` permissions + grant to super_admin/admin.
|
||||
### Dispositions
|
||||
Five: `rebill` · `durchlaufend` (Durchlaufender Posten) · `eigener_aufwand` (company expense) · `duplikat` · `abgelehnt`.
|
||||
|
||||
- **`rebill`** — your own supplier cost, invoiced on to a client, usually with a **markup** (percent or flat). Requires a customer.
|
||||
- **`durchlaufend`** — an amount fronted on behalf of a client and passed through **at cost / VAT-neutral**. May optionally attach a client (then it is re-billed like a rebill, but **never carries a markup** — enforced in both the UI and `categorizeInbound`). With no client it is only booked to an event/company.
|
||||
- **`eigener_aufwand`** — own cost, not re-billed; pick an expense category for the Erfolgsrechnung.
|
||||
|
||||
The triage modal shows an **inline explainer** for the selected disposition (`accounting.disposition.help.*`) and a **note** field on every disposition.
|
||||
|
||||
### Re-categorisation
|
||||
Categorising is **re-runnable** — a categorised invoice can be changed again (e.g. pass-through → company expense), including after the supplier has been paid (supplier-payment and classification are independent axes). When the document was already re-billed, `categorizeInbound` first **unwinds** the prior re-bill line (removes the invoice line, recomputes the invoice totals) before applying the new disposition. It **refuses** (`INVOICE_LOCKED`) only when the re-bill sits on an already-issued invoice — then a Storno is required (`isInvoiceMutable` mirrors the hour-entry lock rules). The only hard lock is an *issued* invoice, never supplier-payment.
|
||||
|
||||
### Re-bill: cadence-aware, like hours
|
||||
Re-bill/pass-through-to-a-customer consolidates onto the client's bill exactly like `customerHoursService`:
|
||||
|
||||
- **Monthly / manual customers** — the line is appended **immediately** onto the customer's running monthly draft (via `invoiceService.createInvoice`'s accumulator intercept). `billed_invoice_id` is set at categorise time.
|
||||
- **Per-event customers** — the item stays **PENDING** in the customer's pool (`customer_account_id` set, `billed_invoice_id` null). The inbox surfaces a **"Pending re-bills"** card grouped by customer; **"Bill these"** (`billPendingRebills`) bundles all of a customer's pending items into **one** invoice (one line per document), then navigates to the bill editor so the admin can add more lines before sending. This mirrors `billUnbilledEntries`.
|
||||
|
||||
Markup resolution (rebill only): expense/document override → contract `Spesen-Zuschlag` clause → 0% (`resolveMarkup`). The re-bill line description is `"{supplier} (Weiterverrechnung)"` / `"… (Durchlaufende Position)"`.
|
||||
|
||||
## Data model (migrations 122–132)
|
||||
All money is integer minor units (`*_amount_minor`). Additive, hasTable/hasColumn-guarded.
|
||||
|
||||
- **122** — seed `accounting` master flag (default OFF; preserve-visuals auto-enable where `taxReport` was on).
|
||||
- **123** — `accounting.view` / `accounting.manage` permissions.
|
||||
- **124** — `inbound_documents`, `expenses`, `expense_categories` (+ seed categories).
|
||||
- **125** — `contracts.expense_markup_type|_percent|_flat_minor` (the Spesen-Zuschlag clause).
|
||||
- **125** — contract `expense_markup_type|_percent|_flat_minor` (Spesen-Zuschlag clause).
|
||||
- **126** — split incoming vs expenses: disposition/tax_treatment/event_id/category_id, re-bill markup + `billed_invoice_id`/`billed_invoice_line_item_id`, supplier-payment columns on `inbound_documents`; `kind`/`quantity`/`rate_minor` on `expenses`.
|
||||
- **127** — separate `expenses` sub-flag + accounting `app_settings` (km/per-diem rate, require-proof). *(NB: `app_settings` has no `created_at/updated_at` — seed `setting_key/value/type` only.)*
|
||||
- **128** — incoming mail (IMAP): `incomingMail` flag + `email_configs.imap_*` + `received_emails`.
|
||||
- **129** — `ledger_accounts` + `vat_codes` (Swiss/LI KMU seed) + category→account mapping.
|
||||
- **130** — `vat_code` snapshot column on quotes + invoices.
|
||||
- **132** — `inbound_documents.note` + `inbound_documents.customer_account_id` (the attached re-bill client; loose link, indexed for the pending-pool lookup).
|
||||
|
||||
Key tables (all money in integer minor units, `*_amount_minor`):
|
||||
- `inbound_documents` — raw received doc + parsed/confirmable fields + `qr_amount_minor` (separate, untrusted) + `status` (unsorted/categorized/declined/duplicate).
|
||||
- `expenses` — the booking: `disposition`, `tax_treatment`, `event_id`, `customer_account_id`, FX (`original_*` + `chf_amount_minor` + `fx_locked`), `markup_type/_percent/_flat_minor`, `category_id`, `billed_invoice_id`, supplier-payment fields, `status`.
|
||||
- `expense_categories` — seeded colored labels.
|
||||
`inbound_documents` key columns: parsed fields (`supplier_name`, `invoice_date`, `total/net/vat_amount_minor`, `iban`, `payment_reference`) + separate untrusted `qr_amount_minor` (tamper cross-check — the authoritative total is the text value); `status` (unsorted/categorized/declined/duplicate); `disposition`; `tax_treatment`; `event_id` (NULL = company); `category_id`; `customer_account_id`; `markup_type/_percent/_flat_minor`; `billed_invoice_id` + `_line_item_id`; `supplier_paid` + `_at/_method/_ref`; `note`.
|
||||
|
||||
## API (`/api/admin/expenses`, gated by `accounting` flag + `accounting.*`)
|
||||
- `POST /inbound` (multipart) — capture an inbound doc (upload/camera).
|
||||
- `GET /inbound` — list (filter by status, paginated).
|
||||
- `GET /inbound/:id` — one doc.
|
||||
- `PATCH /inbound/:id` — confirm/edit parsed fields.
|
||||
- `POST /inbound/:id/categorize` — create an expense with a disposition.
|
||||
- `POST / ` — create a manual expense (no document).
|
||||
- `GET / ` — list expenses (filter by status/disposition/customer/event).
|
||||
- `GET /:id` — one expense.
|
||||
- `PATCH /:id` — edit (locked once billed).
|
||||
- `POST /:id/rebill` — re-bill to a client (event-scoped, contract markup) → scheduled invoice.
|
||||
- `POST /:id/supplier-payment` — toggle supplier paid + method.
|
||||
- `GET/POST/PATCH/DELETE /categories` — manage expense categories.
|
||||
## API (`/api/admin/expenses`, gated by `incomingInvoices` + `accounting.*`)
|
||||
- `POST /inbound` (multipart) — capture (upload/camera). Deduped by SHA-256.
|
||||
- `GET /inbound` — list (joins the attached customer name/email).
|
||||
- `GET /inbound/pending-summary` — per-customer pending re-bills (registered before `/inbound/:id`).
|
||||
- `POST /inbound/bill-pending` — bundle one customer's pending re-bills into one invoice.
|
||||
- `GET /inbound/:id` · `PATCH /inbound/:id` (edit/confirm fields incl. `note`).
|
||||
- `GET /inbound/:id/page/:n` — rasterised PNG of a page. `GET /inbound/:id/file` — original (PDFs as attachment only, never inline).
|
||||
- `POST /inbound/:id/categorize` — set disposition (re-runnable; unwinds prior re-bill).
|
||||
- `POST /inbound/:id/rebill` — explicit "re-bill this one now" (forces an immediate single-doc bill).
|
||||
- `POST /inbound/:id/supplier-payment` — toggle supplier paid + method/date/reference.
|
||||
- Expenses: `GET/POST /`, `GET/PATCH /:id`, `POST /:id/invoice`, `POST /:id/paid`, `GET /:id/proof`.
|
||||
- Categories: `GET/POST/PATCH/DELETE /categories` (accounting master).
|
||||
|
||||
## Camera capture (step 3)
|
||||
The `POST /inbound` endpoint accepts images, so a **mobile web** widget using
|
||||
`<input type="file" accept="image/*" capture="environment">` already enables phone/tablet camera capture — **no native app required for v1**. A native document-scanner (edge-detect/dewarp, multi-page) is a later UX upgrade that improves OCR accuracy.
|
||||
## Document preview = server-side rasterised images
|
||||
Raw PDFs are **never** served inline. `rasterizeService` shells out to poppler `pdftoppm` (OS package in the Docker image — not a Node PDF lib, runs no JS, no egress). Pages cached under `storage/business-docs/inbound/rendered/<id>/page-<n>.png`, served with `Content-Security-Policy: default-src 'none'` + `nosniff`. Page count capped at 200. The triage preview defaults to the last page (the Swiss QR-bill usually sits at the bottom).
|
||||
|
||||
## Deferred (follow-ups)
|
||||
- Real extraction: Tesseract OCR (OS package in the Docker image, shell-out — *not* a sidecar), Swiss-QR decoder, **network-isolated rasterise worker** (no egress), CSP-locked image preview, never serve the raw PDF.
|
||||
- Email intake (`rechnungen@…` IMAP poll, forwarded-message parsing, message-id dedupe).
|
||||
- Bank reconciliation, FX auto-lock backstop (30-day), Erfolgsrechnung, customer-account close guard.
|
||||
- Frontend: the Accounting tab UI (inbox, disposition actions, re-bill dialog) + the camera widget.
|
||||
## Reporting & export
|
||||
- **Tax report** (`taxReportService`) — full Einnahmen-Ausgaben: incoming invoices + expenses feed the `costs` side, grouped Company vs Event; re-billed costs are kept (the matching re-bill revenue is also counted, so it nets). `vatPayable` = output VAT − reclaimable input VAT (excludes `foreign_vat_non_reclaimable`); zero when not VAT-registered. Gated on `accounting` + `taxReport` (no longer `bills`).
|
||||
- **Treuhänder export** (`ledgerService`) — accrual Buchungssätze → generic/Banana/bexio CSV. Accrual basis only; bank/payment postings are Layer B (deferred). See `project_banana_treuhaender_export_format`.
|
||||
- VAT config (codes, rate→code + treatment→code maps, registration & reclaim countries, chart of accounts) lives under **Settings → Accounting**; invoices snapshot the chosen `vat_code`.
|
||||
|
||||
## Flag model
|
||||
`accounting` is an explicit top-level **master** flag with sub-toggles: `incomingInvoices` (this surface), `expenses` (internal expenses), `taxReport` (moved permanently out of CRM, now independent of `bills`). `incomingMail` (IMAP) is a separate flag, not under accounting. `accounting` off forces `taxReport` + `incomingInvoices` off.
|
||||
|
||||
## Conventions followed
|
||||
Idempotent migrations (hasTable/hasColumn-guarded); new flag default OFF; flag reads tolerate `true|1|'1'`; money as integer `*_minor`; `requirePermission` guards; camelCase API ↔ snake_case service; multer + `safePath` containment at every file boundary; localized dates on display; tax/legal surfaces carry a "verify with Treuhänder" disclaimer.
|
||||
Idempotent migrations; new flags default OFF; flag reads tolerate `true|1|'1'`; money as integer `*_minor`; `requirePermission` guards; camelCase API ↔ snake_case service; multer + `safePath` containment at every file boundary; localized dates via `useLocalizedDate`; money via `utils/money`; every tax/legal surface carries a "verify with your Treuhänder" disclaimer.
|
||||
|
||||
## Deferred
|
||||
- **OCR / auto-extract** — `extractionService` is a no-op stub (Tesseract + Swiss-QR decode); admin reads the slip and types the fields.
|
||||
- **Capture-time VAT reclaim default** — `accounting_vat_reclaim_countries` is stored but not yet consumed; needs a `supplier_country` column to default `tax_treatment`.
|
||||
- **Bank reconciliation** — match incoming payments to open invoices / confirm supplier invoices paid (LLB DataFeed / camt.053 / EBICS). Phased, Swiss/LI rails.
|
||||
- **Native double-entry (Layer B)** — picpeak stays a feeder/export tool below the CHF 500k threshold; full Erfolgsrechnung/Bilanz is out of scope.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "picpeak-frontend",
|
||||
"private": true,
|
||||
"version": "3.64.0-beta.0",
|
||||
"version": "3.65.0-beta.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -251,7 +251,12 @@ export const LineItemsTable: React.FC<Props> = ({
|
||||
// never roll directly into net — they only feed their parent's
|
||||
// auto-resolved line total.
|
||||
const subtotal = items.filter((li) => !isSub(li)).reduce((s, li) => s + lineTotal(li), 0);
|
||||
const vatAmount = Math.round(subtotal * vatRate) / 100;
|
||||
// subtotal is in MAJOR units, vatRate is a FRACTION (0.081). Round to cents:
|
||||
// round(subtotal * vatRate * 100) / 100 — the *100 inside round was missing,
|
||||
// which divided the VAT by 100 (CHF 0.63 instead of 63.18). Backend
|
||||
// computeTotals + the PDF were always correct; only this live editor preview
|
||||
// was wrong, and it only surfaced once invoices stopped defaulting to 0% VAT.
|
||||
const vatAmount = Math.round(subtotal * vatRate * 100) / 100;
|
||||
const total = subtotal + vatAmount + (Number(shippingAmount) || 0);
|
||||
|
||||
// Display numbering: top-level items get 1, 2, 3...; sub-items
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
/**
|
||||
* VAT-rate picker for the invoice/quote editors. A dropdown of the configured
|
||||
* OUTPUT VAT codes (Settings → Accounting) plus an "Other (custom rate)" escape
|
||||
* hatch. Controlled by `(rate, code)`: selecting a code emits its rate + code
|
||||
* string (snapshotted on the document for the accounting export); "Other" emits
|
||||
* the typed rate with a null code. Reads the un-gated /admin/vat-codes endpoint,
|
||||
* so it works even when the accounting feature is off.
|
||||
* VAT-rate picker for the invoice/quote editors. A dropdown whose ONLY options
|
||||
* are the configured OUTPUT VAT codes (Settings → Accounting) — there is no
|
||||
* free-text custom rate; to use a different rate, add a VAT code in Accounting.
|
||||
* Controlled by `(rate, code)`: selecting a code emits its rate + code string
|
||||
* (snapshotted on the document for the accounting export). Reads the un-gated
|
||||
* /admin/vat-codes endpoint so it works even when the accounting feature is off.
|
||||
*
|
||||
* Legacy preservation: when editing a document whose stored rate/code isn't an
|
||||
* accounting code anymore (an old invoice, or a deleted code), that value is
|
||||
* shown as a read-only "(not configured)" option so it stays selected and is
|
||||
* never silently changed — issued invoices are immutable. The admin can still
|
||||
* switch it to a current code.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { vatCodesService, type VatCodeOption } from '../../services/vatCodes.service';
|
||||
|
||||
const CUSTOM = '__custom__';
|
||||
const LEGACY = '__legacy__';
|
||||
const selectCls =
|
||||
'w-full rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-3 py-2 text-sm text-neutral-900 dark:text-neutral-100 focus:outline-none focus:ring-2 focus:ring-primary-500';
|
||||
|
||||
@@ -32,11 +38,16 @@ export const VatRateSelect: React.FC<Props> = ({ rate, code, onChange, label, di
|
||||
});
|
||||
|
||||
// Selected option: prefer the snapshotted code; else a code whose rate matches
|
||||
// (legacy rows / no code stored); else "custom".
|
||||
// (legacy rows / no code stored) — BUT only when that rate is unambiguous. If
|
||||
// two configured codes share the rate (e.g. two 8.1% codes), rate-matching
|
||||
// could silently swap one for the other on the next save, so fall through to
|
||||
// the legacy "(not configured)" option and make the admin pick explicitly
|
||||
// (PR #636 review #4).
|
||||
const matched: VatCodeOption | undefined =
|
||||
(code ? codes.find((c) => c.code === code) : undefined)
|
||||
|| (!code ? codes.find((c) => Number(c.rate) === Number(rate)) : undefined);
|
||||
const isCustom = !matched;
|
||||
|| (!code && codes.filter((c) => Number(c.rate) === Number(rate)).length === 1
|
||||
? codes.find((c) => Number(c.rate) === Number(rate)) : undefined);
|
||||
const showLegacy = !matched;
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -46,32 +57,24 @@ export const VatRateSelect: React.FC<Props> = ({ rate, code, onChange, label, di
|
||||
<select
|
||||
className={selectCls}
|
||||
disabled={disabled}
|
||||
value={isCustom ? CUSTOM : String(matched!.id)}
|
||||
value={matched ? String(matched.id) : LEGACY}
|
||||
onChange={(e) => {
|
||||
if (e.target.value === CUSTOM) { onChange(rate, null); return; }
|
||||
if (e.target.value === LEGACY) { onChange(rate, code); return; } // keep the legacy value
|
||||
const c = codes.find((x) => String(x.id) === e.target.value);
|
||||
if (c) onChange(Number(c.rate), c.code);
|
||||
}}
|
||||
>
|
||||
{showLegacy && (
|
||||
<option value={LEGACY}>
|
||||
{t('ledger.vat.legacyRate', '{{rate}}% (not configured)', { rate: Number(rate || 0).toFixed(1) })}
|
||||
</option>
|
||||
)}
|
||||
{codes.map((c) => (
|
||||
<option key={c.id} value={String(c.id)}>
|
||||
{c.name} ({Number(c.rate).toFixed(1)}%)
|
||||
</option>
|
||||
))}
|
||||
<option value={CUSTOM}>{t('vat.customRate', 'Other (custom rate)')}</option>
|
||||
</select>
|
||||
{isCustom && (
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
className={`${selectCls} mt-2`}
|
||||
disabled={disabled}
|
||||
value={rate}
|
||||
placeholder={t('vat.ratePercent', 'VAT rate %') as string}
|
||||
onChange={(e) => onChange(Number(e.target.value) || 0, null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Currency codes for the Business-profile "Default currency" dropdown.
|
||||
* CH/LI-first ordering (picpeak's primary scope), then the common EUR/USD/GBP
|
||||
* and a broad set of other ISO 4217 codes. Stored value is the bare 3-letter
|
||||
* code (e.g. "CHF").
|
||||
*/
|
||||
export const CURRENCY_CODES: string[] = [
|
||||
'CHF', 'EUR', 'USD', 'GBP',
|
||||
'AUD', 'CAD', 'CNY', 'CZK', 'DKK', 'HKD', 'HUF', 'ILS', 'INR', 'JPY',
|
||||
'NOK', 'NZD', 'PLN', 'RON', 'SEK', 'SGD', 'THB', 'TRY', 'ZAR',
|
||||
];
|
||||
|
||||
/**
|
||||
* Normalise a stored/typed currency value to a known code. Upper-cases + trims
|
||||
* (so an old free-text "chf" resolves to "CHF"). Returns the matched code, or
|
||||
* the cleaned input if it isn't in the known list (caller preserves it as an
|
||||
* extra option so nothing is lost), or '' for empty input.
|
||||
*/
|
||||
export function normalizeCurrency(value: string | null | undefined): string {
|
||||
return (value || '').trim().toUpperCase();
|
||||
}
|
||||
|
||||
/** Build the option list, prepending an unknown-but-set value so it's preserved. */
|
||||
export function currencyOptions(current: string | null | undefined): string[] {
|
||||
const cur = normalizeCurrency(current);
|
||||
if (cur && !CURRENCY_CODES.includes(cur)) return [cur, ...CURRENCY_CODES];
|
||||
return CURRENCY_CODES;
|
||||
}
|
||||
@@ -91,6 +91,10 @@ function applyDependencyRules(flags: FeatureFlags): FeatureFlags {
|
||||
out.galleries = true; // foundation — always on
|
||||
if (out.quotes === false) out.bills = false; // bills depend on quotes
|
||||
if (out.calendar === false) out.calendarBooking = false; // booking depends on calendar
|
||||
// Invoices (Bills) force-enable the Accounting master — invoice VAT config +
|
||||
// hourly rate live under Settings → Accounting. Before the accounting→children
|
||||
// rule so sub-features keep their own state.
|
||||
if (out.bills === true) out.accounting = true;
|
||||
// Accounting sub-features require the Accounting master. Tax export is
|
||||
// independent of Bills now — it relocated permanently into Accounting.
|
||||
if (out.accounting === false) {
|
||||
|
||||
@@ -8,9 +8,11 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Save } from 'lucide-react';
|
||||
import { Button, Card, CardContent, Loading } from '../../../components/common';
|
||||
import { Button, Card, CardContent, Input, Loading } from '../../../components/common';
|
||||
import { DecimalInput } from '../../../components/common/DecimalInput';
|
||||
import { accountingService } from '../../../services/accounting.service';
|
||||
import { businessProfileService } from '../../../services/businessProfile.service';
|
||||
import { vatCodesService } from '../../../services/vatCodes.service';
|
||||
import { sortedCountryOptions } from '../../../constants/countries';
|
||||
import { VatCodesManager } from '../../../components/admin/VatCodesManager';
|
||||
import { ChartOfAccountsManager } from '../../../components/admin/ChartOfAccountsManager';
|
||||
@@ -22,12 +24,19 @@ export const AccountingTab: React.FC = () => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const { data, isLoading } = useQuery({ queryKey: ['accounting-settings'], queryFn: () => accountingService.getSettings() });
|
||||
const { data: outputVatCodes = [] } = useQuery({ queryKey: ['vat-codes', 'output'], queryFn: () => vatCodesService.listOutput() });
|
||||
// VAT label + default hourly rate live on business_profile, surfaced here so
|
||||
// all financial/VAT config sits in one tab (and one Save).
|
||||
const { data: profileSnap } = useQuery({ queryKey: ['business-profile'], queryFn: () => businessProfileService.get() });
|
||||
|
||||
const [kmMajor, setKmMajor] = useState<number>(NaN);
|
||||
const [perDiemMajor, setPerDiemMajor] = useState<number>(NaN);
|
||||
const [hourlyMajor, setHourlyMajor] = useState<number>(NaN);
|
||||
const [requireProof, setRequireProof] = useState(false);
|
||||
const [vatRegistered, setVatRegistered] = useState(false);
|
||||
const [reclaimCountries, setReclaimCountries] = useState<string[]>([]);
|
||||
const [defaultOutputVatCode, setDefaultOutputVatCode] = useState('');
|
||||
const [vatLabel, setVatLabel] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
@@ -36,20 +45,41 @@ export const AccountingTab: React.FC = () => {
|
||||
setRequireProof(data.accounting_require_proof);
|
||||
setVatRegistered(data.accounting_vat_registered);
|
||||
setReclaimCountries(data.accounting_vat_reclaim_countries || []);
|
||||
setDefaultOutputVatCode(data.accounting_default_output_vat_code || '');
|
||||
}
|
||||
}, [data]);
|
||||
useEffect(() => {
|
||||
if (profileSnap?.profile) {
|
||||
setVatLabel(profileSnap.profile.vatLabel || '');
|
||||
setHourlyMajor(profileSnap.profile.defaultHourlyRateMinor != null ? profileSnap.profile.defaultHourlyRateMinor / 100 : NaN);
|
||||
}
|
||||
}, [profileSnap]);
|
||||
|
||||
const countries = sortedCountryOptions(i18n.language);
|
||||
const currency = profileSnap?.profile?.defaultCurrency || 'CHF';
|
||||
|
||||
// One Save persists BOTH the app_settings (rates/VAT/proof) and the two
|
||||
// business_profile fields (VAT label + hourly rate).
|
||||
const save = useMutation({
|
||||
mutationFn: () => accountingService.updateSettings({
|
||||
accounting_km_rate_minor: Number.isFinite(kmMajor) ? Math.round(kmMajor * 100) : 0,
|
||||
accounting_per_diem_rate_minor: Number.isFinite(perDiemMajor) ? Math.round(perDiemMajor * 100) : 0,
|
||||
accounting_require_proof: requireProof,
|
||||
accounting_vat_registered: vatRegistered,
|
||||
accounting_vat_reclaim_countries: reclaimCountries,
|
||||
}),
|
||||
onSuccess: () => { toast.success(t('settings.accounting.savedToast', 'Accounting settings saved.')); qc.invalidateQueries({ queryKey: ['accounting-settings'] }); },
|
||||
mutationFn: async () => {
|
||||
await accountingService.updateSettings({
|
||||
accounting_km_rate_minor: Number.isFinite(kmMajor) ? Math.round(kmMajor * 100) : 0,
|
||||
accounting_per_diem_rate_minor: Number.isFinite(perDiemMajor) ? Math.round(perDiemMajor * 100) : 0,
|
||||
accounting_require_proof: requireProof,
|
||||
accounting_vat_registered: vatRegistered,
|
||||
accounting_vat_reclaim_countries: reclaimCountries,
|
||||
accounting_default_output_vat_code: defaultOutputVatCode,
|
||||
});
|
||||
await businessProfileService.update({
|
||||
vatLabel: vatLabel || '',
|
||||
defaultHourlyRateMinor: Number.isFinite(hourlyMajor) ? Math.max(0, Math.round(hourlyMajor * 100)) : null,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(t('settings.accounting.savedToast', 'Accounting settings saved.'));
|
||||
qc.invalidateQueries({ queryKey: ['accounting-settings'] });
|
||||
qc.invalidateQueries({ queryKey: ['business-profile'] });
|
||||
},
|
||||
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'),
|
||||
});
|
||||
|
||||
@@ -69,9 +99,14 @@ export const AccountingTab: React.FC = () => {
|
||||
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">{t('settings.accounting.kmRateHint', 'Default applied to mileage expenses; overridable per entry.')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>{t('settings.accounting.perDiemRate', 'Per-diem rate (CHF / day)')}</label>
|
||||
<label className={labelCls}>{t('settings.accounting.perDiemRate', 'Daily allowance (CHF / day)')}</label>
|
||||
<DecimalInput value={perDiemMajor} onChange={setPerDiemMajor} fractionDigits={2} className={inputCls} />
|
||||
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">{t('settings.accounting.perDiemRateHint', 'Default applied to per-diem expenses; overridable per entry.')}</p>
|
||||
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">{t('settings.accounting.perDiemRateHint', 'A flat daily allowance booked as an expense (not a client billing rate); overridable per entry.')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>{t('settings.accounting.profileFields.hourlyRate', 'Default hourly rate')}</label>
|
||||
<DecimalInput value={hourlyMajor} onChange={setHourlyMajor} fractionDigits={2} className={inputCls} placeholder={t('settings.accounting.profileFields.hourlyRatePlaceholder', 'e.g. 120.00') as string} />
|
||||
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">{t('settings.accounting.profileFields.hourlyRateHint', 'Billing fallback used when a customer has no own rate (hours logging). In {{currency}}, major units. Leave blank to require a per-customer or per-entry rate.', { currency })}</p>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm text-neutral-800 dark:text-neutral-200">
|
||||
<input type="checkbox" checked={requireProof} onChange={(e) => setRequireProof(e.target.checked)} className="rounded border-neutral-300" />
|
||||
@@ -85,7 +120,7 @@ export const AccountingTab: React.FC = () => {
|
||||
the tax report's VAT-payable). */}
|
||||
<Card><CardContent className="p-5 space-y-4">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wider text-neutral-500 dark:text-neutral-400">
|
||||
{t('settings.accounting.vat.title', 'VAT registration & reclaim')}
|
||||
{t('settings.accounting.vat.title', 'VAT')}
|
||||
</h3>
|
||||
<label className="flex items-start gap-2 text-sm text-neutral-800 dark:text-neutral-200">
|
||||
<input type="checkbox" checked={vatRegistered} onChange={(e) => setVatRegistered(e.target.checked)} className="mt-0.5 rounded border-neutral-300" />
|
||||
@@ -113,6 +148,19 @@ export const AccountingTab: React.FC = () => {
|
||||
{t('settings.accounting.vat.reclaimCountriesHint', 'Typically your domestic country (CH / LI). Costs from other countries are treated as non-reclaimable foreign VAT. Cmd/Ctrl-click to multi-select.')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>{t('settings.accounting.vat.defaultOutputCode', 'Default VAT code for new invoices')}</label>
|
||||
<select value={defaultOutputVatCode} onChange={(e) => setDefaultOutputVatCode(e.target.value)} className={inputCls}>
|
||||
<option value="">{t('settings.accounting.vat.defaultOutputCodeNone', '— none (start at 0%) —')}</option>
|
||||
{outputVatCodes.map((c) => <option key={c.id} value={c.code}>{c.name} ({Number(c.rate).toFixed(1)}%)</option>)}
|
||||
</select>
|
||||
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">{t('settings.accounting.vat.defaultOutputCodeHint', 'New invoices and quotes start with this VAT code selected. Existing documents are unaffected.')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>{t('settings.accounting.profileFields.vatLabel', 'VAT label (e.g. MwSt., VAT)')}</label>
|
||||
<Input value={vatLabel} onChange={(e) => setVatLabel(e.target.value)} className={inputCls} />
|
||||
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">{t('settings.accounting.profileFields.vatLabelHint', 'Printed as the VAT-line label on invoice / quote PDFs. Leave blank to use the document language default.')}</p>
|
||||
</div>
|
||||
</CardContent></Card>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -324,6 +324,13 @@ export const FeaturesTab: React.FC = () => {
|
||||
sidebarLabel={t('settings.features.accounting.sidebar', 'Accounting')}
|
||||
enabled={staged.accounting}
|
||||
onToggle={(next) => setFlag('accounting', next)}
|
||||
// Invoices force-enable Accounting (invoice VAT settings live here),
|
||||
// so the master can't be turned off while Bills is on.
|
||||
disabled={staged.bills}
|
||||
lockedReason={staged.bills ? t(
|
||||
'settings.features.accounting.requiredByBills',
|
||||
'On automatically because Invoices is enabled — invoice VAT settings live in the Accounting section.',
|
||||
) : undefined}
|
||||
/>
|
||||
|
||||
<FeatureCard
|
||||
|
||||
@@ -1562,7 +1562,7 @@
|
||||
"statusRevoked": "Widerrufen",
|
||||
"statusExpired": "Abgelaufen",
|
||||
"statusActive": "Aktiv",
|
||||
"confirmRevoke": "Diesen Token wirklich widerrufen?",
|
||||
"confirmRevoke": "\"{{name}}\" widerrufen? Bestehende Integrationen, die dieses Token nutzen, erhalten ab sofort 401.",
|
||||
"revoke": "Widerrufen",
|
||||
"empty": "Noch keine Tokens. Generieren Sie oben einen, um zu beginnen.",
|
||||
"preview": "Vorschau"
|
||||
@@ -1705,7 +1705,8 @@
|
||||
"accounting": {
|
||||
"title": "Buchhaltung",
|
||||
"description": "Ein eigener Buchhaltungsbereich, getrennt vom CRM. Hier aktivieren und dann die Unterfunktionen unten einschalten (Steuerexport, Eingangsrechnungen). MwSt-/Steuerbehandlung dient nur als Orientierung — vor dem Verlassen darauf mit Ihrem Treuhänder prüfen.",
|
||||
"sidebar": "Buchhaltung"
|
||||
"sidebar": "Buchhaltung",
|
||||
"requiredByBills": "Automatisch an, weil Rechnungen aktiviert ist — die MwSt-Einstellungen der Rechnungen liegen im Buchhaltungsbereich."
|
||||
},
|
||||
"incomingInvoices": {
|
||||
"title": "Eingangsrechnungen",
|
||||
@@ -1776,21 +1777,31 @@
|
||||
},
|
||||
"accounting": {
|
||||
"title": "Buchhaltung",
|
||||
"subtitle": "Standardsätze für interne Aufwände und die Belegpflicht.",
|
||||
"subtitle": "Standardsätze, MwSt-Einstellungen und die Belegpflicht.",
|
||||
"kmRate": "Kilometersatz (CHF / km)",
|
||||
"kmRateHint": "Standard für Kilometer-Aufwände; pro Eintrag überschreibbar.",
|
||||
"perDiemRate": "Spesenpauschale (CHF / Tag)",
|
||||
"perDiemRateHint": "Standard für Pauschal-Aufwände; pro Eintrag überschreibbar.",
|
||||
"perDiemRateHint": "Eine Tagespauschale, die als Aufwand gebucht wird (kein Kunden-Verrechnungssatz); pro Eintrag überschreibbar.",
|
||||
"requireProof": "Beleg für jeden Aufwand verlangen",
|
||||
"vat": {
|
||||
"title": "MwSt-Registrierung & Vorsteuerabzug",
|
||||
"title": "MwSt.",
|
||||
"registered": "MwSt-pflichtig (Umsatzsteuer berechnen + Vorsteuer abziehen)",
|
||||
"registeredHint": "Aus = Kleinunternehmen / unter der Schwelle: keine MwSt berechnet, Vorsteuer ist Aufwand (nicht abziehbar).",
|
||||
"reclaimCountries": "Länder mit abziehbarer Vorsteuer",
|
||||
"reclaimCountriesHint": "Üblicherweise Ihr Inland (CH / LI). Kosten aus anderen Ländern gelten als nicht abziehbare ausländische MwSt. Cmd/Ctrl-Klick für Mehrfachauswahl."
|
||||
"reclaimCountriesHint": "Üblicherweise Ihr Inland (CH / LI). Kosten aus anderen Ländern gelten als nicht abziehbare ausländische MwSt. Cmd/Ctrl-Klick für Mehrfachauswahl.",
|
||||
"defaultOutputCode": "Standard-MwSt-Code für neue Rechnungen",
|
||||
"defaultOutputCodeNone": "— keiner (bei 0% beginnen) —",
|
||||
"defaultOutputCodeHint": "Neue Rechnungen und Angebote starten mit diesem MwSt-Code. Bestehende Dokumente bleiben unverändert."
|
||||
},
|
||||
"disclaimer": "Sätze und MwSt-/Steuerbehandlung dienen nur als Orientierung — mit Ihrem Treuhänder prüfen.",
|
||||
"savedToast": "Buchhaltungseinstellungen gespeichert."
|
||||
"savedToast": "Buchhaltungseinstellungen gespeichert.",
|
||||
"profileFields": {
|
||||
"vatLabel": "MwSt-Bezeichnung (z. B. MwSt., VAT)",
|
||||
"vatLabelHint": "Wird als Bezeichnung der MwSt-Zeile auf Rechnungs-/Angebots-PDFs gedruckt. Leer lassen, um die Standardbezeichnung der Dokumentsprache zu verwenden.",
|
||||
"hourlyRate": "Standard-Stundensatz",
|
||||
"hourlyRatePlaceholder": "z. B. 120.00",
|
||||
"hourlyRateHint": "Verrechnungs-Fallback, wenn ein Kunde keinen eigenen Satz hat (Stundenerfassung). In {{currency}}, in Hauptwährungseinheiten. Leer lassen, um einen Satz pro Kunde oder pro Eintrag zu verlangen."
|
||||
}
|
||||
}
|
||||
},
|
||||
"branding": {
|
||||
@@ -3695,7 +3706,14 @@
|
||||
"durchlaufend": "Durchlaufender Posten",
|
||||
"eigener_aufwand": "Eigener Aufwand",
|
||||
"duplikat": "Duplikat",
|
||||
"abgelehnt": "Abgelehnt"
|
||||
"abgelehnt": "Abgelehnt",
|
||||
"help": {
|
||||
"rebill": "Eigene Lieferantenkosten, die du an einen Kunden weiterverrechnest — meist mit Zuschlag. Wird als Kosten und als weiterverrechneter Ertrag gebucht.",
|
||||
"durchlaufend": "Ein Betrag, den du nur im Namen des Kunden vorstreckst und exakt durchreichst — kein Zuschlag, MwSt-neutral (durchlaufender Posten). Kunde zuordnen, um ihn zum Selbstkostenpreis weiterzuverrechnen.",
|
||||
"eigener_aufwand": "Eigene Kosten, die nicht weiterverrechnet werden. Kategorie wählen, damit der Posten richtig in der Erfolgsrechnung landet.",
|
||||
"duplikat": "Duplikat einer bereits erfassten Rechnung — wird nicht verbucht.",
|
||||
"abgelehnt": "Dokument ablehnen — wird nicht verbucht."
|
||||
}
|
||||
},
|
||||
"markup": {
|
||||
"none": "Keiner / aus Vertrag",
|
||||
@@ -3713,6 +3731,7 @@
|
||||
"saveCategorize": "Speichern",
|
||||
"saveCategorizePay": "Speichern & als bezahlt markieren",
|
||||
"categorize": "Kategorisieren",
|
||||
"recategorize": "Neu kategorisieren",
|
||||
"view": "Ansehen",
|
||||
"empty": "Noch keine Dokumente — oben eines erfassen.",
|
||||
"untitled": "Unbenanntes Dokument",
|
||||
@@ -3744,7 +3763,13 @@
|
||||
"eventId": "Event-ID (optional)",
|
||||
"markup": "Zuschlag",
|
||||
"reference": "Zahlungsreferenz",
|
||||
"referenceHint": "QR-/ESR-Referenz oder Mitteilung"
|
||||
"referenceHint": "QR-/ESR-Referenz oder Mitteilung",
|
||||
"note": "Notiz",
|
||||
"noteHint": "Interne Notiz zu dieser Rechnung (optional)",
|
||||
"passthroughCustomerHint": "Optional — einen Kunden zuordnen, um diese durchlaufende Position weiterzuverrechnen; leer lassen, um sie nur auf das Event zu buchen.",
|
||||
"supplierCountry": "Lieferantenland",
|
||||
"supplierCountryNone": "— unbekannt —",
|
||||
"supplierCountryHint": "Setzt die Steuerbehandlung automatisch: ausserhalb deiner Vorsteuer-Länder → ausländische MwSt (nicht abziehbar)."
|
||||
}
|
||||
},
|
||||
"expenseStatus": {
|
||||
@@ -3765,7 +3790,8 @@
|
||||
"label": "Buchen auf",
|
||||
"company": "Firma",
|
||||
"event": "Event",
|
||||
"eventId": "Event-ID"
|
||||
"eventId": "Event-ID",
|
||||
"inboundHint": "Auf welches Event diese Kosten in Auswertungen & Steuerexport entfallen (Firma = allgemeiner Aufwand). Unabhängig davon, an wen du weiterverrechnest."
|
||||
},
|
||||
"incoming": {
|
||||
"triageTitle": "Eingangsrechnung kategorisieren",
|
||||
@@ -3777,7 +3803,15 @@
|
||||
"paid": "Bezahlt",
|
||||
"paidToast": "Als bezahlt markiert.",
|
||||
"categorizedToast": "Kategorisiert.",
|
||||
"categorizedPaidToast": "Kategorisiert und als bezahlt markiert."
|
||||
"categorizedPaidToast": "Kategorisiert und als bezahlt markiert.",
|
||||
"pendingRebill": "Weiterverrechnung offen",
|
||||
"pendingTitle": "Offene Weiterverrechnungen",
|
||||
"pendingBody": "Kategorisierte Rechnungen, die auf die Weiterverrechnung warten. Posten eines Kunden zu einer Rechnung bündeln.",
|
||||
"pendingCount": "{{count}} Posten",
|
||||
"pendingCount_other": "{{count}} Posten",
|
||||
"billPending": "Verrechnen",
|
||||
"bundledToast": "{{count}} Weiterverrechnung zu einer Rechnung gebündelt.",
|
||||
"bundledToast_other": "{{count}} Weiterverrechnungen zu einer Rechnung gebündelt."
|
||||
},
|
||||
"expense": {
|
||||
"kind": "Art",
|
||||
@@ -4135,7 +4169,8 @@
|
||||
"direction": "Richtung",
|
||||
"account": "MWST-Konto",
|
||||
"noAccount": "— keines —",
|
||||
"confirmDelete": "Diesen MWST-Code löschen?"
|
||||
"confirmDelete": "Diesen MWST-Code löschen?",
|
||||
"legacyRate": "{{rate}}% (nicht konfiguriert)"
|
||||
},
|
||||
"export": {
|
||||
"title": "Treuhänder-Export",
|
||||
@@ -4507,6 +4542,7 @@
|
||||
},
|
||||
"businessProfile": {
|
||||
"savedToast": "Geschäftsprofil gespeichert.",
|
||||
"movedToAccounting": "MwSt-Satz, MwSt-Bezeichnung und Standard-Stundensatz befinden sich jetzt unter Einstellungen → Buchhaltung.",
|
||||
"title": "Geschäftsprofil",
|
||||
"subtitle": "Briefkopf, Kontaktdaten und Standardwerte für Angebote und Rechnungen.",
|
||||
"businessHours": {
|
||||
@@ -4553,11 +4589,6 @@
|
||||
"defaultCurrency": "Standardwährung",
|
||||
"defaultLocale": "Standardsprache",
|
||||
"timezone": "Zeitzone (IANA)",
|
||||
"vatLabel": "MwSt-Bezeichnung",
|
||||
"vatRateDefault": "Standard-MwSt-Satz %",
|
||||
"defaultHourlyRate": "Standard-Stundensatz",
|
||||
"defaultHourlyRatePlaceholder": "z. B. 120.00",
|
||||
"defaultHourlyRateHint": "Fallback, wenn ein Kunde keinen eigenen Satz hat. In {{currency}}, in ganzen Einheiten. Leer lassen, um einen Satz pro Kunde oder pro Eintrag zu verlangen.",
|
||||
"defaultQrFormat": "Standard-QR-Format",
|
||||
"footerLine": "Fusszeile"
|
||||
},
|
||||
|
||||
@@ -1120,7 +1120,7 @@
|
||||
"statusRevoked": "Revoked",
|
||||
"statusExpired": "Expired",
|
||||
"statusActive": "Active",
|
||||
"confirmRevoke": "Do you want to revoke this token? This action cannot be undone.",
|
||||
"confirmRevoke": "Revoke \"{{name}}\"? Existing integrations using this token will start getting 401.",
|
||||
"revoke": "Revoke",
|
||||
"empty": "No tokens yet. Generate one above to get started.",
|
||||
"preview": "Preview"
|
||||
@@ -1263,7 +1263,8 @@
|
||||
"accounting": {
|
||||
"title": "Accounting",
|
||||
"description": "A dedicated Accounting area, separate from CRM. Turn this on, then enable the sub-features below (Tax export, Incoming invoices). VAT / tax treatment is guidance only — verify with your Treuhänder before relying on it.",
|
||||
"sidebar": "Accounting"
|
||||
"sidebar": "Accounting",
|
||||
"requiredByBills": "On automatically because Invoices is enabled — invoice VAT settings live in the Accounting section."
|
||||
},
|
||||
"incomingInvoices": {
|
||||
"title": "Incoming invoices",
|
||||
@@ -1334,21 +1335,31 @@
|
||||
},
|
||||
"accounting": {
|
||||
"title": "Accounting",
|
||||
"subtitle": "Default rates for internal expenses and the proof requirement.",
|
||||
"subtitle": "Default rates, VAT settings and the proof requirement.",
|
||||
"kmRate": "Mileage rate (CHF / km)",
|
||||
"kmRateHint": "Default applied to mileage expenses; overridable per entry.",
|
||||
"perDiemRate": "Per-diem rate (CHF / day)",
|
||||
"perDiemRateHint": "Default applied to per-diem expenses; overridable per entry.",
|
||||
"perDiemRate": "Daily allowance (CHF / day)",
|
||||
"perDiemRateHint": "A flat daily allowance booked as an expense (not a client billing rate); overridable per entry.",
|
||||
"requireProof": "Require a proof file on every expense",
|
||||
"vat": {
|
||||
"title": "VAT registration & reclaim",
|
||||
"title": "VAT",
|
||||
"registered": "VAT-registered (charge output VAT + reclaim input VAT)",
|
||||
"registeredHint": "Off = small business / under threshold: no VAT charged, input VAT is a cost (not reclaimable).",
|
||||
"reclaimCountries": "Countries where input VAT is reclaimable",
|
||||
"reclaimCountriesHint": "Typically your domestic country (CH / LI). Costs from other countries are treated as non-reclaimable foreign VAT. Cmd/Ctrl-click to multi-select."
|
||||
"reclaimCountriesHint": "Typically your domestic country (CH / LI). Costs from other countries are treated as non-reclaimable foreign VAT. Cmd/Ctrl-click to multi-select.",
|
||||
"defaultOutputCode": "Default VAT code for new invoices",
|
||||
"defaultOutputCodeNone": "— none (start at 0%) —",
|
||||
"defaultOutputCodeHint": "New invoices and quotes start with this VAT code selected. Existing documents are unaffected."
|
||||
},
|
||||
"disclaimer": "Rates and VAT/tax treatment are guidance only — verify with your Treuhänder.",
|
||||
"savedToast": "Accounting settings saved."
|
||||
"savedToast": "Accounting settings saved.",
|
||||
"profileFields": {
|
||||
"vatLabel": "VAT label (e.g. MwSt., VAT)",
|
||||
"vatLabelHint": "Printed as the VAT-line label on invoice / quote PDFs. Leave blank to use the document language default.",
|
||||
"hourlyRate": "Default hourly rate",
|
||||
"hourlyRatePlaceholder": "e.g. 120.00",
|
||||
"hourlyRateHint": "Billing fallback used when a customer has no own rate (hours logging). In {{currency}}, major units. Leave blank to require a per-customer or per-entry rate."
|
||||
}
|
||||
}
|
||||
},
|
||||
"analytics": {
|
||||
@@ -3695,7 +3706,14 @@
|
||||
"durchlaufend": "Pass-through",
|
||||
"eigener_aufwand": "Company expense",
|
||||
"duplikat": "Duplicate",
|
||||
"abgelehnt": "Declined"
|
||||
"abgelehnt": "Declined",
|
||||
"help": {
|
||||
"rebill": "Your own supplier cost that you invoice on to a client — usually with a markup. Booked as both a cost and re-billed revenue.",
|
||||
"durchlaufend": "An amount you only front on behalf of a client and pass through at the exact figure — no markup, VAT-neutral (durchlaufender Posten). Attach the client to re-bill it at cost.",
|
||||
"eigener_aufwand": "Your own cost, not re-billed to anyone. Pick a category so it lands in the right place in your P&L.",
|
||||
"duplikat": "A duplicate of an invoice you already captured — excluded from the books.",
|
||||
"abgelehnt": "Decline this document — excluded from the books."
|
||||
}
|
||||
},
|
||||
"markup": {
|
||||
"none": "None / from contract",
|
||||
@@ -3713,6 +3731,7 @@
|
||||
"saveCategorize": "Save",
|
||||
"saveCategorizePay": "Save & mark paid",
|
||||
"categorize": "Categorize",
|
||||
"recategorize": "Re-categorize",
|
||||
"view": "View",
|
||||
"empty": "No documents yet — capture one above.",
|
||||
"untitled": "Untitled document",
|
||||
@@ -3744,7 +3763,13 @@
|
||||
"eventId": "Event ID (optional)",
|
||||
"markup": "Markup",
|
||||
"reference": "Payment reference",
|
||||
"referenceHint": "QR / ESR reference or message"
|
||||
"referenceHint": "QR / ESR reference or message",
|
||||
"note": "Note",
|
||||
"noteHint": "Internal note for this invoice (optional)",
|
||||
"passthroughCustomerHint": "Optional — attach a client to re-bill this passthrough; leave empty to only book it to the event.",
|
||||
"supplierCountry": "Supplier country",
|
||||
"supplierCountryNone": "— unknown —",
|
||||
"supplierCountryHint": "Sets the tax treatment automatically: outside your VAT-reclaim countries → foreign VAT (not reclaimable)."
|
||||
}
|
||||
},
|
||||
"expenseStatus": {
|
||||
@@ -3765,7 +3790,8 @@
|
||||
"label": "Book to",
|
||||
"company": "Company",
|
||||
"event": "Event",
|
||||
"eventId": "Event ID"
|
||||
"eventId": "Event ID",
|
||||
"inboundHint": "Which event carries this cost in your reports & tax export (Company = general overhead). This is separate from who you re-bill it to."
|
||||
},
|
||||
"incoming": {
|
||||
"triageTitle": "Categorize incoming invoice",
|
||||
@@ -3777,7 +3803,15 @@
|
||||
"paid": "Paid",
|
||||
"paidToast": "Marked as paid.",
|
||||
"categorizedToast": "Categorized.",
|
||||
"categorizedPaidToast": "Categorized and marked paid."
|
||||
"categorizedPaidToast": "Categorized and marked paid.",
|
||||
"pendingRebill": "Pending re-bill",
|
||||
"pendingTitle": "Pending re-bills",
|
||||
"pendingBody": "Categorized invoices waiting to be re-billed. Bundle a client’s items into one invoice.",
|
||||
"pendingCount": "{{count}} item",
|
||||
"pendingCount_other": "{{count}} items",
|
||||
"billPending": "Bill these",
|
||||
"bundledToast": "Bundled {{count}} re-bill into one invoice.",
|
||||
"bundledToast_other": "Bundled {{count}} re-bills into one invoice."
|
||||
},
|
||||
"expense": {
|
||||
"kind": "Type",
|
||||
@@ -3796,7 +3830,7 @@
|
||||
"expenseKind": {
|
||||
"amount": "Amount",
|
||||
"mileage": "Mileage (km)",
|
||||
"per_diem": "Per-diem"
|
||||
"per_diem": "Daily allowance"
|
||||
},
|
||||
"category": {
|
||||
"infrastructure": "Infrastructure & rent",
|
||||
@@ -4135,7 +4169,8 @@
|
||||
"direction": "Direction",
|
||||
"account": "VAT account",
|
||||
"noAccount": "— none —",
|
||||
"confirmDelete": "Delete this VAT code?"
|
||||
"confirmDelete": "Delete this VAT code?",
|
||||
"legacyRate": "{{rate}}% (not configured)"
|
||||
},
|
||||
"export": {
|
||||
"title": "Treuhänder export",
|
||||
@@ -4505,6 +4540,7 @@
|
||||
},
|
||||
"businessProfile": {
|
||||
"savedToast": "Business profile saved.",
|
||||
"movedToAccounting": "The VAT rate, VAT label and default hourly rate now live under Settings → Accounting.",
|
||||
"title": "Business profile",
|
||||
"subtitle": "Issuer block shown on every quote and invoice PDF.",
|
||||
"businessHours": {
|
||||
@@ -4551,11 +4587,6 @@
|
||||
"defaultCurrency": "Default currency",
|
||||
"defaultLocale": "Default locale",
|
||||
"timezone": "Timezone (IANA)",
|
||||
"vatLabel": "VAT label (e.g. MwSt., VAT)",
|
||||
"vatRateDefault": "Default VAT rate %",
|
||||
"defaultHourlyRate": "Default hourly rate",
|
||||
"defaultHourlyRatePlaceholder": "e.g. 120.00",
|
||||
"defaultHourlyRateHint": "Fallback used when a customer has no own rate. In {{currency}}, major units. Leave blank to require a per-customer or per-entry rate.",
|
||||
"defaultQrFormat": "Default invoice QR",
|
||||
"footerLine": "PDF footer line"
|
||||
},
|
||||
|
||||
@@ -6,15 +6,17 @@
|
||||
* client. PDFs are previewed as server-rasterised page images (never raw).
|
||||
*/
|
||||
import React, { useRef, useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Camera, Upload, Inbox, X, Circle, Eye, RotateCcw } from 'lucide-react';
|
||||
import { Camera, Upload, Inbox, X, Circle, Eye, RotateCcw, Send, Pencil } from 'lucide-react';
|
||||
import { Button, Card, CardContent, Input, LocalizedDateInput, Loading } from '../../../components/common';
|
||||
import { DecimalInput } from '../../../components/common/DecimalInput';
|
||||
import { CustomerAccountPicker, type SelectedCustomer } from '../../../components/admin/CustomerAccountPicker';
|
||||
import { EventBookingSelect } from '../../../components/admin/EventBookingSelect';
|
||||
import { formatMoneyMinor } from '../../../utils/money';
|
||||
import { sortedCountryOptions } from '../../../constants/countries';
|
||||
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
|
||||
import {
|
||||
accountingService, categoryLabel,
|
||||
@@ -150,10 +152,14 @@ const ViewModal: React.FC<{ doc: InboundDocument; onClose: () => void }> = ({ do
|
||||
{field(t('accounting.inbox.field.total', 'Total'), doc.totalAmountMinor != null ? formatMoneyMinor(doc.totalAmountMinor, doc.currency || 'CHF') : null)}
|
||||
{field(t('accounting.inbox.field.invoiceDate', 'Invoice date'), doc.invoiceDate ? format(doc.invoiceDate) : null)}
|
||||
{field(t('accounting.inbox.field.disposition', 'Disposition'), doc.disposition ? t(`accounting.disposition.${doc.disposition}`, doc.disposition) : null)}
|
||||
{field(t('accounting.inbox.status.label', 'Status'), t(`accounting.inbox.status.${doc.status}`, doc.status))}
|
||||
{doc.customerName && field(t('accounting.inbox.field.customer', 'Client'), doc.customerName)}
|
||||
{field(t('accounting.inbox.status.label', 'Status'), doc.customerAccountId && !doc.billedInvoiceId
|
||||
? t('accounting.incoming.pendingRebill', 'Pending re-bill')
|
||||
: t(`accounting.inbox.status.${doc.status}`, doc.status))}
|
||||
{field(t('accounting.incoming.paid', 'Paid'), doc.supplierPaid
|
||||
? (doc.supplierPaidAt ? format(doc.supplierPaidAt) : t('common.yes', 'Yes'))
|
||||
: t('common.no', 'No'))}
|
||||
{doc.note && field(t('accounting.inbox.field.note', 'Note'), doc.note)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 border-t border-neutral-200 dark:border-neutral-700 px-5 py-3">
|
||||
@@ -165,18 +171,28 @@ const ViewModal: React.FC<{ doc: InboundDocument; onClose: () => void }> = ({ do
|
||||
};
|
||||
|
||||
const TriageModal: React.FC<{ doc: InboundDocument; categories: ExpenseCategory[]; onClose: () => void; onDone: () => void }> = ({ doc, categories, onClose, onDone }) => {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const [supplier, setSupplier] = useState(doc.supplierName || '');
|
||||
const [amountMajor, setAmountMajor] = useState<number>(doc.totalAmountMinor != null ? doc.totalAmountMinor / 100 : NaN);
|
||||
const [currency, setCurrency] = useState(doc.currency || 'CHF');
|
||||
const [invoiceDate, setInvoiceDate] = useState(doc.invoiceDate || '');
|
||||
const [reference, setReference] = useState(doc.paymentReference || '');
|
||||
const [disposition, setDisposition] = useState<Disposition>('eigener_aufwand');
|
||||
const [categoryId, setCategoryId] = useState<number | undefined>(undefined);
|
||||
const [eventId, setEventId] = useState<number | null>(null);
|
||||
const [customer, setCustomer] = useState<SelectedCustomer[]>([]);
|
||||
const [markupType, setMarkupType] = useState<MarkupType>('none');
|
||||
const [markupValue, setMarkupValue] = useState<number>(NaN);
|
||||
const [note, setNote] = useState(doc.note || '');
|
||||
const [supplierCountry, setSupplierCountry] = useState(doc.supplierCountry || '');
|
||||
// Pre-fill from the existing disposition so a categorized invoice can be
|
||||
// re-categorized (#1) — falls back to "company expense" for fresh docs.
|
||||
const [disposition, setDisposition] = useState<Disposition>(doc.disposition || 'eigener_aufwand');
|
||||
const [categoryId, setCategoryId] = useState<number | undefined>(doc.categoryId ?? undefined);
|
||||
const [eventId, setEventId] = useState<number | null>(doc.eventId ?? null);
|
||||
const [customer, setCustomer] = useState<SelectedCustomer[]>(
|
||||
doc.customerAccountId ? [{ id: doc.customerAccountId, email: doc.customerEmail || '', displayName: doc.customerName }] : [],
|
||||
);
|
||||
const [markupType, setMarkupType] = useState<MarkupType>(doc.markupType || 'none');
|
||||
const [markupValue, setMarkupValue] = useState<number>(
|
||||
doc.markupType === 'percent' && doc.markupPercent != null ? doc.markupPercent
|
||||
: doc.markupType === 'flat' && doc.markupFlatMinor != null ? doc.markupFlatMinor / 100
|
||||
: NaN,
|
||||
);
|
||||
|
||||
const totalMinor = Number.isFinite(amountMajor) ? Math.round(amountMajor * 100) : null;
|
||||
|
||||
@@ -191,13 +207,15 @@ const TriageModal: React.FC<{ doc: InboundDocument; categories: ExpenseCategory[
|
||||
// entered) — no second dialog — so "Save & mark paid" actually pays.
|
||||
const save = useMutation({
|
||||
mutationFn: async (pay: boolean) => {
|
||||
await accountingService.updateInbound(doc.id, { supplierName: supplier || null, totalAmountMinor: totalMinor, currency: currency || null, invoiceDate: invoiceDate || null, paymentReference: reference || null });
|
||||
await accountingService.updateInbound(doc.id, { supplierName: supplier || null, totalAmountMinor: totalMinor, currency: currency || null, invoiceDate: invoiceDate || null, paymentReference: reference || null, note: note || null, supplierCountry: supplierCountry || null });
|
||||
await accountingService.categorizeInbound(doc.id, {
|
||||
disposition,
|
||||
eventId: BOOKING_DISPOSITIONS.includes(disposition) ? eventId : null,
|
||||
categoryId: disposition === 'eigener_aufwand' ? (categoryId ?? null) : null,
|
||||
customerAccountId: disposition === 'rebill' && customer[0] ? customer[0].id : null,
|
||||
...markupPayload(),
|
||||
// Both rebill and passthrough can attach to a customer (#3).
|
||||
customerAccountId: BOOKING_DISPOSITIONS.includes(disposition) && customer[0] ? customer[0].id : null,
|
||||
// Markup is a re-bill concept only — a pass-through bills at cost.
|
||||
...(disposition === 'rebill' ? markupPayload() : { markupType: 'none', markupPercent: null, markupFlatMinor: null }),
|
||||
});
|
||||
if (pay) {
|
||||
await accountingService.markInboundPaid(doc.id, { paid: true, paymentReference: reference || undefined });
|
||||
@@ -227,20 +245,35 @@ const TriageModal: React.FC<{ doc: InboundDocument; categories: ExpenseCategory[
|
||||
<div className="col-span-2"><label className={labelCls}>{t('accounting.inbox.field.supplier', 'Supplier')}</label><Input value={supplier} onChange={(e) => setSupplier(e.target.value)} /></div>
|
||||
<div><label className={labelCls}>{t('accounting.inbox.field.total', 'Total')}</label><DecimalInput value={amountMajor} onChange={setAmountMajor} fractionDigits={2} className={selectCls} /></div>
|
||||
<div><label className={labelCls}>{t('accounting.inbox.field.currency', 'Currency')}</label><Input value={currency} maxLength={3} onChange={(e) => setCurrency(e.target.value.toUpperCase())} /></div>
|
||||
<div className="col-span-2"><label className={labelCls}>{t('accounting.inbox.field.supplierCountry', 'Supplier country')}</label>
|
||||
<select value={supplierCountry} onChange={(e) => setSupplierCountry(e.target.value)} className={selectCls}>
|
||||
<option value="">{t('accounting.inbox.field.supplierCountryNone', '— unknown —')}</option>
|
||||
{sortedCountryOptions(i18n.language).map((c) => <option key={c.code} value={c.code}>{c.label}</option>)}
|
||||
</select>
|
||||
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">{t('accounting.inbox.field.supplierCountryHint', 'Sets the tax treatment automatically: outside your VAT-reclaim countries → foreign VAT (not reclaimable).')}</p>
|
||||
</div>
|
||||
<div className="col-span-2"><label className={labelCls}>{t('accounting.inbox.field.invoiceDate', 'Invoice date')}</label><LocalizedDateInput value={invoiceDate} onChange={setInvoiceDate} /></div>
|
||||
<div className="col-span-2"><label className={labelCls}>{t('accounting.inbox.field.reference', 'Payment reference')}</label><Input value={reference} onChange={(e) => setReference(e.target.value)} placeholder={t('accounting.inbox.field.referenceHint', 'QR / ESR reference or message') as string} /></div>
|
||||
<div className="col-span-2"><label className={labelCls}>{t('accounting.inbox.field.note', 'Note')}</label>
|
||||
<textarea value={note} onChange={(e) => setNote(e.target.value)} rows={2} className={selectCls} placeholder={t('accounting.inbox.field.noteHint', 'Internal note for this invoice (optional)') as string} /></div>
|
||||
</div>
|
||||
|
||||
<div><label className={labelCls}>{t('accounting.inbox.field.disposition', 'Disposition')}</label>
|
||||
<select value={disposition} onChange={(e) => setDisposition(e.target.value as Disposition)} className={selectCls}>
|
||||
{DISPOSITIONS.map((d) => <option key={d} value={d}>{t(`accounting.disposition.${d}`, d)}</option>)}
|
||||
</select>
|
||||
{/* Explain the selected disposition — re-bill vs pass-through vs
|
||||
company expense aren't obvious from the labels alone. */}
|
||||
<p className="mt-1 rounded-md bg-neutral-50 dark:bg-neutral-800/60 px-2.5 py-1.5 text-xs text-neutral-600 dark:text-neutral-400">
|
||||
{t(`accounting.disposition.help.${disposition}`, '')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{BOOKING_DISPOSITIONS.includes(disposition) && (
|
||||
<div>
|
||||
<label className={labelCls}>{t('accounting.booking.label', 'Book to')}</label>
|
||||
<EventBookingSelect value={eventId} onChange={setEventId} className={selectCls} />
|
||||
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">{t('accounting.booking.inboundHint', 'Which event carries this cost in your reports & tax export (Company = general overhead). This is separate from who you re-bill it to.')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -253,18 +286,24 @@ const TriageModal: React.FC<{ doc: InboundDocument; categories: ExpenseCategory[
|
||||
</div>
|
||||
)}
|
||||
|
||||
{disposition === 'rebill' && (
|
||||
{BOOKING_DISPOSITIONS.includes(disposition) && (
|
||||
<div className="space-y-3 rounded-lg border border-neutral-200 dark:border-neutral-700 p-3">
|
||||
<div><label className={labelCls}>{t('accounting.inbox.field.customer', 'Client')} *</label>
|
||||
<CustomerAccountPicker value={customer.slice(0, 1)} onChange={(next) => setCustomer(next.slice(-1))} /></div>
|
||||
<div><label className={labelCls}>{t('accounting.inbox.field.markup', 'Markup')}</label>
|
||||
<select value={markupType} onChange={(e) => setMarkupType(e.target.value as MarkupType)} className={selectCls}>
|
||||
<option value="none">{t('accounting.markup.none', 'None / from contract')}</option>
|
||||
<option value="percent">{t('accounting.markup.percent', 'Percent')}</option>
|
||||
<option value="flat">{t('accounting.markup.flat', 'Flat')}</option>
|
||||
</select>
|
||||
<div><label className={labelCls}>{t('accounting.inbox.field.customer', 'Client')} {disposition === 'rebill' ? '*' : ''}</label>
|
||||
<CustomerAccountPicker value={customer.slice(0, 1)} onChange={(next) => setCustomer(next.slice(-1))} />
|
||||
{disposition === 'durchlaufend' && <p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">{t('accounting.inbox.field.passthroughCustomerHint', 'Optional — attach a client to re-bill this passthrough; leave empty to only book it to the event.')}</p>}
|
||||
</div>
|
||||
{markupType !== 'none' && <DecimalInput value={markupValue} onChange={setMarkupValue} fractionDigits={2} className={selectCls} placeholder={markupType === 'percent' ? '%' : currency} />}
|
||||
{/* Markup is a re-bill concept only. A pass-through is invoiced
|
||||
at cost (VAT-neutral), so no markup control here. */}
|
||||
{disposition === 'rebill' && (<>
|
||||
<div><label className={labelCls}>{t('accounting.inbox.field.markup', 'Markup')}</label>
|
||||
<select value={markupType} onChange={(e) => setMarkupType(e.target.value as MarkupType)} className={selectCls}>
|
||||
<option value="none">{t('accounting.markup.none', 'None / from contract')}</option>
|
||||
<option value="percent">{t('accounting.markup.percent', 'Percent')}</option>
|
||||
<option value="flat">{t('accounting.markup.flat', 'Flat')}</option>
|
||||
</select>
|
||||
</div>
|
||||
{markupType !== 'none' && <DecimalInput value={markupValue} onChange={setMarkupValue} fractionDigits={2} className={selectCls} placeholder={markupType === 'percent' ? '%' : currency} />}
|
||||
</>)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -283,6 +322,7 @@ const TriageModal: React.FC<{ doc: InboundDocument; categories: ExpenseCategory[
|
||||
export const AccountingInboxPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const { format } = useLocalizedDate();
|
||||
const cameraRef = useRef<HTMLInputElement>(null);
|
||||
const uploadRef = useRef<HTMLInputElement>(null);
|
||||
@@ -294,6 +334,8 @@ export const AccountingInboxPage: React.FC = () => {
|
||||
// without a manual reload (the poller runs server-side every 60s).
|
||||
const { data, isLoading } = useQuery({ queryKey: ['accounting-inbound'], queryFn: () => accountingService.listInbound({ pageSize: 100 }), refetchInterval: 30000, refetchOnWindowFocus: true });
|
||||
const { data: categories } = useQuery({ queryKey: ['expense-categories'], queryFn: () => accountingService.listCategories() });
|
||||
// Per-event customers carrying pending (categorised, unbilled) re-bills (#3).
|
||||
const { data: pending } = useQuery({ queryKey: ['accounting-pending-rebills'], queryFn: () => accountingService.listPendingRebills(), refetchInterval: 30000 });
|
||||
|
||||
const upload = useMutation({
|
||||
mutationFn: ({ file, source }: { file: File; source: 'upload' | 'camera' }) => accountingService.uploadInbound(file, source),
|
||||
@@ -305,11 +347,24 @@ export const AccountingInboxPage: React.FC = () => {
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['accounting-inbound'] }); },
|
||||
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'),
|
||||
});
|
||||
const billPending = useMutation({
|
||||
mutationFn: (customerAccountId: number) => accountingService.billPendingRebills(customerAccountId),
|
||||
onSuccess: ({ invoiceId, count }) => {
|
||||
toast.success(t('accounting.incoming.bundledToast', 'Bundled {{count}} re-bill(s) into one invoice.', { count }));
|
||||
qc.invalidateQueries({ queryKey: ['accounting-inbound'] });
|
||||
qc.invalidateQueries({ queryKey: ['accounting-pending-rebills'] });
|
||||
navigate(`/admin/clients/bills/${invoiceId}/edit`);
|
||||
},
|
||||
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'),
|
||||
});
|
||||
|
||||
const onFile = (source: 'upload' | 'camera') => (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]; if (file) upload.mutate({ file, source }); e.target.value = '';
|
||||
};
|
||||
const refresh = () => qc.invalidateQueries({ queryKey: ['accounting-inbound'] });
|
||||
const refresh = () => { qc.invalidateQueries({ queryKey: ['accounting-inbound'] }); qc.invalidateQueries({ queryKey: ['accounting-pending-rebills'] }); };
|
||||
|
||||
const pendingItems = pending ?? [];
|
||||
const customerLabel = (p: typeof pendingItems[number]) => p.displayName || p.companyName || [p.firstName, p.lastName].filter(Boolean).join(' ') || p.email || `#${p.customerAccountId}`;
|
||||
|
||||
const handleTriageDone = () => { setTriageDoc(null); refresh(); };
|
||||
|
||||
@@ -329,6 +384,31 @@ export const AccountingInboxPage: React.FC = () => {
|
||||
<Button variant="outline" onClick={() => uploadRef.current?.click()} disabled={upload.isPending}><Upload className="w-4 h-4 mr-2" /> {t('accounting.inbox.uploadFile', 'Upload file')}</Button>
|
||||
</CardContent></Card>
|
||||
|
||||
{/* Pending re-bills (#3): per-event customers accumulate categorised
|
||||
rebill/passthrough invoices here; bundle them into one invoice like
|
||||
"Bill these hours". Monthly/manual customers never surface (they
|
||||
consolidate onto their running draft at categorise time). */}
|
||||
{pendingItems.length > 0 && (
|
||||
<Card className="mb-6"><CardContent className="p-5">
|
||||
<h2 className="text-base font-semibold text-neutral-900 dark:text-neutral-100 mb-1">{t('accounting.incoming.pendingTitle', 'Pending re-bills')}</h2>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-3">{t('accounting.incoming.pendingBody', 'Categorized invoices waiting to be re-billed. Bundle a client’s items into one invoice.')}</p>
|
||||
<div className="space-y-2">
|
||||
{pendingItems.map((p) => (
|
||||
<div key={p.customerAccountId} className="flex flex-wrap items-center gap-3 rounded-lg border border-neutral-200 dark:border-neutral-700 px-4 py-2">
|
||||
<div className="flex-1 min-w-[12rem]">
|
||||
<div className="text-sm font-medium text-neutral-900 dark:text-neutral-100">{customerLabel(p)}</div>
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{t('accounting.incoming.pendingCount', '{{count}} item(s)', { count: p.itemCount })}
|
||||
{' · '}{formatMoneyMinor(p.openAmountMinor, 'CHF')}
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => billPending.mutate(p.customerAccountId)} disabled={billPending.isPending}><Send className="w-3.5 h-3.5 mr-1" /> {t('accounting.incoming.billPending', 'Bill these')}</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent></Card>
|
||||
)}
|
||||
|
||||
{isLoading ? <Loading /> : items.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-neutral-300 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-900 p-8 text-center">
|
||||
<Inbox className="w-10 h-10 mx-auto mb-3 text-neutral-400" />
|
||||
@@ -362,6 +442,10 @@ export const AccountingInboxPage: React.FC = () => {
|
||||
{doc.totalAmountMinor != null ? formatMoneyMinor(doc.totalAmountMinor, doc.currency || 'CHF') : t('accounting.inbox.noAmount', 'amount not entered')}
|
||||
{' · '}{format(doc.createdAt)}
|
||||
{doc.disposition && <>{' · '}{t(`accounting.disposition.${doc.disposition}`, doc.disposition)}</>}
|
||||
{/* Pending re-bill = attached to a client but not yet on an invoice. */}
|
||||
{doc.customerAccountId && !doc.billedInvoiceId && (
|
||||
<span className="text-indigo-600 dark:text-indigo-400">{' · '}{t('accounting.incoming.pendingRebill', 'Pending re-bill')}{doc.customerName ? ` → ${doc.customerName}` : ''}</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setViewDoc(doc)}><Eye className="w-3.5 h-3.5 mr-1" /> {t('accounting.inbox.view', 'View')}</Button>
|
||||
@@ -372,7 +456,11 @@ export const AccountingInboxPage: React.FC = () => {
|
||||
? <Button size="sm" variant="ghost" onClick={() => unpay.mutate(doc.id)} disabled={unpay.isPending}><RotateCcw className="w-3.5 h-3.5 mr-1" /> {t('accounting.incoming.markUnpaid', 'Mark unpaid')}</Button>
|
||||
: <Button size="sm" variant="outline" onClick={() => setPayDoc(doc)}><Circle className="w-3.5 h-3.5 mr-1" /> {t('accounting.incoming.markPaid', 'Mark paid')}</Button>
|
||||
)}
|
||||
{doc.status === 'unsorted' && <Button size="sm" onClick={() => setTriageDoc(doc)}>{t('accounting.inbox.categorize', 'Categorize')}</Button>}
|
||||
{/* #1: re-categorize is available after the first triage too, so a
|
||||
disposition can be changed (e.g. passthrough → company expense). */}
|
||||
{doc.status === 'unsorted'
|
||||
? <Button size="sm" onClick={() => setTriageDoc(doc)}>{t('accounting.inbox.categorize', 'Categorize')}</Button>
|
||||
: <Button size="sm" variant="outline" onClick={() => setTriageDoc(doc)}><Pencil className="w-3.5 h-3.5 mr-1" /> {t('accounting.inbox.recategorize', 'Re-categorize')}</Button>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -15,6 +15,8 @@ import { contractsService } from '../../../services/contracts.service';
|
||||
import { businessProfileService } from '../../../services/businessProfile.service';
|
||||
import { CustomerPicker } from '../../../components/admin/CustomerPicker';
|
||||
import { VatRateSelect } from '../../../components/admin/VatRateSelect';
|
||||
import { accountingService } from '../../../services/accounting.service';
|
||||
import { vatCodesService } from '../../../services/vatCodes.service';
|
||||
import { LineItemsTable, type EditableLineItem } from '../../../components/admin/LineItemsTable';
|
||||
import { InstallmentsPanel } from '../../../components/admin/InstallmentsPanel';
|
||||
import { customerAdminService } from '../../../services/customerAdmin.service';
|
||||
@@ -197,6 +199,23 @@ export const BillEditorPage: React.FC = () => {
|
||||
setCcPdfEmail((cur) => cur || currentAdmin.email);
|
||||
}, [currentAdmin?.email, isEdit]);
|
||||
|
||||
// Seed the VAT from the configured default OUTPUT code (Settings →
|
||||
// Accounting) on a brand-new, blank invoice — so new invoices don't silently
|
||||
// start at 0%. Skips edits and conversions (quote/contract bring their own
|
||||
// VAT), and never clobbers a value the admin already touched.
|
||||
const { data: acctSettings } = useQuery({ queryKey: ['accounting-settings'], queryFn: () => accountingService.getSettings() });
|
||||
const { data: outputVatCodes } = useQuery({ queryKey: ['vat-codes', 'output'], queryFn: () => vatCodesService.listOutput() });
|
||||
const didSeedVatRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (isEdit || didSeedVatRef.current) return;
|
||||
if (searchParams.get('fromContractId') || searchParams.get('fromQuoteId')) return;
|
||||
if (vatCode || vatRate) return;
|
||||
const code = acctSettings?.accounting_default_output_vat_code;
|
||||
if (!code || !outputVatCodes) return;
|
||||
const match = outputVatCodes.find((c) => c.code === code);
|
||||
if (match) { didSeedVatRef.current = true; setVatRate(Number(match.rate)); setVatCode(match.code); }
|
||||
}, [isEdit, searchParams, acctSettings, outputVatCodes, vatCode, vatRate]);
|
||||
|
||||
// Pre-fill the customer when the editor is opened from a customer
|
||||
// detail page via `?customerAccountId=42`. Runs once on mount, only
|
||||
// when creating a new invoice, and skips if the user has already
|
||||
|
||||
@@ -26,6 +26,8 @@ import { LineItemsTable, type EditableLineItem } from '../../../components/admin
|
||||
import { CustomerPicker } from '../../../components/admin/CustomerPicker';
|
||||
import { ProjectSelect } from '../../../components/admin/ProjectSelect';
|
||||
import { VatRateSelect } from '../../../components/admin/VatRateSelect';
|
||||
import { accountingService } from '../../../services/accounting.service';
|
||||
import { vatCodesService } from '../../../services/vatCodes.service';
|
||||
import { InstallmentsPanel } from '../../../components/admin/InstallmentsPanel';
|
||||
import { customerAdminService } from '../../../services/customerAdmin.service';
|
||||
import { userManagementService } from '../../../services/userManagement.service';
|
||||
@@ -189,6 +191,23 @@ export const QuoteEditorPage: React.FC = () => {
|
||||
}
|
||||
})();
|
||||
}, [isEdit, searchParams]);
|
||||
|
||||
// Seed the VAT from the configured default OUTPUT code (Settings →
|
||||
// Accounting) on a brand-new, blank quote — so quotes (and the invoices they
|
||||
// convert to) don't silently start at 0%. Never clobbers a touched value.
|
||||
const { data: acctSettings } = useQuery({ queryKey: ['accounting-settings'], queryFn: () => accountingService.getSettings() });
|
||||
const { data: outputVatCodes } = useQuery({ queryKey: ['vat-codes', 'output'], queryFn: () => vatCodesService.listOutput() });
|
||||
const didSeedVatRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (isEdit || didSeedVatRef.current) return;
|
||||
const code = acctSettings?.accounting_default_output_vat_code;
|
||||
if (!code || !outputVatCodes) return;
|
||||
const match = outputVatCodes.find((c) => c.code === code);
|
||||
if (!match) return;
|
||||
setForm((prev) => (prev.vatCode || prev.vatRate ? prev : { ...prev, vatRate: Number(match.rate), vatCode: match.code }));
|
||||
didSeedVatRef.current = true;
|
||||
}, [isEdit, acctSettings, outputVatCodes]);
|
||||
|
||||
// Customer search + inline-create state now lives inside
|
||||
// <CustomerPicker> (migration C.5 extraction).
|
||||
|
||||
|
||||
@@ -18,8 +18,8 @@ import {
|
||||
type QrFormat,
|
||||
} from '../../../services/businessProfile.service';
|
||||
import { Button, Card, Loading, Input, CountrySelect, TimeField } from '../../../components/common';
|
||||
import { DecimalInput } from '../../../components/common/DecimalInput';
|
||||
import { toast } from 'react-toastify';
|
||||
import { currencyOptions, normalizeCurrency } from '../../../constants/currencies';
|
||||
|
||||
// Full IANA timezone list for the picker. `Intl.supportedValuesOf` is ES2022
|
||||
// (all current browsers); fall back to a small CH/LI-relevant set on the rare
|
||||
@@ -45,7 +45,16 @@ export const SettingsBusinessProfilePage: React.FC = () => {
|
||||
useEffect(() => { if (data?.profile) setProfile(data.profile); }, [data]);
|
||||
|
||||
const saveProfile = useMutation({
|
||||
mutationFn: () => profile ? businessProfileService.update(profile) : Promise.reject(),
|
||||
// vatLabel + defaultHourlyRateMinor now live on Settings → Accounting, and
|
||||
// vatRateDefault is retired (the rates are the Accounting VAT codes). Strip
|
||||
// them from this save so an open Business-profile page can't clobber an edit
|
||||
// made on the Accounting tab with its stale loaded value.
|
||||
mutationFn: () => {
|
||||
if (!profile) return Promise.reject();
|
||||
const { vatLabel, defaultHourlyRateMinor, vatRateDefault, ...rest } = profile;
|
||||
void vatLabel; void defaultHourlyRateMinor; void vatRateDefault;
|
||||
return businessProfileService.update(rest);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(t('businessProfile.savedToast', 'Business profile saved.'));
|
||||
qc.invalidateQueries({ queryKey: ['business-profile'] });
|
||||
@@ -124,9 +133,29 @@ export const SettingsBusinessProfilePage: React.FC = () => {
|
||||
|
||||
<Card>
|
||||
<h3 className="font-semibold mb-3">{t('businessProfile.section.defaults', 'Defaults')}</h3>
|
||||
{/* Pointer so admins who look for the old VAT/hourly-rate fields here
|
||||
know where they went. */}
|
||||
<p className="mb-3 rounded-md border border-blue-200 dark:border-blue-900/50 bg-blue-50 dark:bg-blue-900/20 px-3 py-2 text-xs text-blue-800 dark:text-blue-300">
|
||||
{t('businessProfile.movedToAccounting', 'The VAT rate, VAT label and default hourly rate now live under Settings → Accounting.')}
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<Input label={t('businessProfile.field.defaultCurrency', 'Default currency') as string} value={profile.defaultCurrency}
|
||||
maxLength={3} onChange={(e) => setProfile({ ...profile, defaultCurrency: e.target.value.toUpperCase() })} />
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('businessProfile.field.defaultCurrency', 'Default currency')}
|
||||
</label>
|
||||
{/* Dropdown; the stored value is normalised (e.g. an old free-text
|
||||
"chf" → "CHF") so it pre-selects, and an unknown code is kept as
|
||||
an extra option so nothing is lost. */}
|
||||
<select
|
||||
value={normalizeCurrency(profile.defaultCurrency)}
|
||||
onChange={(e) => setProfile({ ...profile, defaultCurrency: e.target.value })}
|
||||
className="w-full px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
{currencyOptions(profile.defaultCurrency).map((c) => (
|
||||
<option key={c} value={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<Input label={t('businessProfile.field.defaultLocale', 'Default locale') as string} value={profile.defaultLocale}
|
||||
maxLength={8} onChange={(e) => setProfile({ ...profile, defaultLocale: e.target.value })} />
|
||||
{/* Migration 137 — IANA timezone for the admin calendar + the
|
||||
@@ -149,35 +178,9 @@ export const SettingsBusinessProfilePage: React.FC = () => {
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<Input label={t('businessProfile.field.vatLabel', 'VAT label (e.g. MwSt., VAT)') as string} value={profile.vatLabel}
|
||||
onChange={(e) => setProfile({ ...profile, vatLabel: e.target.value })} />
|
||||
<Input type="number" step="0.01" label={t('businessProfile.field.vatRateDefault', 'Default VAT rate %') as string}
|
||||
value={profile.vatRateDefault ?? 0}
|
||||
onChange={(e) => setProfile({ ...profile, vatRateDefault: Number(e.target.value) })} />
|
||||
{/* Install-wide fallback hourly rate (migration 113). Stored in
|
||||
minor units; entered here in major units. Blank = no global
|
||||
default, so hours-logging then needs a per-customer or
|
||||
per-entry rate. Comma-tolerant via DecimalInput. */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
{t('businessProfile.field.defaultHourlyRate', 'Default hourly rate')}
|
||||
</label>
|
||||
<DecimalInput
|
||||
value={profile.defaultHourlyRateMinor != null ? profile.defaultHourlyRateMinor / 100 : NaN}
|
||||
fractionDigits={2}
|
||||
onChange={(n) => setProfile({
|
||||
...profile,
|
||||
defaultHourlyRateMinor: Number.isFinite(n) ? Math.max(0, Math.round(n * 100)) : null,
|
||||
})}
|
||||
className="w-full px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm"
|
||||
placeholder={t('businessProfile.field.defaultHourlyRatePlaceholder', 'e.g. 120.00') as string}
|
||||
/>
|
||||
<p className="text-xs text-muted-theme mt-1">
|
||||
{t('businessProfile.field.defaultHourlyRateHint',
|
||||
'Fallback used when a customer has no own rate. In {{currency}}, major units. Leave blank to require a per-customer or per-entry rate.',
|
||||
{ currency: profile.defaultCurrency || 'CHF' })}
|
||||
</p>
|
||||
</div>
|
||||
{/* VAT rate %, VAT label and the default hourly rate moved to
|
||||
Settings → Accounting (so all financial/VAT config lives in one
|
||||
place). See the callout above. */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">{t('businessProfile.field.defaultQrFormat', 'Default invoice QR')}</label>
|
||||
<select value={profile.defaultQrFormat} onChange={(e) => setProfile({ ...profile, defaultQrFormat: e.target.value as QrFormat })}
|
||||
|
||||
@@ -35,6 +35,14 @@ export interface InboundDocument {
|
||||
markupPercent: number | null;
|
||||
markupFlatMinor: number | null;
|
||||
billedInvoiceId: number | null;
|
||||
/** Client a rebill/passthrough is attached to (migration 132). */
|
||||
customerAccountId: number | null;
|
||||
customerName: string | null;
|
||||
customerEmail: string | null;
|
||||
/** ISO-2 supplier country — auto-defaults the tax treatment (reclaim list). */
|
||||
supplierCountry: string | null;
|
||||
/** Free-text categorisation note. */
|
||||
note: string | null;
|
||||
supplierPaid: boolean;
|
||||
supplierPaidAt: string | null;
|
||||
supplierPaymentMethod: PaymentMethod | null;
|
||||
@@ -79,6 +87,20 @@ export interface InvoiceExpensePayload {
|
||||
markupFlatMinor?: number | null;
|
||||
}
|
||||
|
||||
/** One customer with categorised-but-unbilled rebill/passthrough docs. */
|
||||
export interface PendingRebillSummary {
|
||||
customerAccountId: number;
|
||||
companyName: string | null;
|
||||
displayName: string | null;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
email: string | null;
|
||||
isPassive: boolean;
|
||||
billingCadence: string | null;
|
||||
itemCount: number;
|
||||
openAmountMinor: number;
|
||||
}
|
||||
|
||||
export interface ExpenseCategory { id: number; name: string; color: string | null; is_seed: boolean; display_order: number; }
|
||||
export interface Paginated<T> { items: T[]; pagination: { page: number; pageSize: number; total: number; totalPages: number }; }
|
||||
|
||||
@@ -92,6 +114,8 @@ export interface AccountingSettings {
|
||||
/** ISO-2 countries whose input VAT can be reclaimed (drives cost
|
||||
* tax-treatment + the report's VAT-payable). */
|
||||
accounting_vat_reclaim_countries: string[];
|
||||
/** Output VAT code stamped onto NEW invoices/quotes ('' = none). */
|
||||
accounting_default_output_vat_code: string;
|
||||
}
|
||||
|
||||
export interface CategorizePayload {
|
||||
@@ -148,6 +172,10 @@ export const accountingService = {
|
||||
async updateInbound(id: number, fields: Partial<InboundDocument>): Promise<InboundDocument> { const { data } = await api.patch(`/admin/expenses/inbound/${id}`, fields); return data.document; },
|
||||
async categorizeInbound(id: number, payload: CategorizePayload): Promise<InboundDocument> { const { data } = await api.post(`/admin/expenses/inbound/${id}/categorize`, payload); return data.document; },
|
||||
async rebillInbound(id: number, payload: CategorizePayload): Promise<{ document: InboundDocument; invoiceId: number }> { const { data } = await api.post(`/admin/expenses/inbound/${id}/rebill`, payload); return data; },
|
||||
/** Per-event customers with pending (categorised, unbilled) re-bills. */
|
||||
async listPendingRebills(): Promise<PendingRebillSummary[]> { const { data } = await api.get('/admin/expenses/inbound/pending-summary'); return data.items; },
|
||||
/** Bundle one customer's pending re-bills into a single invoice. */
|
||||
async billPendingRebills(customerAccountId: number): Promise<{ invoiceId: number; count: number }> { const { data } = await api.post('/admin/expenses/inbound/bill-pending', { customerAccountId }); return data; },
|
||||
async markInboundPaid(id: number, payload: { paid: boolean; paidAt?: string; paymentMethod?: PaymentMethod; paymentReference?: string }): Promise<InboundDocument> { const { data } = await api.post(`/admin/expenses/inbound/${id}/supplier-payment`, payload); return data.document; },
|
||||
async getInboundFileBlob(id: number): Promise<Blob> { const { data } = await api.get(`/admin/expenses/inbound/${id}/file`, { responseType: 'blob' }); return data; },
|
||||
async getInboundPageBlob(id: number, page: number): Promise<Blob> { const { data } = await api.get(`/admin/expenses/inbound/${id}/page/${page}`, { responseType: 'blob' }); return data; },
|
||||
@@ -186,6 +214,8 @@ export const accountingService = {
|
||||
accounting_vat_registered: data.accounting_vat_registered === true,
|
||||
accounting_vat_reclaim_countries: Array.isArray(data.accounting_vat_reclaim_countries)
|
||||
? data.accounting_vat_reclaim_countries : [],
|
||||
accounting_default_output_vat_code: typeof data.accounting_default_output_vat_code === 'string'
|
||||
? data.accounting_default_output_vat_code : '',
|
||||
};
|
||||
},
|
||||
async updateSettings(payload: Partial<AccountingSettings>): Promise<{ updated: string[] }> {
|
||||
|
||||
Reference in New Issue
Block a user