Merge pull request #622 from Luca-Timo/feat/accounting-inbound-invoices
Accounting suite + CRM hardening
This commit is contained in:
@@ -134,7 +134,10 @@ jobs:
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.meta-backend.outputs.labels }}
|
||||
cache-from: type=gha,scope=backend-${{ env.PLATFORM_PAIR }}
|
||||
cache-to: type=gha,mode=max,scope=backend-${{ env.PLATFORM_PAIR }}
|
||||
# ignore-error: a flaky GitHub Actions cache write ("error writing
|
||||
# layer blob: not_found") must not fail an otherwise-successful build
|
||||
# that already pushed the image.
|
||||
cache-to: type=gha,mode=max,scope=backend-${{ env.PLATFORM_PAIR }},ignore-error=true
|
||||
outputs: ${{ steps.push-decision.outputs.push == 'true' && format('type=image,name={0}/{1},push-by-digest=true,name-canonical=true,push=true', env.REGISTRY, env.BACKEND_IMAGE_NAME) || 'type=cacheonly' }}
|
||||
build-args: |
|
||||
CACHEBUST=${{ github.run_number }}
|
||||
@@ -360,7 +363,10 @@ jobs:
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.meta-frontend.outputs.labels }}
|
||||
cache-from: type=gha,scope=frontend-${{ env.PLATFORM_PAIR }}
|
||||
cache-to: type=gha,mode=max,scope=frontend-${{ env.PLATFORM_PAIR }}
|
||||
# ignore-error: a flaky GitHub Actions cache write ("error writing
|
||||
# layer blob: not_found") must not fail an otherwise-successful build
|
||||
# that already pushed the image.
|
||||
cache-to: type=gha,mode=max,scope=frontend-${{ env.PLATFORM_PAIR }},ignore-error=true
|
||||
outputs: ${{ steps.push-decision.outputs.push == 'true' && format('type=image,name={0}/{1},push-by-digest=true,name-canonical=true,push=true', env.REGISTRY, env.FRONTEND_IMAGE_NAME) || 'type=cacheonly' }}
|
||||
build-args: |
|
||||
CACHEBUST=${{ github.run_number }}
|
||||
|
||||
@@ -61,7 +61,8 @@ jobs:
|
||||
load: true
|
||||
tags: picpeak-backend:smoke
|
||||
cache-from: type=gha,scope=install-smoke
|
||||
cache-to: type=gha,mode=max,scope=install-smoke
|
||||
# ignore-error: a flaky GHA cache write must not fail the build.
|
||||
cache-to: type=gha,mode=max,scope=install-smoke,ignore-error=true
|
||||
|
||||
- name: Create Docker network
|
||||
run: docker network create picpeak-smoke
|
||||
|
||||
@@ -129,3 +129,6 @@ docker-compose.dev.yml
|
||||
|
||||
# New layout development files
|
||||
new-layouts/
|
||||
|
||||
# Generated CRM/accounting documents (runtime) — never commit
|
||||
backend/storage/business-docs/
|
||||
|
||||
@@ -68,6 +68,14 @@ Unlike expensive SaaS solutions, PicPeak gives you:
|
||||
- 🛡️ **Security First** - JWT auth, rate limiting, CORS protection
|
||||
- 📈 **Scalable** - From small studios to large agencies
|
||||
|
||||
### For Studios — CRM & Accounting (Beta · off by default)
|
||||
- 📝 **Quotes → Contracts → Invoices** - One deal lineage; cancel-and-reissue (Storno) keeps issued invoices immutable
|
||||
- ⏱️ **Hours Logging & Calendar** - Per-customer time tracking; admin calendar of events, logged hours, and pending quotes/contracts
|
||||
- 🧾 **Inbound Supplier Invoices & Expenses** - Capture received invoices (upload/camera, rasterised server-side), categorise, and re-bill costs to clients
|
||||
- 📊 **Tax Report & Accountant Export** - Period-scoped income/cost report with VAT breakdown; PDF/CSV plus a Treuhänder/Banana (Swiss/LI) journal export, scopable to income-only or cost-only
|
||||
- 🌍 **VAT & Multi-currency** - Single VAT-code registry snapshotted onto each document; data-driven per-country rates
|
||||
- ⚠️ **Verify locally** - Feature-flagged off by default. Seeded contracts, QR/IBAN and tax defaults are **examples only** — review your own legal **and tax** regulations first (see disclaimers below)
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
Get PicPeak running in under 5 minutes:
|
||||
@@ -401,7 +409,7 @@ These features are currently in beta testing and may have limited functionality
|
||||
|
||||
| Feature | Description | Status |
|
||||
|---------|-------------|--------|
|
||||
| **CRM Module** | Quotes, contracts, invoices, hours logging, calendar, and tax report — feature-flagged off by default. Seeded contract blocks, payment terms, and IBAN / tax defaults are **examples only** and need legal / financial review before customer-facing use. See [docs.picpeak.app/features/crm](https://docs.picpeak.app/features/crm). | 🧪 Beta |
|
||||
| **CRM & Accounting Module** | Quotes, contracts, invoices (+ Storno), hours logging, calendar, and tax report — plus inbound supplier-invoice capture, internal expenses, and a Treuhänder/Banana (Swiss/LI) accountant-journal export. Feature-flagged off by default. Seeded contract blocks, payment terms, IBAN / QR-bill and tax defaults are **examples only** and need legal / financial / **tax** review before customer-facing use. See [docs.picpeak.app/features/crm](https://docs.picpeak.app/features/crm). | 🧪 Beta |
|
||||
| **Simple Deployment Script** | One-click deployment script for quick server setup with automated configuration and dependency installation | 🧪 Beta |
|
||||
|
||||
### 📋 Future Enhancements
|
||||
@@ -440,7 +448,7 @@ PicPeak is inspired by the best features of commercial platforms while remaining
|
||||
|
||||
A huge thank you to the people whose code, reports, and feedback have shaped PicPeak:
|
||||
|
||||
- [**@Luca-Timo**](https://github.com/Luca-Timo) — native Apple Silicon multi-arch images, external-URL toggle for legal CMS pages, the lazy-loaded folder tree picker, the admin-email picker on event creation, the data-driven self-hosted webfont system, the gallery header/banner decoupling, and several typed-API refactors. Consistently raises the bar with thoughtful PRs.
|
||||
- [**@Luca-Timo**](https://github.com/Luca-Timo) — native Apple Silicon multi-arch images, external-URL toggle for legal CMS pages, the lazy-loaded folder tree picker, the admin-email picker on event creation, the data-driven self-hosted webfont system, the gallery header/banner decoupling, several typed-API refactors, and the CRM + accounting suite (quotes/contracts/invoices, hours logging, calendar, tax report, inbound supplier-invoice capture, expenses, and the Treuhänder/Banana export). Consistently raises the bar with thoughtful PRs.
|
||||
- [**@Rekoo-PS**](https://github.com/Rekoo-PS) — sharp-eyed bug reporter and product feedback. Filed the issues that drove the login-loop fix, the gallery-loading skeleton work, the redirection cleanup, the mobile-lightbox overhaul, the admin-events search-counter fix, the photo-count column, and the bulk-delete workflow. Also a [BuyMeACoffee](https://buymeacoffee.com/theluap) supporter — the kind of feedback loop that keeps the project useful for real deployments.
|
||||
|
||||
If you've contributed and aren't listed here, please open a PR — this list is meant to grow.
|
||||
@@ -455,10 +463,11 @@ This project was generated with the assistance of AI technology, but has been:
|
||||
|
||||
We believe in transparent development practices and the responsible use of AI as a tool to accelerate development while maintaining high standards of quality and security.
|
||||
|
||||
## ⚠️ CRM disclaimers — examples only
|
||||
## ⚠️ CRM & Accounting disclaimers — examples only, verify locally
|
||||
|
||||
The CRM module (contracts, invoices, QR-bills) ships seeded content
|
||||
that is intended as a **starting point only**:
|
||||
The CRM & accounting modules (contracts, invoices, QR-bills, the tax
|
||||
report and the accountant exports) ship seeded content and computed
|
||||
figures that are intended as a **starting point only**:
|
||||
|
||||
- **Contract blocks** (image rights, NDA, model release, cancellation,
|
||||
jurisdiction, …) are written by the maintainer, **not by a lawyer**.
|
||||
@@ -468,9 +477,19 @@ that is intended as a **starting point only**:
|
||||
typed. Picpeak is open source — please scan a test invoice with your
|
||||
bank's app to check the QR actually works. We are not responsible for
|
||||
any mistakes that come from sending an invoice with bad data on it.
|
||||
- **Tax, VAT & accounting figures** (the tax report, VAT-payable, the
|
||||
per-rate breakdown, the Treuhänder / Banana export, etc.) are computed
|
||||
from the data you enter and the defaults you configure. They are
|
||||
**guidance only and jurisdiction-specific** — tax rules, VAT rates,
|
||||
deduction schemes (e.g. the Liechtenstein 20 % Gewinnungskosten flat
|
||||
rate) and filing duties differ by country and change over time. **Every
|
||||
operator must check their own tax / VAT regulations and verify the
|
||||
numbers with their accountant / Treuhänder / tax authority before
|
||||
relying on any figure or export.** Picpeak makes no warranty that the
|
||||
output is correct for your jurisdiction or situation.
|
||||
|
||||
Read [`docs/crm-disclaimers.md`](docs/crm-disclaimers.md) before
|
||||
enabling the Contracts or Invoices features.
|
||||
enabling the Contracts, Invoices or Accounting features.
|
||||
|
||||
## 📄 License
|
||||
|
||||
|
||||
+6
-1
@@ -50,8 +50,13 @@ RUN npm install -g npm@10
|
||||
# Unicode fallback; picpeak's own brand fonts (assets/fonts/, the same files
|
||||
# PDFKit + the web UI use) are registered with fontconfig further down so the
|
||||
# logo's text renders in its actual typeface, not a fallback.
|
||||
# poppler-utils provides `pdftoppm`, used to rasterise inbound supplier-invoice
|
||||
# PDFs to flat PNGs server-side so the admin UI NEVER renders a raw (possibly
|
||||
# malicious) PDF. pdftoppm does not execute embedded JS or fetch remote
|
||||
# resources, so it doubles as the SSRF/phone-home guard for untrusted inbound
|
||||
# documents (see docs/accounting-inbound-invoices.md).
|
||||
RUN apk add --no-cache dumb-init postgresql-client ffmpeg su-exec \
|
||||
fontconfig ttf-dejavu ttf-liberation && \
|
||||
fontconfig ttf-dejavu ttf-liberation poppler-utils && \
|
||||
fc-cache -f
|
||||
|
||||
# Create non-root user
|
||||
|
||||
@@ -72,7 +72,9 @@ describe('admin CRM routes — auth + permission gate', () => {
|
||||
// of permissions, so for happy-path tests we flip every CRM flag
|
||||
// on. Negative tests (no-token, bad-signature) hit adminAuth
|
||||
// first and never reach the flag check, so they're unaffected.
|
||||
const crmFlags = ['quotes', 'bills', 'contracts', 'hoursLogging', 'calendar', 'taxReport', 'clients'];
|
||||
// `accounting` is the master flag the tax-report route now requires
|
||||
// (tax export moved out of CRM into Accounting, independent of bills).
|
||||
const crmFlags = ['quotes', 'bills', 'contracts', 'hoursLogging', 'calendar', 'taxReport', 'clients', 'accounting'];
|
||||
for (const key of crmFlags) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await db('feature_flags').where({ key }).update({ value: 1 });
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Unit tests for the accounting money logic — re-bill markup (incoming
|
||||
* invoices) and internal-expense amount/build. Pure functions via _internal.
|
||||
*/
|
||||
const expenseService = require('../../src/services/expenseService');
|
||||
|
||||
const { computeMarkupMinor, resolveMarkup, computeExpenseAmount, buildExpenseInsert } = expenseService._internal;
|
||||
|
||||
describe('computeMarkupMinor', () => {
|
||||
it('percent of base, rounded', () => {
|
||||
expect(computeMarkupMinor(10000, { type: 'percent', percent: 10 })).toBe(1000);
|
||||
expect(computeMarkupMinor(333, { type: 'percent', percent: 10 })).toBe(33);
|
||||
expect(computeMarkupMinor(335, { type: 'percent', percent: 10 })).toBe(34);
|
||||
});
|
||||
it('flat / none', () => {
|
||||
expect(computeMarkupMinor(10000, { type: 'flat', flatMinor: 500 })).toBe(500);
|
||||
expect(computeMarkupMinor(10000, { type: 'none' })).toBe(0);
|
||||
expect(computeMarkupMinor(10000, { type: 'percent', percent: null })).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveMarkup precedence (no contract / no DB)', () => {
|
||||
it('override > source clause', async () => {
|
||||
await expect(resolveMarkup({ markupType: 'flat', markupFlatMinor: 999 }, { markupType: 'percent', markupPercent: 5 }, null, null))
|
||||
.resolves.toEqual({ type: 'percent', percent: 5, flatMinor: null });
|
||||
});
|
||||
it("source clause when no override", async () => {
|
||||
await expect(resolveMarkup({ markupType: 'flat', markupFlatMinor: 200 }, {}, null, null))
|
||||
.resolves.toEqual({ type: 'flat', percent: null, flatMinor: 200 });
|
||||
});
|
||||
it('none when nothing set', async () => {
|
||||
await expect(resolveMarkup({ markupType: 'none' }, {}, null, null))
|
||||
.resolves.toEqual({ type: 'none', percent: null, flatMinor: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeExpenseAmount', () => {
|
||||
it('mileage / per-diem = quantity x rate, rounded', () => {
|
||||
expect(computeExpenseAmount('mileage', 42, 70, null)).toBe(2940); // 42 km x CHF 0.70
|
||||
expect(computeExpenseAmount('per_diem', 3, 8000, null)).toBe(24000); // 3 days x CHF 80
|
||||
expect(computeExpenseAmount('mileage', 10.5, 71, null)).toBe(746); // 745.5 -> 746
|
||||
});
|
||||
it('amount = the entered minor amount', () => {
|
||||
expect(computeExpenseAmount('amount', null, null, 5000)).toBe(5000);
|
||||
});
|
||||
it('null when quantity or rate missing', () => {
|
||||
expect(computeExpenseAmount('mileage', null, 70, null)).toBeNull();
|
||||
expect(computeExpenseAmount('mileage', 42, null, null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildExpenseInsert (internal expense)', () => {
|
||||
it('defaults: kind=amount, disposition=eigener_aufwand, tax=domestic, status=open', () => {
|
||||
const row = buildExpenseInsert({ chfAmountMinor: 5000 }, 7);
|
||||
expect(row.kind).toBe('amount');
|
||||
expect(row.disposition).toBe('eigener_aufwand');
|
||||
expect(row.tax_treatment).toBe('domestic');
|
||||
expect(row.status).toBe('open');
|
||||
expect(row.chf_amount_minor).toBe(5000);
|
||||
expect(row.created_by_admin_id).toBe(7);
|
||||
expect(row.inbound_document_id).toBeNull();
|
||||
});
|
||||
|
||||
it('mileage uses the override rate, else the settings km rate', () => {
|
||||
const withDefault = buildExpenseInsert({ kind: 'mileage', quantity: 42 }, 1, { kmRateMinor: 70 });
|
||||
expect(withDefault.rate_minor).toBe(70);
|
||||
expect(withDefault.chf_amount_minor).toBe(2940);
|
||||
|
||||
const withOverride = buildExpenseInsert({ kind: 'mileage', quantity: 42, rateMinor: 100 }, 1, { kmRateMinor: 70 });
|
||||
expect(withOverride.rate_minor).toBe(100);
|
||||
expect(withOverride.chf_amount_minor).toBe(4200);
|
||||
});
|
||||
|
||||
it('per_diem uses days x per-diem rate', () => {
|
||||
const row = buildExpenseInsert({ kind: 'per_diem', quantity: 2 }, 1, { perDiemRateMinor: 8000 });
|
||||
expect(row.rate_minor).toBe(8000);
|
||||
expect(row.chf_amount_minor).toBe(16000);
|
||||
});
|
||||
|
||||
it('event_id null = booked to company; proof path carried', () => {
|
||||
const company = buildExpenseInsert({ kind: 'amount', chfAmountMinor: 100 }, 1, { receiptPath: '/p/x.pdf' });
|
||||
expect(company.event_id).toBeNull();
|
||||
expect(company.receipt_path).toBe('/p/x.pdf');
|
||||
const evt = buildExpenseInsert({ kind: 'amount', chfAmountMinor: 100, eventId: 9 }, 1);
|
||||
expect(evt.event_id).toBe(9);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* Tests for ledgerService (Accounting Layer A).
|
||||
*
|
||||
* Two layers:
|
||||
* 1. Pure helpers (rateKey, csvEscape, minorToDecimal).
|
||||
* 2. buildPostings + exportPostings — db chain + appSettings mocked so we can
|
||||
* feed canned invoices/inbound/expenses and assert the Buchungssätze +
|
||||
* the per-tool CSV shapes.
|
||||
*/
|
||||
|
||||
// ----- canned data per table ------------------------------------------
|
||||
let accountsRows = [];
|
||||
let vatRows = [];
|
||||
let invoiceRows = [];
|
||||
let inboundRows = [];
|
||||
let expenseRows = [];
|
||||
|
||||
function makeChain(rows) {
|
||||
const c = {
|
||||
_rows: rows,
|
||||
then(onR, onJ) { return Promise.resolve(this._rows).then(onR, onJ); },
|
||||
leftJoin() { return this; },
|
||||
where() { return this; },
|
||||
whereNot() { return this; },
|
||||
whereIn() { return this; },
|
||||
whereNotIn() { return this; },
|
||||
whereBetween() { return this; },
|
||||
whereRaw() { return this; },
|
||||
orderBy() { return this; },
|
||||
orderByRaw() { return this; },
|
||||
modify(cb) { if (typeof cb === 'function') cb(this); return this; },
|
||||
select() { return Promise.resolve(this._rows); },
|
||||
first() { return Promise.resolve(this._rows[0]); },
|
||||
};
|
||||
return c;
|
||||
}
|
||||
|
||||
const mockDbFn = jest.fn((table) => {
|
||||
switch (table) {
|
||||
case 'ledger_accounts': return makeChain(accountsRows);
|
||||
case 'vat_codes': return makeChain(vatRows);
|
||||
case 'invoices': return makeChain(invoiceRows);
|
||||
case 'inbound_documents': return makeChain(inboundRows);
|
||||
case 'expenses': return makeChain(expenseRows);
|
||||
default: return makeChain([]);
|
||||
}
|
||||
});
|
||||
mockDbFn.raw = (s) => s;
|
||||
mockDbFn.schema = {
|
||||
hasTable: jest.fn(async () => true),
|
||||
hasColumn: jest.fn(async () => true),
|
||||
};
|
||||
|
||||
jest.mock('../../src/database/db', () => ({ db: mockDbFn, withRetry: async (fn) => fn() }));
|
||||
|
||||
const SETTINGS = {
|
||||
ledger_account_debitoren: '1100',
|
||||
ledger_account_kreditoren: '2000',
|
||||
ledger_account_default_revenue: '3400',
|
||||
ledger_account_default_expense: '6700',
|
||||
ledger_account_mileage: '6200',
|
||||
ledger_account_per_diem: '6640',
|
||||
ledger_account_rebilled_revenue: '3940',
|
||||
ledger_vat_map: { domestic: 'VST81', reverse_charge_service: 'BZ', foreign_vat_non_reclaimable: 'VST00', import_goods: 'VST81' },
|
||||
ledger_output_vat_map: { '8.1': 'UN81', '2.6': 'UN26', '3.8': 'UN38', '0': 'UN00' },
|
||||
};
|
||||
jest.mock('../../src/utils/appSettings', () => ({
|
||||
getAppSetting: jest.fn(async (key, def) => (key in SETTINGS ? SETTINGS[key] : def)),
|
||||
}));
|
||||
|
||||
const ledgerService = require('../../src/services/ledgerService');
|
||||
const { rateKey, csvEscape, minorToDecimal } = ledgerService._internal;
|
||||
|
||||
beforeEach(() => {
|
||||
accountsRows = [
|
||||
{ id: 1, number: '1100', name: 'Debitoren', type: 'asset' },
|
||||
{ id: 2, number: '3400', name: 'Dienstleistungsertrag', type: 'revenue' },
|
||||
{ id: 3, number: '2000', name: 'Kreditoren', type: 'liability' },
|
||||
{ id: 4, number: '6570', name: 'Informatikaufwand', type: 'expense' },
|
||||
{ id: 5, number: '6200', name: 'Fahrzeugaufwand', type: 'expense' },
|
||||
{ id: 6, number: '6700', name: 'Sonstiger Betriebsaufwand', type: 'expense' },
|
||||
];
|
||||
vatRows = [{ id: 9, code: 'UN81', rate: 8.1, direction: 'output', account_id: null }];
|
||||
invoiceRows = [];
|
||||
inboundRows = [];
|
||||
expenseRows = [];
|
||||
});
|
||||
|
||||
// ----- pure helpers ----------------------------------------------------
|
||||
describe('rateKey', () => {
|
||||
it('normalises rate to the output-map key', () => {
|
||||
expect(rateKey(8.1)).toBe('8.1');
|
||||
expect(rateKey(8.10)).toBe('8.1');
|
||||
expect(rateKey('2.60')).toBe('2.6');
|
||||
expect(rateKey(0)).toBe('0');
|
||||
expect(rateKey(null)).toBe('0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('csvEscape / minorToDecimal', () => {
|
||||
it('quotes + doubles inner quotes', () => {
|
||||
expect(csvEscape('a,b')).toBe('"a,b"');
|
||||
expect(csvEscape('he said "hi"')).toBe('"he said ""hi"""');
|
||||
expect(csvEscape(null)).toBe('""');
|
||||
});
|
||||
it('renders minor units as 2dp', () => {
|
||||
expect(minorToDecimal(10810)).toBe('108.10');
|
||||
expect(minorToDecimal(0)).toBe('0.00');
|
||||
expect(minorToDecimal(null)).toBe('0.00');
|
||||
});
|
||||
});
|
||||
|
||||
// ----- buildPostings ---------------------------------------------------
|
||||
describe('buildPostings', () => {
|
||||
const period = { from: '2026-01-01', to: '2026-03-31', currency: 'CHF' };
|
||||
|
||||
it('books a revenue invoice as Dr Debitoren / Cr Ertrag with the output VAT code', async () => {
|
||||
invoiceRows = [{
|
||||
id: 1, invoice_number: 'R-2026-0001', issue_date: '2026-01-10', vat_rate: 8.1,
|
||||
net_amount_minor: 10000, vat_amount_minor: 810, total_amount_minor: 10810,
|
||||
customer_company_name: 'ACME GmbH', event_name: 'Wedding A',
|
||||
}];
|
||||
const { postings } = await ledgerService.buildPostings(period);
|
||||
expect(postings).toHaveLength(1);
|
||||
expect(postings[0]).toMatchObject({
|
||||
debitAccount: '1100', debitName: 'Debitoren',
|
||||
creditAccount: '3400', creditName: 'Dienstleistungsertrag',
|
||||
grossMinor: 10810, netMinor: 10000, vatMinor: 810,
|
||||
vatCode: 'UN81', source: 'revenue', eventName: 'Wedding A',
|
||||
});
|
||||
});
|
||||
|
||||
it('books an incoming invoice as Dr Aufwand(category) / Cr Kreditoren with the input VAT code', async () => {
|
||||
inboundRows = [{
|
||||
id: 5, invoice_number: 'L-77', invoice_date: '2026-01-12', created_at: '2026-01-13 09:00:00',
|
||||
supplier_name: 'Lab AG', tax_treatment: 'domestic',
|
||||
net_amount_minor: 2000, vat_amount_minor: 162, total_amount_minor: 2162,
|
||||
event_id: 7, cat_account_id: 4, event_name: 'Wedding A',
|
||||
}];
|
||||
const { postings } = await ledgerService.buildPostings(period);
|
||||
expect(postings).toHaveLength(1);
|
||||
expect(postings[0]).toMatchObject({
|
||||
debitAccount: '6570', creditAccount: '2000',
|
||||
grossMinor: 2162, netMinor: 2000, vatMinor: 162,
|
||||
vatCode: 'VST81', source: 'incoming', eventName: 'Wedding A',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the kind default account for a category-less mileage expense', async () => {
|
||||
expenseRows = [{
|
||||
id: 9, created_at: '2026-02-01 12:00:00', kind: 'mileage', supplier_name: null, description: 'Drive',
|
||||
tax_treatment: 'foreign_vat_non_reclaimable', event_id: null,
|
||||
original_amount_minor: null, chf_amount_minor: 5000,
|
||||
net_amount_minor: null, vat_amount_minor: null, gross_amount_minor: null, cat_account_id: null,
|
||||
}];
|
||||
const { postings } = await ledgerService.buildPostings(period);
|
||||
expect(postings).toHaveLength(1);
|
||||
expect(postings[0]).toMatchObject({
|
||||
debitAccount: '6200', creditAccount: '2000',
|
||||
grossMinor: 5000, vatMinor: 0,
|
||||
vatCode: 'VST00', source: 'expense', eventName: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('sorts the combined journal chronologically across all sources', async () => {
|
||||
invoiceRows = [{ id: 1, invoice_number: 'R1', issue_date: '2026-02-20', vat_rate: 8.1, net_amount_minor: 100, vat_amount_minor: 8, total_amount_minor: 108, customer_company_name: 'A' }];
|
||||
inboundRows = [{ id: 5, invoice_number: 'L1', invoice_date: '2026-01-05', created_at: '2026-01-05', supplier_name: 'Lab', tax_treatment: 'domestic', net_amount_minor: 50, vat_amount_minor: 4, total_amount_minor: 54, event_id: null, cat_account_id: null }];
|
||||
expenseRows = [{ id: 9, created_at: '2026-01-30', kind: 'amount', description: 'x', tax_treatment: 'domestic', event_id: null, chf_amount_minor: 200, net_amount_minor: null, vat_amount_minor: null, gross_amount_minor: null, cat_account_id: null }];
|
||||
const { postings } = await ledgerService.buildPostings(period);
|
||||
expect(postings.map((p) => p.source)).toEqual(['incoming', 'expense', 'revenue']);
|
||||
});
|
||||
|
||||
it('requires from/to/currency', async () => {
|
||||
await expect(ledgerService.buildPostings({})).rejects.toThrow(/from.+to/);
|
||||
await expect(ledgerService.buildPostings({ from: '2026-01-01', to: '2026-03-31' })).rejects.toThrow(/currency/);
|
||||
});
|
||||
});
|
||||
|
||||
// ----- exportPostings --------------------------------------------------
|
||||
describe('exportPostings', () => {
|
||||
const period = { from: '2026-01-01', to: '2026-03-31', currency: 'CHF' };
|
||||
beforeEach(() => {
|
||||
invoiceRows = [{ id: 1, invoice_number: 'R-2026-0001', issue_date: '2026-01-10', vat_rate: 8.1, net_amount_minor: 10000, vat_amount_minor: 810, total_amount_minor: 10810, customer_company_name: 'ACME' }];
|
||||
});
|
||||
|
||||
it('generic format carries all human-friendly columns', async () => {
|
||||
const { content, filename, count } = await ledgerService.exportPostings({ ...period, format: 'generic' });
|
||||
const [header, row] = content.trim().split('\r\n');
|
||||
expect(count).toBe(1);
|
||||
expect(header).toContain('DebitAccountName');
|
||||
expect(header).toContain('NetAmount');
|
||||
expect(header).toContain('VatCode');
|
||||
expect(row).toContain('1100');
|
||||
expect(row).toContain('108.10'); // gross 2dp
|
||||
expect(filename).toMatch(/_generic\.csv$/);
|
||||
});
|
||||
|
||||
it('banana format is a TAB-separated .txt with Banana column names', async () => {
|
||||
const { content, filename, contentType } = await ledgerService.exportPostings({ ...period, format: 'banana' });
|
||||
const header = content.split('\r\n')[0];
|
||||
// Banana's "Text file with column headers" import wants TAB-separated,
|
||||
// unquoted values in a .txt — not a comma CSV.
|
||||
expect(header).toBe('Date\tDoc\tDescription\tAccountDebit\tAccountCredit\tAmount\tVatCode');
|
||||
expect(content.split('\r\n')[1]).toContain('\t');
|
||||
expect(content).not.toContain('"');
|
||||
expect(filename).toMatch(/_banana\.txt$/);
|
||||
expect(contentType).toMatch(/text\/plain/);
|
||||
});
|
||||
|
||||
it('banana_ie format is Income & Expense columns, tab-separated .txt', async () => {
|
||||
const { content, filename, contentType } = await ledgerService.exportPostings({ ...period, format: 'banana_ie' });
|
||||
const [header, row] = content.trim().split('\r\n');
|
||||
expect(header).toBe('Date\tDoc\tDescription\tIncome\tExpenses\tCategory\tVatCode');
|
||||
// The mock period holds one revenue posting (gross 108.10) → Income filled,
|
||||
// Expenses empty, Category = the revenue account.
|
||||
const cells = row.split('\t');
|
||||
expect(cells[3]).toBe('108.10'); // Income
|
||||
expect(cells[4]).toBe(''); // Expenses
|
||||
expect(cells[5]).not.toBe(''); // Category (revenue account)
|
||||
expect(filename).toMatch(/_banana_ie\.txt$/);
|
||||
expect(contentType).toMatch(/text\/plain/);
|
||||
});
|
||||
|
||||
it('formats a Postgres Date object as yyyy-mm-dd (not "Thu Jan ...")', async () => {
|
||||
// PG returns DATE columns as JS Date objects (SQLite returns strings); the
|
||||
// export must still emit an ISO date, or Banana rejects it and the Date
|
||||
// column imports empty.
|
||||
invoiceRows = [{
|
||||
id: 1, invoice_number: 'R-2026-0001', issue_date: new Date(2026, 0, 10),
|
||||
vat_rate: 8.1, net_amount_minor: 10000, vat_amount_minor: 810, total_amount_minor: 10810,
|
||||
customer_company_name: 'ACME',
|
||||
}];
|
||||
const { content } = await ledgerService.exportPostings({ ...period, format: 'banana' });
|
||||
const dateCell = content.split('\r\n')[1].split('\t')[0];
|
||||
expect(dateCell).toBe('2026-01-10');
|
||||
});
|
||||
|
||||
it('bexio format includes tax_code + currency', async () => {
|
||||
const { content } = await ledgerService.exportPostings({ ...period, format: 'bexio' });
|
||||
const header = content.split('\r\n')[0];
|
||||
expect(header).toContain('tax_code');
|
||||
expect(header).toContain('currency');
|
||||
});
|
||||
|
||||
it('unknown format falls back to generic', async () => {
|
||||
const { filename } = await ledgerService.exportPostings({ ...period, format: 'nope' });
|
||||
expect(filename).toMatch(/_generic\.csv$/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
const rasterizeService = require('../../src/services/rasterizeService');
|
||||
|
||||
// The page-range guard runs BEFORE any fs/pdftoppm work, so these reject
|
||||
// without touching the binary or disk (PR #622 concern 6).
|
||||
describe('getRenderedPagePath page-range guard', () => {
|
||||
it.each([0, -1, 201, 1000, 1.5, NaN])('rejects out-of-range page %p', async (page) => {
|
||||
await expect(rasterizeService.getRenderedPagePath(1, '/tmp/does-not-exist.pdf', page))
|
||||
.rejects.toMatchObject({ statusCode: 400, code: 'PAGE_OUT_OF_RANGE' });
|
||||
});
|
||||
});
|
||||
@@ -22,19 +22,26 @@ function makeChain(initialRows) {
|
||||
},
|
||||
leftJoin: jest.fn(function () { return this; }),
|
||||
where: jest.fn(function () { return this; }),
|
||||
whereNot: jest.fn(function () { return this; }),
|
||||
whereIn: jest.fn(function () { return this; }),
|
||||
whereNotIn: jest.fn(function () { return this; }),
|
||||
whereBetween: jest.fn(function () { return this; }),
|
||||
whereRaw: jest.fn(function () { return this; }),
|
||||
orderBy: jest.fn(function () { return this; }),
|
||||
orderByRaw: jest.fn(function () { return this; }),
|
||||
select: jest.fn(function () { return Promise.resolve(this._rows); }),
|
||||
};
|
||||
}
|
||||
|
||||
const mockDbFn = jest.fn((tableName) => {
|
||||
callCount += 1;
|
||||
// Route by table name when supplied — the Skonto aggregate (added
|
||||
// by migration 126) queries `invoice_payment_log`; everything else
|
||||
// (main listing, replacements lookup) hits `invoices`.
|
||||
// by migration 126) queries `invoice_payment_log`; the #4 cost side
|
||||
// queries `inbound_documents` + `expenses`; everything else (main
|
||||
// listing, replacements lookup) hits `invoices`.
|
||||
if (tableName === 'invoice_payment_log') return makeChain([]);
|
||||
if (tableName === 'inbound_documents') return makeChain([]);
|
||||
if (tableName === 'expenses') return makeChain([]);
|
||||
callCount += 1;
|
||||
if (callCount === 1) return makeChain(invoiceRowsForRun);
|
||||
return makeChain(replacementsRowsForRun);
|
||||
});
|
||||
@@ -42,6 +49,10 @@ const mockDbFn = jest.fn((tableName) => {
|
||||
// COALESCE (migration 123). The chain's select() ignores its
|
||||
// arguments so the raw() return value just needs to exist.
|
||||
mockDbFn.raw = jest.fn((sql) => sql);
|
||||
// #4 loadCosts schema-guards each cost table; default the PDF/CSV
|
||||
// fixtures to "no accounting tables" so these renderers exercise the
|
||||
// revenue path unchanged.
|
||||
mockDbFn.schema = { hasTable: jest.fn(async () => false) };
|
||||
|
||||
jest.mock('../../src/database/db', () => ({
|
||||
db: mockDbFn,
|
||||
@@ -182,8 +193,11 @@ describe('renderTaxReportCsv', () => {
|
||||
expect(contentType).toMatch(/text\/csv/);
|
||||
expect(filename).toBe('tax_report_2026-01-01_to_2026-03-31_CHF.csv');
|
||||
const lines = content.split('\r\n');
|
||||
expect(lines[0]).toContain('Rechnung'); // de header for tax_col_invoice
|
||||
expect(lines[0]).toContain('Kunde');
|
||||
// Unified ledger CSV: Typ / Referenz / Kunde-Lieferant columns replace the
|
||||
// old Rechnung/Kunde split.
|
||||
expect(lines[0]).toContain('Typ'); // de header for tax_col_type
|
||||
expect(lines[0]).toContain('Referenz'); // de header for tax_col_reference
|
||||
expect(lines[0]).toContain('Kunde'); // "Kunde / Lieferant"
|
||||
expect(lines[0]).toContain('Netto');
|
||||
});
|
||||
|
||||
@@ -223,19 +237,17 @@ describe('renderTaxReportCsv', () => {
|
||||
expect(content).toMatch(/"161\.55"/);
|
||||
});
|
||||
|
||||
it('marks cancelled rows with a 1 in the cancelled column', async () => {
|
||||
it('flags a cancelled row with a "(Cancelled)" suffix on its Reference cell', async () => {
|
||||
invoiceRowsForRun = [
|
||||
SAMPLE_ROW({ status: 'cancelled' }),
|
||||
];
|
||||
const { content } = await taxReportService.renderTaxReportCsv({
|
||||
from: '2026-01-01', to: '2026-03-31', currency: 'CHF', locale: 'en',
|
||||
});
|
||||
// Migration 126 added a trailing Skonto column. The cancelled
|
||||
// marker is now second-to-last; the Skonto cell is empty for
|
||||
// non-Skonto rows. Asserting on a regex keeps the test stable
|
||||
// against future trailing-column additions.
|
||||
// Unified ledger CSV has no separate cancelled column — a cancelled row is
|
||||
// flagged by appending the localised "(Cancelled)" tag to its Reference.
|
||||
const dataRow = content.split('\r\n')[1];
|
||||
expect(/"1","[^"]*"$/.test(dataRow)).toBe(true);
|
||||
expect(dataRow).toContain('R-2026-0001 (Cancelled)');
|
||||
});
|
||||
|
||||
it('uses CRLF line endings (RFC 4180) and BOM-free body', async () => {
|
||||
|
||||
@@ -22,6 +22,9 @@
|
||||
|
||||
let invoiceRowsForRun = [];
|
||||
let replacementsRowsForRun = [];
|
||||
let inboundRowsForRun = [];
|
||||
let expenseRowsForRun = [];
|
||||
let costTablesPresent = false;
|
||||
let callCount = 0;
|
||||
|
||||
function makeChain(initialRows) {
|
||||
@@ -32,24 +35,35 @@ function makeChain(initialRows) {
|
||||
},
|
||||
leftJoin: jest.fn(function () { return this; }),
|
||||
where: jest.fn(function () { return this; }),
|
||||
whereNot: jest.fn(function () { return this; }),
|
||||
whereIn: jest.fn(function () { return this; }),
|
||||
whereNotIn: jest.fn(function () { return this; }),
|
||||
whereBetween: jest.fn(function () { return this; }),
|
||||
whereRaw: jest.fn(function () { return this; }),
|
||||
orderBy: jest.fn(function () { return this; }),
|
||||
orderByRaw: jest.fn(function () { return this; }),
|
||||
select: jest.fn(function () { return Promise.resolve(this._rows); }),
|
||||
};
|
||||
return c;
|
||||
}
|
||||
|
||||
const mockDbFn = jest.fn((tableName) => {
|
||||
callCount += 1;
|
||||
// Migration 126 added a Skonto aggregate that hits
|
||||
// `invoice_payment_log` — route those explicitly to an empty list so
|
||||
// the test surface stays focused on the invoices/replacements flow.
|
||||
if (tableName === 'invoice_payment_log') return makeChain([]);
|
||||
// First call: main listing. Second call: replacements lookup.
|
||||
// Cost side (#4): incoming invoices + internal expenses.
|
||||
if (tableName === 'inbound_documents') return makeChain(inboundRowsForRun);
|
||||
if (tableName === 'expenses') return makeChain(expenseRowsForRun);
|
||||
// `invoices` is queried for the main listing (call 1) and, when there
|
||||
// are cancelled rows, the replacements lookup (call 2).
|
||||
callCount += 1;
|
||||
if (callCount === 1) return makeChain(invoiceRowsForRun);
|
||||
return makeChain(replacementsRowsForRun);
|
||||
});
|
||||
// loadCosts (#4) schema-guards each cost table. Default off so the
|
||||
// revenue-only tests are unaffected; cost-side tests flip it on.
|
||||
mockDbFn.schema = { hasTable: jest.fn(async () => costTablesPresent) };
|
||||
// `.raw()` is used in the .select() column list for the event_name
|
||||
// COALESCE (migration 123). The chain's select() ignores its
|
||||
// arguments and returns the mocked rows, so the raw() return value
|
||||
@@ -67,6 +81,9 @@ const { grossUpLateFee, computeReportedAmounts, buildCustomerLabel } = taxReport
|
||||
beforeEach(() => {
|
||||
invoiceRowsForRun = [];
|
||||
replacementsRowsForRun = [];
|
||||
inboundRowsForRun = [];
|
||||
expenseRowsForRun = [];
|
||||
costTablesPresent = false;
|
||||
callCount = 0;
|
||||
mockDbFn.mockClear();
|
||||
});
|
||||
@@ -335,4 +352,180 @@ describe('getTaxReport', () => {
|
||||
expect(out.totalsByVatRate).toEqual([]);
|
||||
expect(out.cancelledCount).toBe(0);
|
||||
});
|
||||
|
||||
it('returns an empty cost side + zeroed summary when accounting tables are absent', async () => {
|
||||
invoiceRowsForRun = [
|
||||
{
|
||||
id: 1, invoice_number: 'R-2026-0001', issue_date: '2026-01-15',
|
||||
currency: 'CHF', status: 'paid', 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', event_name: 'X',
|
||||
},
|
||||
];
|
||||
costTablesPresent = false; // no accounting migrations on this DB
|
||||
const out = await taxReportService.getTaxReport({
|
||||
from: '2026-01-01', to: '2026-03-31', currency: 'CHF',
|
||||
});
|
||||
expect(out.costs).toEqual({ rows: [], totalNet: 0, totalVat: 0, totalGross: 0, reclaimableVat: 0 });
|
||||
expect(out.summary).toMatchObject({
|
||||
incomeNetMinor: 10000, incomeVatMinor: 770, incomeGrossMinor: 10770,
|
||||
costNetMinor: 0, costVatMinor: 0, costGrossMinor: 0,
|
||||
resultNetMinor: 10000, resultGrossMinor: 10770,
|
||||
// VAT registration unconfigured in the test DB → refuse to compute payable.
|
||||
vatRegistrationConfigured: false, vatPayableMinor: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('aggregates incoming invoices + expenses into the cost side and nets the result', async () => {
|
||||
invoiceRowsForRun = [
|
||||
{
|
||||
id: 1, invoice_number: 'R-2026-0001', issue_date: '2026-01-15',
|
||||
currency: 'CHF', status: 'paid', vat_rate: 7.7,
|
||||
net_amount_minor: 100000, vat_amount_minor: 7700, total_amount_minor: 107700,
|
||||
late_fee_amount_minor: 0, replaces_invoice_id: null,
|
||||
customer_company_name: 'ACME', event_name: 'Wedding A',
|
||||
},
|
||||
];
|
||||
costTablesPresent = true;
|
||||
// Incoming supplier invoice: net 20000 + vat 1540 = 21540.
|
||||
inboundRowsForRun = [
|
||||
{
|
||||
id: 5, invoice_date: '2026-01-20', created_at: '2026-01-21 09:00:00',
|
||||
supplier_name: 'Lab AG', description: 'Prints', disposition: 'eigener_aufwand',
|
||||
tax_treatment: 'domestic', status: 'categorized', event_id: 7,
|
||||
net_amount_minor: 20000, vat_amount_minor: 1540, total_amount_minor: 21540,
|
||||
event_name: 'Wedding A',
|
||||
},
|
||||
];
|
||||
// Internal expense (mileage, no VAT split): only a CHF base amount.
|
||||
expenseRowsForRun = [
|
||||
{
|
||||
id: 9, created_at: '2026-02-01 12:00:00',
|
||||
supplier_name: null, description: 'Travel', disposition: 'eigener_aufwand',
|
||||
tax_treatment: 'domestic', status: 'open', event_id: null,
|
||||
original_currency: null, original_amount_minor: null, chf_amount_minor: 5000,
|
||||
net_amount_minor: null, vat_amount_minor: null, gross_amount_minor: null,
|
||||
event_name: null,
|
||||
},
|
||||
];
|
||||
const out = await taxReportService.getTaxReport({
|
||||
from: '2026-01-01', to: '2026-03-31', currency: 'CHF',
|
||||
});
|
||||
|
||||
expect(out.costs.rows).toHaveLength(2);
|
||||
// Incoming invoice mapped + booked to the event.
|
||||
const incoming = out.costs.rows.find((r) => r.source === 'incoming');
|
||||
expect(incoming).toMatchObject({
|
||||
supplierLabel: 'Lab AG', eventName: 'Wedding A',
|
||||
netMinor: 20000, vatMinor: 1540, totalMinor: 21540,
|
||||
});
|
||||
// Expense: no net/vat/gross → falls back to the CHF base as total,
|
||||
// and (company-booked) event name blank.
|
||||
const expense = out.costs.rows.find((r) => r.source === 'expense');
|
||||
expect(expense).toMatchObject({
|
||||
eventName: '', netMinor: 5000, vatMinor: 0, totalMinor: 5000,
|
||||
});
|
||||
|
||||
expect(out.costs.totalNet).toBe(25000);
|
||||
expect(out.costs.totalVat).toBe(1540);
|
||||
expect(out.costs.totalGross).toBe(26540);
|
||||
|
||||
// Summary nets income against costs.
|
||||
expect(out.summary).toMatchObject({
|
||||
incomeNetMinor: 100000, incomeVatMinor: 7700, incomeGrossMinor: 107700,
|
||||
costNetMinor: 25000, costVatMinor: 1540, costGrossMinor: 26540,
|
||||
resultNetMinor: 75000, resultGrossMinor: 81160,
|
||||
vatRegistrationConfigured: false, vatPayableMinor: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('excludes declined/duplicate incoming invoices via the query filter (sanity on chain wiring)', async () => {
|
||||
costTablesPresent = true;
|
||||
inboundRowsForRun = []; // the whereNotIn filter is applied in SQL; here we assert empty → zeroed
|
||||
expenseRowsForRun = [];
|
||||
const out = await taxReportService.getTaxReport({
|
||||
from: '2026-01-01', to: '2026-03-31', currency: 'CHF',
|
||||
});
|
||||
expect(out.costs.totalGross).toBe(0);
|
||||
expect(out.summary.costGrossMinor).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ----- export scope (income/cost split) --------------------------------
|
||||
describe('export scope helpers', () => {
|
||||
const { scopeLedger, normalizeScope } = taxReportService._internal;
|
||||
const ledger = [
|
||||
{ type: 'outgoing', reference: 'R-1' },
|
||||
{ type: 'incoming', reference: 'IN-1' },
|
||||
{ type: 'expense', reference: 'EXP-1' },
|
||||
];
|
||||
|
||||
it('normalizeScope defaults unknown/empty to "all"', () => {
|
||||
expect(normalizeScope('all')).toBe('all');
|
||||
expect(normalizeScope('income')).toBe('income');
|
||||
expect(normalizeScope('cost')).toBe('cost');
|
||||
expect(normalizeScope('bogus')).toBe('all');
|
||||
expect(normalizeScope(undefined)).toBe('all');
|
||||
});
|
||||
|
||||
it('scopeLedger income keeps only outgoing rows', () => {
|
||||
expect(scopeLedger(ledger, 'income').map((r) => r.type)).toEqual(['outgoing']);
|
||||
});
|
||||
|
||||
it('scopeLedger cost keeps incoming + expense rows', () => {
|
||||
expect(scopeLedger(ledger, 'cost').map((r) => r.type)).toEqual(['incoming', 'expense']);
|
||||
});
|
||||
|
||||
it('scopeLedger all keeps everything; null-safe', () => {
|
||||
expect(scopeLedger(ledger, 'all')).toHaveLength(3);
|
||||
expect(scopeLedger(null, 'income')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderTaxReportCsv scope', () => {
|
||||
beforeEach(() => {
|
||||
costTablesPresent = true;
|
||||
invoiceRowsForRun = [{
|
||||
id: 1, invoice_number: 'R-2026-0001', issue_date: '2026-01-15',
|
||||
currency: 'CHF', status: 'paid', vat_rate: 8.1,
|
||||
net_amount_minor: 100000, vat_amount_minor: 8100, total_amount_minor: 108100,
|
||||
late_fee_amount_minor: 0, replaces_invoice_id: null,
|
||||
customer_company_name: 'ACME', event_name: 'Wedding A',
|
||||
}];
|
||||
inboundRowsForRun = [{
|
||||
id: 5, invoice_date: '2026-01-20', created_at: '2026-01-21 09:00:00',
|
||||
supplier_name: 'Lab AG', description: 'Prints', disposition: 'eigener_aufwand',
|
||||
tax_treatment: 'domestic', status: 'categorized', event_id: 7,
|
||||
net_amount_minor: 20000, vat_amount_minor: 1620, total_amount_minor: 21620,
|
||||
event_name: 'Wedding A',
|
||||
}];
|
||||
});
|
||||
|
||||
it('income scope keeps the invoice row, drops the supplier cost row', async () => {
|
||||
const { content, filename } = await taxReportService.renderTaxReportCsv({
|
||||
from: '2026-01-01', to: '2026-03-31', currency: 'CHF', scope: 'income',
|
||||
});
|
||||
expect(content).toContain('R-2026-0001');
|
||||
expect(content).not.toContain('Lab AG');
|
||||
expect(filename).toContain('income_');
|
||||
});
|
||||
|
||||
it('cost scope keeps the supplier row, drops the invoice row', async () => {
|
||||
const { content, filename } = await taxReportService.renderTaxReportCsv({
|
||||
from: '2026-01-01', to: '2026-03-31', currency: 'CHF', scope: 'cost',
|
||||
});
|
||||
expect(content).toContain('Lab AG');
|
||||
expect(content).not.toContain('R-2026-0001');
|
||||
expect(filename).toContain('cost_');
|
||||
});
|
||||
|
||||
it('all scope (default) keeps both', async () => {
|
||||
const { content, filename } = await taxReportService.renderTaxReportCsv({
|
||||
from: '2026-01-01', to: '2026-03-31', currency: 'CHF',
|
||||
});
|
||||
expect(content).toContain('R-2026-0001');
|
||||
expect(content).toContain('Lab AG');
|
||||
expect(filename).not.toMatch(/income_|cost_/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
const { isUniqueViolation } = require('../../src/utils/dbErrors');
|
||||
|
||||
describe('isUniqueViolation (PR #622 blocker 2 race-safety detector)', () => {
|
||||
it('true for Postgres SQLSTATE 23505', () => {
|
||||
expect(isUniqueViolation({ code: '23505' })).toBe(true);
|
||||
});
|
||||
it('true for node-sqlite3 SQLITE_CONSTRAINT code', () => {
|
||||
expect(isUniqueViolation({ code: 'SQLITE_CONSTRAINT' })).toBe(true);
|
||||
});
|
||||
it('true for a better-sqlite3 "UNIQUE constraint failed" message', () => {
|
||||
expect(isUniqueViolation({ message: 'UNIQUE constraint failed: received_emails.message_id' })).toBe(true);
|
||||
});
|
||||
it('false for unrelated errors and nullish', () => {
|
||||
expect(isUniqueViolation({ code: '23503' })).toBe(false); // FK violation
|
||||
expect(isUniqueViolation({ message: 'connection refused' })).toBe(false);
|
||||
expect(isUniqueViolation(null)).toBe(false);
|
||||
expect(isUniqueViolation(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
const { neutralizeSpreadsheetFormula } = require('../../src/utils/spreadsheetSafe');
|
||||
const { _internal } = require('../../src/services/ledgerService');
|
||||
|
||||
describe('neutralizeSpreadsheetFormula — CSV/Banana formula-injection defence (PR #622 blocker 1)', () => {
|
||||
it.each([
|
||||
['=', '=cmd|"/C calc"!A1'],
|
||||
['+', '+1+1'],
|
||||
['-', '-2+3'],
|
||||
['@', '@SUM(1+1)'],
|
||||
['tab', '\tSUM(A1)'],
|
||||
['carriage-return', '\rSUM(A1)'],
|
||||
])('prefixes a single quote when the cell starts with %s', (_label, payload) => {
|
||||
const out = neutralizeSpreadsheetFormula(payload);
|
||||
expect(out).toBe(`'${payload}`);
|
||||
expect(out[0]).toBe("'");
|
||||
});
|
||||
|
||||
it('leaves safe values untouched', () => {
|
||||
expect(neutralizeSpreadsheetFormula('LBM-R-2026-0001')).toBe('LBM-R-2026-0001');
|
||||
expect(neutralizeSpreadsheetFormula('Acme GmbH')).toBe('Acme GmbH');
|
||||
expect(neutralizeSpreadsheetFormula('29.40')).toBe('29.40');
|
||||
// A minus only mid-string is fine — only a LEADING risky char matters.
|
||||
expect(neutralizeSpreadsheetFormula('Q-2026-0001')).toBe('Q-2026-0001');
|
||||
});
|
||||
|
||||
it('coerces null/undefined to empty string', () => {
|
||||
expect(neutralizeSpreadsheetFormula(null)).toBe('');
|
||||
expect(neutralizeSpreadsheetFormula(undefined)).toBe('');
|
||||
});
|
||||
|
||||
it('ledgerService.csvEscape applies the prefix AND the RFC-4180 quote wrap', () => {
|
||||
// formula cell → prefixed then quote-wrapped
|
||||
expect(_internal.csvEscape('=1+1')).toBe('"\'=1+1"');
|
||||
// embedded quotes still doubled; safe value not prefixed
|
||||
expect(_internal.csvEscape('a"b')).toBe('"a""b"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Migration 122: seed the Accounting feature flags.
|
||||
*
|
||||
* - `accounting` : top-level MASTER for the Accounting section
|
||||
* (separate from CRM). Default OFF — EXCEPT on
|
||||
* installs that already had the Tax report
|
||||
* (`taxReport`) enabled: the Tax export relocated
|
||||
* permanently into Accounting, so we auto-enable the
|
||||
* master there to preserve the existing menu (per the
|
||||
* "migrations preserve visual state" rule). Otherwise
|
||||
* admins opt in under Settings → Features.
|
||||
* - `incomingInvoices` : Accounting sub-feature (supplier-invoice capture +
|
||||
* expenses + re-bill). Always default OFF (new).
|
||||
*
|
||||
* Idempotent: each row is inserted only when missing. 107_crm_consolidated
|
||||
* already shipped its flag set and won't re-run.
|
||||
*/
|
||||
exports.up = async function (knex) {
|
||||
if (!(await knex.schema.hasTable('feature_flags'))) return;
|
||||
|
||||
const existingAccounting = await knex('feature_flags').where({ key: 'accounting' }).first();
|
||||
if (!existingAccounting) {
|
||||
// Preserve visuals: if Tax was already on, light up the Accounting
|
||||
// master so the relocated Tax export doesn't vanish on upgrade.
|
||||
const taxRow = await knex('feature_flags').where({ key: 'taxReport' }).first();
|
||||
const taxOn = !!(taxRow && (taxRow.value === true || taxRow.value === 1 || taxRow.value === '1'));
|
||||
await knex('feature_flags').insert({ key: 'accounting', value: taxOn });
|
||||
}
|
||||
|
||||
const existingIncoming = await knex('feature_flags').where({ key: 'incomingInvoices' }).first();
|
||||
if (!existingIncoming) {
|
||||
await knex('feature_flags').insert({ key: 'incomingInvoices', value: false });
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function (knex) {
|
||||
if (!(await knex.schema.hasTable('feature_flags'))) return;
|
||||
await knex('feature_flags').whereIn('key', ['accounting', 'incomingInvoices']).del();
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Migration 123: seed `accounting.view` / `accounting.manage` permissions
|
||||
* and grant them to the super_admin + admin roles.
|
||||
*
|
||||
* Idempotent: inserts only missing permission names and only missing
|
||||
* (role_id, permission_id) grants (mirrors 107_crm_consolidated Section 13).
|
||||
*/
|
||||
const NEW_PERMISSIONS = [
|
||||
{
|
||||
name: 'accounting.view',
|
||||
display_name: 'View Accounting',
|
||||
category: 'accounting',
|
||||
description: 'View inbound documents, expenses and accounting reports',
|
||||
},
|
||||
{
|
||||
name: 'accounting.manage',
|
||||
display_name: 'Manage Accounting',
|
||||
category: 'accounting',
|
||||
description: 'Capture inbound documents, categorize expenses and re-bill to clients',
|
||||
},
|
||||
];
|
||||
|
||||
exports.up = async function (knex) {
|
||||
if (!(await knex.schema.hasTable('permissions'))) return;
|
||||
|
||||
const names = NEW_PERMISSIONS.map((p) => p.name);
|
||||
const existing = await knex('permissions').whereIn('name', names).select('name');
|
||||
const existingSet = new Set(existing.map((r) => r.name));
|
||||
const toInsert = NEW_PERMISSIONS.filter((p) => !existingSet.has(p.name));
|
||||
if (toInsert.length > 0) await knex('permissions').insert(toInsert);
|
||||
|
||||
if (!(await knex.schema.hasTable('roles')) || !(await knex.schema.hasTable('role_permissions'))) {
|
||||
return;
|
||||
}
|
||||
const roles = await knex('roles').whereIn('name', ['super_admin', 'admin']).select('id');
|
||||
const perms = await knex('permissions').whereIn('name', names).select('id');
|
||||
if (!roles.length || !perms.length) return;
|
||||
|
||||
const existingGrants = await knex('role_permissions')
|
||||
.whereIn('role_id', roles.map((r) => r.id))
|
||||
.whereIn('permission_id', perms.map((p) => p.id))
|
||||
.select('role_id', 'permission_id');
|
||||
const grantSet = new Set(existingGrants.map((g) => `${g.role_id}:${g.permission_id}`));
|
||||
|
||||
const toGrant = [];
|
||||
for (const r of roles) {
|
||||
for (const p of perms) {
|
||||
if (!grantSet.has(`${r.id}:${p.id}`)) {
|
||||
toGrant.push({ role_id: r.id, permission_id: p.id });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (toGrant.length > 0) await knex('role_permissions').insert(toGrant);
|
||||
};
|
||||
|
||||
exports.down = async function (knex) {
|
||||
if (!(await knex.schema.hasTable('permissions'))) return;
|
||||
const names = NEW_PERMISSIONS.map((p) => p.name);
|
||||
const perms = await knex('permissions').whereIn('name', names).select('id');
|
||||
if (perms.length && (await knex.schema.hasTable('role_permissions'))) {
|
||||
await knex('role_permissions').whereIn('permission_id', perms.map((p) => p.id)).del();
|
||||
}
|
||||
await knex('permissions').whereIn('name', names).del();
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Migration 124: Accounting foundation tables.
|
||||
*
|
||||
* - expense_categories : seeded, admin-editable colored labels (feed the
|
||||
* future Erfolgsrechnung).
|
||||
* - inbound_documents : received supplier invoices / receipts (system of
|
||||
* record). Holds best-effort parsed fields plus the
|
||||
* QR-encoded amount SEPARATELY (untrusted, tamper
|
||||
* cross-check — the authoritative total is the
|
||||
* text/line-item value).
|
||||
* - expenses : the booking created when a document gets a
|
||||
* disposition (or a manual expense with no document).
|
||||
*
|
||||
* All money is stored in integer minor units (*_amount_minor). All creates
|
||||
* are hasTable-guarded so partial states + re-runs are safe.
|
||||
*/
|
||||
const SEED_CATEGORIES = [
|
||||
{ name: 'Infrastruktur & Miete', color: 'slate' },
|
||||
{ name: 'Equipment & Hardware', color: 'indigo' },
|
||||
{ name: 'Software & Lizenzen', color: 'violet' },
|
||||
{ name: 'Material & Verbrauch', color: 'amber' },
|
||||
{ name: 'Reise & Spesen', color: 'teal' },
|
||||
{ name: 'Werbung & Marketing', color: 'rose' },
|
||||
{ name: 'Dienstleistungen/Fremdleistungen', color: 'blue' },
|
||||
{ name: 'Versicherungen & Gebühren', color: 'gray' },
|
||||
{ name: 'Weiterbildung', color: 'green' },
|
||||
{ name: 'Sonstiges', color: 'zinc' },
|
||||
];
|
||||
|
||||
exports.up = async function (knex) {
|
||||
if (!(await knex.schema.hasTable('expense_categories'))) {
|
||||
await knex.schema.createTable('expense_categories', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('name', 128).notNullable();
|
||||
table.string('color', 24);
|
||||
table.boolean('is_seed').notNullable().defaultTo(false);
|
||||
table.integer('display_order').notNullable().defaultTo(0);
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
table.timestamp('updated_at').defaultTo(knex.fn.now());
|
||||
});
|
||||
const rows = SEED_CATEGORIES.map((c, i) => ({
|
||||
name: c.name,
|
||||
color: c.color,
|
||||
is_seed: true,
|
||||
display_order: (i + 1) * 10,
|
||||
}));
|
||||
await knex('expense_categories').insert(rows);
|
||||
}
|
||||
|
||||
if (!(await knex.schema.hasTable('inbound_documents'))) {
|
||||
await knex.schema.createTable('inbound_documents', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('source', 16).notNullable().defaultTo('upload'); // upload|camera|email|manual
|
||||
table.string('original_filename', 512);
|
||||
table.string('file_path', 512);
|
||||
table.string('mime_type', 128);
|
||||
table.string('file_sha256', 64);
|
||||
table.string('status', 24).notNullable().defaultTo('unsorted'); // unsorted|categorized|declined|duplicate
|
||||
table.string('parse_status', 16).notNullable().defaultTo('pending'); // pending|parsed|failed|manual
|
||||
table.text('parse_error');
|
||||
table.string('parse_method', 24); // qr|pdf_text|ocr|none
|
||||
table.integer('page_count'); // PDF page count (for "jump to last page / QR")
|
||||
// Best-effort parsed fields (assist only — always editable/confirmable):
|
||||
table.string('supplier_name', 255);
|
||||
table.string('invoice_number', 128);
|
||||
table.date('invoice_date');
|
||||
table.date('due_date');
|
||||
table.string('currency', 3);
|
||||
table.integer('net_amount_minor');
|
||||
table.integer('vat_amount_minor');
|
||||
table.integer('total_amount_minor');
|
||||
// QR-encoded amount kept SEPARATE + untrusted (tamper cross-check):
|
||||
table.integer('qr_amount_minor');
|
||||
table.string('iban', 34);
|
||||
table.string('payment_reference', 140);
|
||||
table.text('raw_parsed'); // JSON blob of the raw extraction result
|
||||
table.integer('duplicate_of_id').unsigned()
|
||||
.references('id').inTable('inbound_documents').onDelete('SET NULL');
|
||||
table.integer('created_by_admin_id').unsigned();
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
table.timestamp('updated_at').defaultTo(knex.fn.now());
|
||||
table.index(['status']);
|
||||
table.index(['file_sha256']);
|
||||
});
|
||||
}
|
||||
|
||||
if (!(await knex.schema.hasTable('expenses'))) {
|
||||
await knex.schema.createTable('expenses', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.integer('inbound_document_id').unsigned()
|
||||
.references('id').inTable('inbound_documents').onDelete('SET NULL');
|
||||
// rebill|durchlaufend|eigener_aufwand|duplikat|abgelehnt
|
||||
table.string('disposition', 24).notNullable();
|
||||
// domestic|reverse_charge_service|foreign_vat_non_reclaimable|import_goods
|
||||
table.string('tax_treatment', 32).notNullable().defaultTo('domestic');
|
||||
// Loose links (no hard FK — kept resilient across SQLite/PG, mirrors the
|
||||
// invoice event snapshot approach); indexed for lookups:
|
||||
table.integer('event_id').unsigned();
|
||||
table.integer('customer_account_id').unsigned();
|
||||
table.string('supplier_name', 255);
|
||||
table.text('description');
|
||||
// FX: capture original + converted base (CHF) amount.
|
||||
table.string('original_currency', 3);
|
||||
table.integer('original_amount_minor');
|
||||
table.integer('chf_amount_minor');
|
||||
table.boolean('fx_locked').notNullable().defaultTo(false);
|
||||
table.string('fx_lock_reason', 32); // bank_reconciled|auto_30d|billed
|
||||
table.integer('net_amount_minor');
|
||||
table.integer('vat_amount_minor');
|
||||
table.integer('gross_amount_minor');
|
||||
// Re-bill markup (Spesen-Zuschlag): expense override else contract clause.
|
||||
table.string('markup_type', 8).notNullable().defaultTo('none'); // none|percent|flat
|
||||
table.decimal('markup_percent', 5, 2);
|
||||
table.integer('markup_flat_minor');
|
||||
table.integer('category_id').unsigned()
|
||||
.references('id').inTable('expense_categories').onDelete('SET NULL');
|
||||
table.text('tags'); // JSON array
|
||||
table.integer('billed_invoice_id').unsigned();
|
||||
table.integer('billed_invoice_line_item_id').unsigned();
|
||||
table.boolean('unbilled_parked').notNullable().defaultTo(false);
|
||||
table.timestamp('billed_at');
|
||||
// Supplier payment (decoupled from categorisation):
|
||||
table.boolean('supplier_paid').notNullable().defaultTo(false);
|
||||
table.timestamp('supplier_paid_at');
|
||||
// bank_transfer|cash|twint|paypal|card|other
|
||||
table.string('payment_method', 16);
|
||||
table.string('payment_reference', 140);
|
||||
table.string('receipt_path', 512);
|
||||
table.text('decline_reason');
|
||||
table.string('status', 16).notNullable().defaultTo('open'); // open|parked|billed|declined
|
||||
table.integer('created_by_admin_id').unsigned();
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
table.timestamp('updated_at').defaultTo(knex.fn.now());
|
||||
table.index(['disposition']);
|
||||
table.index(['status']);
|
||||
table.index(['event_id']);
|
||||
table.index(['customer_account_id']);
|
||||
table.index(['billed_invoice_id']);
|
||||
table.index(['supplier_paid']);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function (knex) {
|
||||
await knex.schema.dropTableIfExists('expenses');
|
||||
await knex.schema.dropTableIfExists('inbound_documents');
|
||||
await knex.schema.dropTableIfExists('expense_categories');
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Migration 125: add the Spesen-Zuschlag (expense surcharge) clause to
|
||||
* contracts. Drives the DEFAULT markup applied when an expense is re-billed
|
||||
* to a client on that contract's event (a per-expense override still wins).
|
||||
*
|
||||
* - expense_markup_type : 'none' | 'percent' | 'flat' (default 'none' = 0%)
|
||||
* - expense_markup_percent: decimal(5,2) (used when type='percent')
|
||||
* - expense_markup_flat_minor: integer minor units (used when type='flat')
|
||||
*
|
||||
* Idempotent: each column is hasColumn-guarded so re-runs / partial states
|
||||
* are safe. Default 'none' preserves existing behaviour (at-cost re-bill).
|
||||
*/
|
||||
exports.up = async function (knex) {
|
||||
if (!(await knex.schema.hasTable('contracts'))) return;
|
||||
|
||||
if (!(await knex.schema.hasColumn('contracts', 'expense_markup_type'))) {
|
||||
await knex.schema.alterTable('contracts', (table) => {
|
||||
table.string('expense_markup_type', 8).notNullable().defaultTo('none');
|
||||
});
|
||||
}
|
||||
if (!(await knex.schema.hasColumn('contracts', 'expense_markup_percent'))) {
|
||||
await knex.schema.alterTable('contracts', (table) => {
|
||||
table.decimal('expense_markup_percent', 5, 2);
|
||||
});
|
||||
}
|
||||
if (!(await knex.schema.hasColumn('contracts', 'expense_markup_flat_minor'))) {
|
||||
await knex.schema.alterTable('contracts', (table) => {
|
||||
table.integer('expense_markup_flat_minor');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function (knex) {
|
||||
if (!(await knex.schema.hasTable('contracts'))) return;
|
||||
for (const col of ['expense_markup_type', 'expense_markup_percent', 'expense_markup_flat_minor']) {
|
||||
if (await knex.schema.hasColumn('contracts', col)) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await knex.schema.alterTable('contracts', (table) => table.dropColumn(col));
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Migration 126: split Incoming invoices (external) from Expenses (internal).
|
||||
*
|
||||
* Incoming invoices now own their payable + disposition + re-bill on the
|
||||
* `inbound_documents` row itself (no derived `expenses` row), so a supplier
|
||||
* invoice lives ONLY in the inbox/incoming-invoices surface. The `expenses`
|
||||
* table becomes internal-only (mileage / per-diem / cash with proof).
|
||||
*
|
||||
* Both can be booked to an event (event_id) or to the company (event_id NULL).
|
||||
*
|
||||
* Additive + hasColumn-guarded so it runs forward cleanly on dev (122-125 are
|
||||
* already deployed there — no in-place edits).
|
||||
*/
|
||||
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')) {
|
||||
// Disposition + classification (now stored on the document itself).
|
||||
await addColumn(knex, 'inbound_documents', 'disposition', (t) => t.string('disposition', 24));
|
||||
await addColumn(knex, 'inbound_documents', 'tax_treatment', (t) => t.string('tax_treatment', 32));
|
||||
await addColumn(knex, 'inbound_documents', 'category_id', (t) => t.integer('category_id').unsigned());
|
||||
// Booking target: event_id NULL = booked to the company.
|
||||
await addColumn(knex, 'inbound_documents', 'event_id', (t) => t.integer('event_id').unsigned());
|
||||
// Re-bill (Weiterverrechnung) linkage + markup.
|
||||
await addColumn(knex, 'inbound_documents', 'markup_type', (t) => t.string('markup_type', 8));
|
||||
await addColumn(knex, 'inbound_documents', 'markup_percent', (t) => t.decimal('markup_percent', 5, 2));
|
||||
await addColumn(knex, 'inbound_documents', 'markup_flat_minor', (t) => t.integer('markup_flat_minor'));
|
||||
await addColumn(knex, 'inbound_documents', 'billed_invoice_id', (t) => t.integer('billed_invoice_id').unsigned());
|
||||
await addColumn(knex, 'inbound_documents', 'billed_invoice_line_item_id', (t) => t.integer('billed_invoice_line_item_id').unsigned());
|
||||
// Supplier payment (the payable is paid HERE, on the incoming invoice).
|
||||
await addColumn(knex, 'inbound_documents', 'supplier_paid', (t) => t.boolean('supplier_paid').notNullable().defaultTo(false));
|
||||
await addColumn(knex, 'inbound_documents', 'supplier_paid_at', (t) => t.timestamp('supplier_paid_at'));
|
||||
await addColumn(knex, 'inbound_documents', 'supplier_payment_method', (t) => t.string('supplier_payment_method', 16));
|
||||
await addColumn(knex, 'inbound_documents', 'supplier_payment_ref', (t) => t.string('supplier_payment_ref', 140));
|
||||
}
|
||||
|
||||
if (await knex.schema.hasTable('expenses')) {
|
||||
// Internal-expense kind + quantity-driven amount (mileage / per-diem).
|
||||
await addColumn(knex, 'expenses', 'kind', (t) => t.string('kind', 16).notNullable().defaultTo('amount')); // amount|mileage|per_diem
|
||||
await addColumn(knex, 'expenses', 'quantity', (t) => t.decimal('quantity', 10, 2)); // km count or number of days
|
||||
await addColumn(knex, 'expenses', 'rate_minor', (t) => t.integer('rate_minor')); // snapshotted km/day rate
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function (knex) {
|
||||
const dropCols = async (table, cols) => {
|
||||
if (!(await knex.schema.hasTable(table))) return;
|
||||
for (const col of cols) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
if (await knex.schema.hasColumn(table, col)) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await knex.schema.alterTable(table, (t) => t.dropColumn(col));
|
||||
}
|
||||
}
|
||||
};
|
||||
await dropCols('inbound_documents', [
|
||||
'disposition', 'tax_treatment', 'category_id', 'event_id',
|
||||
'markup_type', 'markup_percent', 'markup_flat_minor',
|
||||
'billed_invoice_id', 'billed_invoice_line_item_id',
|
||||
'supplier_paid', 'supplier_paid_at', 'supplier_payment_method', 'supplier_payment_ref',
|
||||
]);
|
||||
await dropCols('expenses', ['kind', 'quantity', 'rate_minor']);
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Migration 127: seed the `expenses` feature flag + the Accounting settings.
|
||||
*
|
||||
* - `expenses` feature flag (default OFF) — separate sub-toggle from
|
||||
* `incomingInvoices` under the Accounting master.
|
||||
* - app_settings (setting_type='accounting'):
|
||||
* accounting_km_rate_minor default 70 (CHF 0.70 / km — VERIFY with
|
||||
* your Treuhänder, guideline only)
|
||||
* accounting_per_diem_rate_minor default 0 (admin sets a daily rate)
|
||||
* accounting_require_proof default false (require a proof file on
|
||||
* internal expenses)
|
||||
*
|
||||
* Idempotent: inserts only when missing.
|
||||
*/
|
||||
const ACCOUNTING_SETTINGS = [
|
||||
{ key: 'accounting_km_rate_minor', value: 70 },
|
||||
{ key: 'accounting_per_diem_rate_minor', value: 0 },
|
||||
{ key: 'accounting_require_proof', value: false },
|
||||
];
|
||||
|
||||
exports.up = async function (knex) {
|
||||
if (await knex.schema.hasTable('feature_flags')) {
|
||||
const existing = await knex('feature_flags').where({ key: 'expenses' }).first();
|
||||
if (!existing) await knex('feature_flags').insert({ key: 'expenses', value: false });
|
||||
}
|
||||
|
||||
if (await knex.schema.hasTable('app_settings')) {
|
||||
for (const s of ACCOUNTING_SETTINGS) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const row = await knex('app_settings').where({ setting_key: s.key }).first();
|
||||
if (!row) {
|
||||
// NB: match the canonical app_settings seed pattern (migration 103) —
|
||||
// setting_key/value/type only, NO created_at/updated_at (the table's
|
||||
// migration schema has no such columns; including them errors).
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await knex('app_settings').insert({
|
||||
setting_key: s.key,
|
||||
setting_value: JSON.stringify(s.value),
|
||||
setting_type: 'accounting',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function (knex) {
|
||||
if (await knex.schema.hasTable('feature_flags')) {
|
||||
await knex('feature_flags').where({ key: 'expenses' }).del();
|
||||
}
|
||||
if (await knex.schema.hasTable('app_settings')) {
|
||||
await knex('app_settings').whereIn('setting_key', ACCOUNTING_SETTINGS.map((s) => s.key)).del();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Migration 128: incoming mail (IMAP) support.
|
||||
*
|
||||
* - email_configs gains imap_* columns (a second config block alongside the
|
||||
* outgoing smtp_* one; single row, same field shape).
|
||||
* - `incomingMail` feature flag (default OFF, standalone).
|
||||
* - received_emails: an audit log of messages the IMAP poller processed
|
||||
* (dedupe key = message_id), mirroring the outgoing email_queue / "Sent
|
||||
* emails" surface with a "Received emails" one.
|
||||
*/
|
||||
async function addColumn(knex, table, column, builder) {
|
||||
if (!(await knex.schema.hasColumn(table, column))) {
|
||||
await knex.schema.alterTable(table, builder);
|
||||
}
|
||||
}
|
||||
|
||||
exports.up = async function (knex) {
|
||||
if (await knex.schema.hasTable('email_configs')) {
|
||||
await addColumn(knex, 'email_configs', 'imap_host', (t) => t.string('imap_host', 255));
|
||||
await addColumn(knex, 'email_configs', 'imap_port', (t) => t.integer('imap_port'));
|
||||
await addColumn(knex, 'email_configs', 'imap_secure', (t) => t.boolean('imap_secure').notNullable().defaultTo(true));
|
||||
await addColumn(knex, 'email_configs', 'imap_user', (t) => t.string('imap_user', 255));
|
||||
await addColumn(knex, 'email_configs', 'imap_pass', (t) => t.string('imap_pass', 512));
|
||||
await addColumn(knex, 'email_configs', 'imap_folder', (t) => t.string('imap_folder', 128).defaultTo('INBOX'));
|
||||
}
|
||||
|
||||
if (await knex.schema.hasTable('feature_flags')) {
|
||||
const existing = await knex('feature_flags').where({ key: 'incomingMail' }).first();
|
||||
if (!existing) await knex('feature_flags').insert({ key: 'incomingMail', value: false });
|
||||
}
|
||||
|
||||
if (!(await knex.schema.hasTable('received_emails'))) {
|
||||
await knex.schema.createTable('received_emails', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('message_id', 512);
|
||||
table.string('from_address', 512);
|
||||
table.text('subject');
|
||||
table.timestamp('received_at');
|
||||
table.integer('attachment_count').notNullable().defaultTo(0);
|
||||
// ingested | no_attachment | duplicate | error
|
||||
table.string('status', 24).notNullable().defaultTo('ingested');
|
||||
table.integer('inbound_document_id').unsigned();
|
||||
table.text('error');
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
// UNIQUE (not just INDEX): message_id is the dedup/claim key for the IMAP
|
||||
// poller. The in-process `polling` lock serialises within one backend, but a
|
||||
// second replica / rolling-deploy overlap would otherwise let two workers
|
||||
// both pass the check-then-insert and double-ingest the same mail. NULLs stay
|
||||
// distinct (Postgres + SQLite) so no-Message-ID rows aren't blocked. The
|
||||
// intake claims this row BEFORE ingesting.
|
||||
table.unique(['message_id']);
|
||||
table.index(['status']);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function (knex) {
|
||||
await knex.schema.dropTableIfExists('received_emails');
|
||||
if (await knex.schema.hasTable('feature_flags')) {
|
||||
await knex('feature_flags').where({ key: 'incomingMail' }).del();
|
||||
}
|
||||
if (await knex.schema.hasTable('email_configs')) {
|
||||
for (const col of ['imap_host', 'imap_port', 'imap_secure', 'imap_user', 'imap_pass', 'imap_folder']) {
|
||||
if (await knex.schema.hasColumn('email_configs', col)) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await knex.schema.alterTable('email_configs', (t) => t.dropColumn(col));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* Migration 129: Accounting Layer A — chart of accounts + VAT codes + mappings.
|
||||
*
|
||||
* Prepares picpeak to feed a Treuhänder's double-entry software once a user
|
||||
* crosses the CHF ~500k threshold (LI PGR Art. 1045 → full Buchführung). We do
|
||||
* NOT become an ERP here: we attach account + VAT codes to the data we already
|
||||
* capture so a "collective journal" export can be imported into Banana / bexio /
|
||||
* etc. Native double-entry (journal, Bilanz, Erfolgsrechnung) is Layer B.
|
||||
*
|
||||
* - ledger_accounts : chart of accounts (seeded Swiss/LI KMU Kontenrahmen,
|
||||
* admin-editable — full CRUD).
|
||||
* - vat_codes : MWST codes (CH/LI rates 8.1 / 2.6 / 3.8 / 0 + reverse
|
||||
* charge), each linked to its VAT account.
|
||||
* - expense_categories.ledger_account_id : which expense account a category
|
||||
* books to (mapping, editable).
|
||||
* - app_settings (accounting) : system/default account numbers + the
|
||||
* tax_treatment→VAT-code and output-rate→VAT-code maps.
|
||||
*
|
||||
* Everything is hasTable/hasColumn-guarded + idempotent. app_settings rows use
|
||||
* setting_key/value/type ONLY (no created_at/updated_at — see migration 103).
|
||||
* Legal/financial defaults are EXAMPLES — every surface must point the user at
|
||||
* a Treuhänder.
|
||||
*/
|
||||
|
||||
// Swiss/LI KMU-Kontenrahmen (condensed for a services/photography SME).
|
||||
const SEED_ACCOUNTS = [
|
||||
// Aktiven
|
||||
{ number: '1000', name: 'Kasse', type: 'asset' },
|
||||
{ number: '1020', name: 'Bank', type: 'asset' },
|
||||
{ number: '1100', name: 'Forderungen aus Lieferungen und Leistungen (Debitoren)', type: 'asset' },
|
||||
{ number: '1170', name: 'Vorsteuer MWST', type: 'asset' },
|
||||
{ number: '1300', name: 'Aktive Rechnungsabgrenzung', type: 'asset' },
|
||||
{ number: '1500', name: 'Mobiliar und Einrichtungen', type: 'asset' },
|
||||
{ number: '1520', name: 'Büromaschinen, Informatik, Kommunikation', type: 'asset' },
|
||||
// Passiven
|
||||
{ number: '2000', name: 'Verbindlichkeiten aus Lieferungen und Leistungen (Kreditoren)', type: 'liability' },
|
||||
{ number: '2200', name: 'Geschuldete MWST (Umsatzsteuer)', type: 'liability' },
|
||||
{ number: '2300', name: 'Passive Rechnungsabgrenzung', type: 'liability' },
|
||||
{ number: '2800', name: 'Eigenkapital', type: 'equity' },
|
||||
// Ertrag
|
||||
{ number: '3000', name: 'Produktionsertrag (Fotografie)', type: 'revenue' },
|
||||
{ number: '3200', name: 'Handelsertrag', type: 'revenue' },
|
||||
{ number: '3400', name: 'Dienstleistungsertrag', type: 'revenue' },
|
||||
{ number: '3940', name: 'Weiterverrechnete Spesen', type: 'revenue' },
|
||||
// Aufwand
|
||||
{ number: '4000', name: 'Materialaufwand', type: 'expense' },
|
||||
{ number: '4400', name: 'Aufwand für bezogene Dienstleistungen', type: 'expense' },
|
||||
{ number: '6000', name: 'Raumaufwand (Miete)', type: 'expense' },
|
||||
{ number: '6100', name: 'Unterhalt und Reparaturen', type: 'expense' },
|
||||
{ number: '6200', name: 'Fahrzeug- und Transportaufwand', type: 'expense' },
|
||||
{ number: '6300', name: 'Sachversicherungen, Abgaben, Gebühren', type: 'expense' },
|
||||
{ number: '6500', name: 'Verwaltungsaufwand', type: 'expense' },
|
||||
{ number: '6510', name: 'Telefon, Internet, Porti', type: 'expense' },
|
||||
{ number: '6570', name: 'Informatikaufwand (Software)', type: 'expense' },
|
||||
{ number: '6600', name: 'Werbeaufwand', type: 'expense' },
|
||||
{ number: '6640', name: 'Reise- und Spesenaufwand', type: 'expense' },
|
||||
{ number: '6700', name: 'Sonstiger Betriebsaufwand', type: 'expense' },
|
||||
{ number: '6800', name: 'Abschreibungen', type: 'expense' },
|
||||
];
|
||||
|
||||
// CH/LI MWST codes. `direction` = output (Umsatzsteuer) | input (Vorsteuer).
|
||||
// `account` is the VAT account number (resolved to an id after the accounts
|
||||
// are seeded). 0%/exempt codes carry no VAT account.
|
||||
const SEED_VAT_CODES = [
|
||||
{ code: 'UN81', name: 'Umsatz Normalsatz 8.1%', rate: 8.1, direction: 'output', account: '2200' },
|
||||
{ code: 'UN26', name: 'Umsatz reduzierter Satz 2.6%', rate: 2.6, direction: 'output', account: '2200' },
|
||||
{ code: 'UN38', name: 'Umsatz Beherbergung 3.8%', rate: 3.8, direction: 'output', account: '2200' },
|
||||
{ code: 'UN00', name: 'Umsatz ohne MWST / befreit', rate: 0, direction: 'output', account: null },
|
||||
{ code: 'VST81', name: 'Vorsteuer 8.1%', rate: 8.1, direction: 'input', account: '1170' },
|
||||
{ code: 'VST26', name: 'Vorsteuer 2.6%', rate: 2.6, direction: 'input', account: '1170' },
|
||||
{ code: 'VST00', name: 'Keine Vorsteuer', rate: 0, direction: 'input', account: null },
|
||||
{ code: 'BZ', name: 'Bezugsteuer (Reverse Charge) 8.1%', rate: 8.1, direction: 'input', account: '1170' },
|
||||
];
|
||||
|
||||
// expense_categories.name → expense account number.
|
||||
const CATEGORY_ACCOUNT_MAP = {
|
||||
'Infrastruktur & Miete': '6000',
|
||||
'Equipment & Hardware': '6700',
|
||||
'Software & Lizenzen': '6570',
|
||||
'Material & Verbrauch': '4000',
|
||||
'Reise & Spesen': '6640',
|
||||
'Werbung & Marketing': '6600',
|
||||
'Dienstleistungen/Fremdleistungen': '4400',
|
||||
'Versicherungen & Gebühren': '6300',
|
||||
'Weiterbildung': '6500',
|
||||
'Sonstiges': '6700',
|
||||
};
|
||||
|
||||
// app_settings (type 'accounting'). Account references stored as NUMBERS
|
||||
// (resilient to re-seeding); the maps are JSON keyed by tax_treatment /
|
||||
// output VAT rate.
|
||||
const SETTINGS = [
|
||||
{ key: 'ledger_account_debitoren', value: '1100' },
|
||||
{ key: 'ledger_account_kreditoren', value: '2000' },
|
||||
{ key: 'ledger_account_bank', value: '1020' },
|
||||
{ key: 'ledger_account_cash', value: '1000' },
|
||||
{ key: 'ledger_account_default_revenue', value: '3400' },
|
||||
{ key: 'ledger_account_default_expense', value: '6700' },
|
||||
{ key: 'ledger_account_mileage', value: '6200' },
|
||||
{ key: 'ledger_account_per_diem', value: '6640' },
|
||||
{ key: 'ledger_account_rebilled_revenue', value: '3940' },
|
||||
{ key: 'ledger_vat_map', value: { domestic: 'VST81', reverse_charge_service: 'BZ', foreign_vat_non_reclaimable: 'VST00', import_goods: 'VST81' } },
|
||||
{ key: 'ledger_output_vat_map', value: { '8.1': 'UN81', '2.6': 'UN26', '3.8': 'UN38', '0': 'UN00' } },
|
||||
];
|
||||
|
||||
exports.up = async function (knex) {
|
||||
// 1) ledger_accounts
|
||||
if (!(await knex.schema.hasTable('ledger_accounts'))) {
|
||||
await knex.schema.createTable('ledger_accounts', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('number', 16).notNullable();
|
||||
table.string('name', 200).notNullable();
|
||||
// asset|liability|equity|revenue|expense
|
||||
table.string('type', 16).notNullable();
|
||||
table.boolean('is_seed').notNullable().defaultTo(false);
|
||||
table.boolean('active').notNullable().defaultTo(true);
|
||||
table.integer('display_order').notNullable().defaultTo(0);
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
table.timestamp('updated_at').defaultTo(knex.fn.now());
|
||||
table.unique(['number']);
|
||||
table.index(['type']);
|
||||
});
|
||||
await knex('ledger_accounts').insert(SEED_ACCOUNTS.map((a, i) => ({
|
||||
number: a.number, name: a.name, type: a.type, is_seed: true, active: true, display_order: (i + 1) * 10,
|
||||
})));
|
||||
}
|
||||
|
||||
// Resolve account number → id for the FK references below.
|
||||
const accountRows = await knex('ledger_accounts').select('id', 'number');
|
||||
const idByNumber = new Map(accountRows.map((r) => [r.number, r.id]));
|
||||
|
||||
// 2) vat_codes
|
||||
if (!(await knex.schema.hasTable('vat_codes'))) {
|
||||
await knex.schema.createTable('vat_codes', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('code', 16).notNullable();
|
||||
table.string('name', 200).notNullable();
|
||||
table.decimal('rate', 5, 2).notNullable().defaultTo(0);
|
||||
table.string('direction', 8).notNullable(); // output|input
|
||||
table.integer('account_id').unsigned().references('id').inTable('ledger_accounts').onDelete('SET NULL');
|
||||
table.boolean('is_seed').notNullable().defaultTo(false);
|
||||
table.boolean('active').notNullable().defaultTo(true);
|
||||
table.integer('display_order').notNullable().defaultTo(0);
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
table.timestamp('updated_at').defaultTo(knex.fn.now());
|
||||
table.unique(['code']);
|
||||
table.index(['direction']);
|
||||
});
|
||||
await knex('vat_codes').insert(SEED_VAT_CODES.map((v, i) => ({
|
||||
code: v.code, name: v.name, rate: v.rate, direction: v.direction,
|
||||
account_id: v.account ? (idByNumber.get(v.account) || null) : null,
|
||||
is_seed: true, active: true, display_order: (i + 1) * 10,
|
||||
})));
|
||||
}
|
||||
|
||||
// 3) expense_categories.ledger_account_id (mapping) + seed defaults
|
||||
if (await knex.schema.hasTable('expense_categories')) {
|
||||
if (!(await knex.schema.hasColumn('expense_categories', 'ledger_account_id'))) {
|
||||
await knex.schema.alterTable('expense_categories', (table) => {
|
||||
table.integer('ledger_account_id').unsigned().references('id').inTable('ledger_accounts').onDelete('SET NULL');
|
||||
});
|
||||
}
|
||||
// Seed the category→account mapping for the seeded categories only when
|
||||
// still unset (don't clobber an admin's choice).
|
||||
const cats = await knex('expense_categories').select('id', 'name', 'ledger_account_id');
|
||||
for (const c of cats) {
|
||||
const accNum = CATEGORY_ACCOUNT_MAP[c.name];
|
||||
if (accNum && c.ledger_account_id == null && idByNumber.get(accNum)) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await knex('expense_categories').where({ id: c.id }).update({ ledger_account_id: idByNumber.get(accNum) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4) app_settings defaults (setting_key/value/type only)
|
||||
if (await knex.schema.hasTable('app_settings')) {
|
||||
for (const s of SETTINGS) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const row = await knex('app_settings').where({ setting_key: s.key }).first();
|
||||
if (!row) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await knex('app_settings').insert({
|
||||
setting_key: s.key,
|
||||
setting_value: JSON.stringify(s.value),
|
||||
setting_type: 'accounting',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function (knex) {
|
||||
if (await knex.schema.hasTable('expense_categories') && await knex.schema.hasColumn('expense_categories', 'ledger_account_id')) {
|
||||
await knex.schema.alterTable('expense_categories', (table) => { table.dropColumn('ledger_account_id'); });
|
||||
}
|
||||
await knex.schema.dropTableIfExists('vat_codes');
|
||||
await knex.schema.dropTableIfExists('ledger_accounts');
|
||||
if (await knex.schema.hasTable('app_settings')) {
|
||||
await knex('app_settings').whereIn('setting_key', SETTINGS.map((s) => s.key)).del();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Migration: snapshot the chosen VAT code on quotes + invoices.
|
||||
*
|
||||
* The invoice/quote editors now pick an output VAT code from the central
|
||||
* vat_codes registry (Settings → Accounting) instead of free-typing a rate.
|
||||
* We snapshot the CODE STRING (e.g. "UN81") on the document at create time —
|
||||
* alongside the existing vat_rate — so the Treuhänder/accounting export emits
|
||||
* exactly the code the document was issued with, immutably. Editing or deleting
|
||||
* a vat_codes row later never changes a historical document's export code.
|
||||
*
|
||||
* quotes.vat_code nullable string (snapshot)
|
||||
* invoices.vat_code nullable string (snapshot)
|
||||
*
|
||||
* Legacy rows stay null; the export falls back to the rate→code map for those.
|
||||
* Idempotent: columns guarded.
|
||||
*/
|
||||
|
||||
exports.up = async function (knex) {
|
||||
for (const tbl of ['quotes', 'invoices']) {
|
||||
if ((await knex.schema.hasTable(tbl)) && !(await knex.schema.hasColumn(tbl, 'vat_code'))) {
|
||||
await knex.schema.alterTable(tbl, (table) => {
|
||||
table.string('vat_code', 16);
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function (knex) {
|
||||
for (const tbl of ['quotes', 'invoices']) {
|
||||
if ((await knex.schema.hasTable(tbl)) && (await knex.schema.hasColumn(tbl, 'vat_code'))) {
|
||||
await knex.schema.alterTable(tbl, (table) => {
|
||||
table.dropColumn('vat_code');
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Migration 131: per-customer `feature_contracts` override on customer_accounts.
|
||||
*
|
||||
* Contracts was master-only — every active customer saw the Contracts tab
|
||||
* whenever the global `contracts` feature flag was on. This adds a per-customer
|
||||
* toggle to match feature_calendar / feature_quotes / feature_bills /
|
||||
* feature_hours_logging, so an admin can hide Contracts for an individual
|
||||
* customer.
|
||||
*
|
||||
* PRESERVE-VISUALS: unlike the opt-in quotes/bills columns (default false),
|
||||
* contracts is currently opt-OUT (everyone has it), so the column defaults
|
||||
* TRUE. Adding a NOT NULL column with a default backfills existing rows to
|
||||
* true on both SQLite and Postgres, so no customer loses their Contracts tab
|
||||
* on upgrade. The effective resolver becomes
|
||||
* `contractsMaster && truthy(feature_contracts)`.
|
||||
*
|
||||
* Idempotent: guarded by hasColumn.
|
||||
*/
|
||||
exports.up = async function (knex) {
|
||||
const has = await knex.schema.hasColumn('customer_accounts', 'feature_contracts');
|
||||
if (!has) {
|
||||
await knex.schema.alterTable('customer_accounts', (table) => {
|
||||
table.boolean('feature_contracts').notNullable().defaultTo(true);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function (knex) {
|
||||
if (await knex.schema.hasColumn('customer_accounts', 'feature_contracts')) {
|
||||
await knex.schema.alterTable('customer_accounts', (table) => {
|
||||
table.dropColumn('feature_contracts');
|
||||
});
|
||||
}
|
||||
};
|
||||
Generated
+395
-12
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "picpeak-backend",
|
||||
"version": "3.47.2-beta.0",
|
||||
"version": "3.60.6-beta.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "picpeak-backend",
|
||||
"version": "3.47.2-beta.0",
|
||||
"version": "3.60.6-beta.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.850.0",
|
||||
"@aws-sdk/lib-storage": "^3.850.0",
|
||||
@@ -29,11 +29,13 @@
|
||||
"i18next": "25.3.2",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"imapflow": "^1.4.0",
|
||||
"ipaddr.js": "^2.3.0",
|
||||
"joi": "^17.9.1",
|
||||
"js-yaml": "^4.1.1",
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
"knex": "^2.4.2",
|
||||
"mailparser": "^3.9.9",
|
||||
"mime-types": "^3.0.1",
|
||||
"multer": "^2.0.2",
|
||||
"node-cron": "^3.0.2",
|
||||
@@ -2725,6 +2727,12 @@
|
||||
"pako": "^1.0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@pinojs/redact": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
|
||||
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@scarf/scarf": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz",
|
||||
@@ -2732,6 +2740,22 @@
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@selderee/plugin-htmlparser2": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.12.0.tgz",
|
||||
"integrity": "sha512-oELmoyA6ML9jDRMV3kgcMQFKxUfBU0yFVn6yTctVaLT5ygXnxH52I3TZEgV9EhXJC68/uFvE5Daj1/25c0Xa/A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "~2.3.0",
|
||||
"domhandler": "~5.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/KillyMXI"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"selderee": "~0.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@sideway/address": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz",
|
||||
@@ -3722,6 +3746,17 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@zone-eu/mailsplit": {
|
||||
"version": "5.4.12",
|
||||
"resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.12.tgz",
|
||||
"integrity": "sha512-w7Gy+NvjZ0MiXm8F6zfjImAqcTONKDImgWVBjDKQVFUXWuz3VFM5levNArkL2M877ajql5+bkS2pDV56injlmg==",
|
||||
"license": "(MIT OR EUPL-1.1+)",
|
||||
"dependencies": {
|
||||
"libbase64": "1.3.0",
|
||||
"libmime": "5.3.8",
|
||||
"libqp": "2.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/abbrev": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
|
||||
@@ -4047,6 +4082,15 @@
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/atomic-sleep": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
|
||||
"integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.15.2",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz",
|
||||
@@ -5087,6 +5131,15 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/deepmerge-ts": {
|
||||
"version": "7.1.5",
|
||||
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz",
|
||||
"integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
@@ -5329,6 +5382,15 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/encoding-japanese": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/encoding-japanese/-/encoding-japanese-2.2.0.tgz",
|
||||
"integrity": "sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/end-of-stream": {
|
||||
"version": "1.4.5",
|
||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||
@@ -6531,6 +6593,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/he": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
|
||||
"integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"he": "bin/he"
|
||||
}
|
||||
},
|
||||
"node_modules/helmet": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/helmet/-/helmet-7.2.0.tgz",
|
||||
@@ -6556,6 +6627,56 @@
|
||||
"void-elements": "3.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/html-to-text": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-10.0.0.tgz",
|
||||
"integrity": "sha512-2OH59Gtprdczel+7Rxgpz9hGVJREaf8Lt1H4kZwWHpEn70VQKRuMNGsb2eDbwaTzrYzb0hheiOG1P7Dim0B4dQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@selderee/plugin-htmlparser2": "~0.12.0",
|
||||
"deepmerge-ts": "^7.1.5",
|
||||
"dom-serializer": "^2.0.0",
|
||||
"htmlparser2": "^10.1.0",
|
||||
"selderee": "~0.12.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/KillyMXI"
|
||||
}
|
||||
},
|
||||
"node_modules/html-to-text/node_modules/entities": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
||||
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/html-to-text/node_modules/htmlparser2": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
|
||||
"integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
|
||||
"funding": [
|
||||
"https://github.com/fb55/htmlparser2?sponsor=1",
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3",
|
||||
"domutils": "^3.2.2",
|
||||
"entities": "^7.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/htmlparser2": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
|
||||
@@ -6700,6 +6821,22 @@
|
||||
"cross-fetch": "4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
||||
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/ieee754": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||
@@ -6737,6 +6874,23 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/imapflow": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.4.0.tgz",
|
||||
"integrity": "sha512-bpNWv3AwzZryMMYoKiqPebcxmldCQwWxqhBQ5b/nTlJYAgexCzDIpN0LdhVJpJp6H25lAmwvShX+Fu/9AU5Spg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@zone-eu/mailsplit": "5.4.12",
|
||||
"encoding-japanese": "2.2.0",
|
||||
"iconv-lite": "0.7.2",
|
||||
"libbase64": "1.3.0",
|
||||
"libmime": "5.3.8",
|
||||
"libqp": "2.1.1",
|
||||
"nodemailer": "8.0.10",
|
||||
"pino": "10.3.1",
|
||||
"socks": "2.8.9"
|
||||
}
|
||||
},
|
||||
"node_modules/import-fresh": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||
@@ -6827,7 +6981,6 @@
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
||||
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
@@ -7972,6 +8125,15 @@
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/leac": {
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/leac/-/leac-0.7.0.tgz",
|
||||
"integrity": "sha512-qMrZeyEekgdRQ9o6a4NAB2EQZrv827GJdn1vnapwSJ90hWRB4TzUSunvacPkxQ2TnNqHNI1/zSt0hlo0crG8Jw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/KillyMXI"
|
||||
}
|
||||
},
|
||||
"node_modules/leven": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
|
||||
@@ -7996,6 +8158,30 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/libbase64": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/libbase64/-/libbase64-1.3.0.tgz",
|
||||
"integrity": "sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/libmime": {
|
||||
"version": "5.3.8",
|
||||
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.8.tgz",
|
||||
"integrity": "sha512-ZrCY+Q66mPvasAfjsQ/IgahzoBvfE1VdtGRpo1hwRB1oK3wJKxhKA3GOcd2a6j7AH5eMFccxK9fBoCpRZTf8ng==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"encoding-japanese": "2.2.0",
|
||||
"iconv-lite": "0.7.2",
|
||||
"libbase64": "1.3.0",
|
||||
"libqp": "2.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/libqp": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/libqp/-/libqp-2.1.1.tgz",
|
||||
"integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/linebreak": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
|
||||
@@ -8022,6 +8208,25 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/linkify-it": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.1.tgz",
|
||||
"integrity": "sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/puzrin"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/markdown-it"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"uc.micro": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||
@@ -8164,6 +8369,24 @@
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/mailparser": {
|
||||
"version": "3.9.9",
|
||||
"resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.9.9.tgz",
|
||||
"integrity": "sha512-ulZi7h1eKm8WQmXibIgj8dmMQGDQCUS/g+XHkxxjcLDq4Dwn2ppo+0hz5Fi+ltvu4eN7mh3ykIp5RcpiWWav1w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@zone-eu/mailsplit": "5.4.12",
|
||||
"encoding-japanese": "2.2.0",
|
||||
"he": "1.2.0",
|
||||
"html-to-text": "10.0.0",
|
||||
"iconv-lite": "0.7.2",
|
||||
"libmime": "5.3.8",
|
||||
"linkify-it": "5.0.1",
|
||||
"nodemailer": "8.0.10",
|
||||
"punycode.js": "2.3.1",
|
||||
"tlds": "1.261.0"
|
||||
}
|
||||
},
|
||||
"node_modules/make-dir": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
|
||||
@@ -8836,9 +9059,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "8.0.7",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.7.tgz",
|
||||
"integrity": "sha512-pkjE4mkBzQjdJT4/UmlKl3pX0rC9fZmjh7c6C9o7lv66Ac6w9WCnzPzhbPNxwZAzlF4mdq4CSWB5+FbK6FWCow==",
|
||||
"version": "8.0.10",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.10.tgz",
|
||||
"integrity": "sha512-BLFuSth7QtHOkBzyqTehWWyub0NTRDuK2Q2SQfnGLsrJnzyU+Yeh4WpV1eZGuARFj1xQJHIdnTuJZLP+b9R1GQ==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
@@ -9036,6 +9259,15 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/on-exit-leak-free": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
|
||||
"integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/on-finished": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||
@@ -9214,6 +9446,19 @@
|
||||
"integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/parseley": {
|
||||
"version": "0.13.1",
|
||||
"resolved": "https://registry.npmjs.org/parseley/-/parseley-0.13.1.tgz",
|
||||
"integrity": "sha512-uNBJZzmb60l6p6VWLTmevizNAGnE0xoSf1n0B4q3ntegDNzcS68NRCcBDZTcyXHxt2XhBChsCuqj4M+nChvE/A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"leac": "^0.7.0",
|
||||
"peberminta": "^0.10.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/KillyMXI"
|
||||
}
|
||||
},
|
||||
"node_modules/parseurl": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||
@@ -9318,6 +9563,15 @@
|
||||
"png-js": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/peberminta": {
|
||||
"version": "0.10.0",
|
||||
"resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.10.0.tgz",
|
||||
"integrity": "sha512-80B2AsU+I4Qdb0ZAPSfe9UwvGzwkM37IKIFEvdS3D/3Ndgv2bsuJ0bfG1+iEYO+l7Gfd4EUJmuRyq7efLgRMzQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/KillyMXI"
|
||||
}
|
||||
},
|
||||
"node_modules/pg": {
|
||||
"version": "8.16.3",
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
|
||||
@@ -9432,6 +9686,43 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/pino": {
|
||||
"version": "10.3.1",
|
||||
"resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz",
|
||||
"integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@pinojs/redact": "^0.4.0",
|
||||
"atomic-sleep": "^1.0.0",
|
||||
"on-exit-leak-free": "^2.1.0",
|
||||
"pino-abstract-transport": "^3.0.0",
|
||||
"pino-std-serializers": "^7.0.0",
|
||||
"process-warning": "^5.0.0",
|
||||
"quick-format-unescaped": "^4.0.3",
|
||||
"real-require": "^0.2.0",
|
||||
"safe-stable-stringify": "^2.3.1",
|
||||
"sonic-boom": "^4.0.1",
|
||||
"thread-stream": "^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"pino": "bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/pino-abstract-transport": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz",
|
||||
"integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"split2": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pino-std-serializers": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
|
||||
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pirates": {
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
|
||||
@@ -9665,6 +9956,22 @@
|
||||
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/process-warning": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
|
||||
"integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/promise-inflight": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz",
|
||||
@@ -9758,6 +10065,15 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode.js": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
|
||||
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/pure-rand": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
|
||||
@@ -9966,6 +10282,12 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/quick-format-unescaped": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
|
||||
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/range-parser": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||
@@ -10078,6 +10400,15 @@
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/real-require": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
|
||||
"integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rechoir": {
|
||||
"version": "0.8.0",
|
||||
"resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz",
|
||||
@@ -10285,6 +10616,18 @@
|
||||
"postcss": "^8.3.11"
|
||||
}
|
||||
},
|
||||
"node_modules/selderee": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/selderee/-/selderee-0.12.0.tgz",
|
||||
"integrity": "sha512-b1YMh3+DHZp59DLna3qVwQ5iOla/nrI6mLBNW02XxU77M3046Df6VLkoaJyFz20VsGIG5kkp+FK0kg4K4HnUFw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"parseley": "~0.13.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/KillyMXI"
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||
@@ -10628,20 +10971,18 @@
|
||||
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
|
||||
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">= 6.0.0",
|
||||
"npm": ">= 3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/socks": {
|
||||
"version": "2.8.7",
|
||||
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz",
|
||||
"integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==",
|
||||
"version": "2.8.9",
|
||||
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz",
|
||||
"integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"ip-address": "^10.0.1",
|
||||
"ip-address": "^10.1.1",
|
||||
"smart-buffer": "^4.2.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -10664,6 +11005,15 @@
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/sonic-boom": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
|
||||
"integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"atomic-sleep": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
@@ -11284,6 +11634,24 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/thread-stream": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz",
|
||||
"integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"real-require": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/thread-stream/node_modules/real-require": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz",
|
||||
"integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tildify": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tildify/-/tildify-2.0.0.tgz",
|
||||
@@ -11299,6 +11667,15 @@
|
||||
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tlds": {
|
||||
"version": "1.261.0",
|
||||
"resolved": "https://registry.npmjs.org/tlds/-/tlds-1.261.0.tgz",
|
||||
"integrity": "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"tlds": "bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/tmpl": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
|
||||
@@ -11447,6 +11824,12 @@
|
||||
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/uc.micro": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
|
||||
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/undefsafe": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
|
||||
|
||||
@@ -35,11 +35,13 @@
|
||||
"i18next": "25.3.2",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"imapflow": "^1.4.0",
|
||||
"ipaddr.js": "^2.3.0",
|
||||
"joi": "^17.9.1",
|
||||
"js-yaml": "^4.1.1",
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
"knex": "^2.4.2",
|
||||
"mailparser": "^3.9.9",
|
||||
"mime-types": "^3.0.1",
|
||||
"multer": "^2.0.2",
|
||||
"node-cron": "^3.0.2",
|
||||
|
||||
@@ -707,6 +707,11 @@ app.use('/api/admin/projects', require('./src/routes/adminProjects'));
|
||||
app.use('/api/admin/calendar', require('./src/routes/adminCalendar'));
|
||||
app.use('/api/admin/deals', require('./src/routes/adminDeals'));
|
||||
app.use('/api/admin/tax-report', require('./src/routes/adminTaxReport'));
|
||||
app.use('/api/admin/expenses', require('./src/routes/adminExpenses'));
|
||||
app.use('/api/admin/ledger', require('./src/routes/adminLedger'));
|
||||
// Read-only VAT-code registry for the invoice/quote editors — un-gated by the
|
||||
// accounting flag (management stays under /ledger).
|
||||
app.use('/api/admin/vat-codes', require('./src/routes/adminVatCodes'));
|
||||
app.use('/api/admin/system-health', require('./src/routes/adminSystemHealth'));
|
||||
app.use('/api/admin/dev', require('./src/routes/adminDev'));
|
||||
app.use('/api/public/quotes', require('./src/routes/publicQuotes'));
|
||||
@@ -837,6 +842,15 @@ async function startServer() {
|
||||
}
|
||||
startEmailQueueProcessor();
|
||||
|
||||
// Start incoming-mail (IMAP) poller — no-ops each minute unless the
|
||||
// `incomingMail` flag is on and a mailbox is configured (migration 128).
|
||||
try {
|
||||
const { startIncomingMailPoller } = require('./src/services/emailIntakeService');
|
||||
startIncomingMailPoller();
|
||||
} catch (err) {
|
||||
logger.warn('Incoming-mail poller failed to start:', err.message);
|
||||
}
|
||||
|
||||
// Start webhook delivery worker (#327)
|
||||
const { startWebhookDeliveryWorker } = require('./src/services/webhookDeliveryWorker');
|
||||
startWebhookDeliveryWorker();
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* requireFeatureFlag(key, code?) — 403 when the named `feature_flags` row is off.
|
||||
*
|
||||
* Belt-and-braces gate for admin routes whose feature can be toggled in
|
||||
* Settings → Features. The frontend hides disabled surfaces, but a direct API
|
||||
* hit must still be refused so a disabled feature is never actable. Mirrors the
|
||||
* truthy logic feature_flags uses everywhere (true | 1 | '1').
|
||||
*
|
||||
* Cached: the accounting area alone is 10+ gated endpoints and the dashboard
|
||||
* polls several, so a per-request DB read is wasteful. Flags change rarely and
|
||||
* only via `PUT /admin/feature-flags`, which calls invalidateFeatureFlagCache()
|
||||
* — so a short TTL is belt-and-braces against any other mutation path.
|
||||
*
|
||||
* Several route files (adminLedger, adminExpenses) predate this and define an
|
||||
* identical local `requireFlag`; new gates should import this instead.
|
||||
*/
|
||||
const { db } = require('../database/db');
|
||||
|
||||
const TTL_MS = 10_000;
|
||||
const cache = new Map(); // key -> { enabled, expires }
|
||||
|
||||
function flagEnabledFromRow(row) {
|
||||
return !!(row && (row.value === true || row.value === 1 || row.value === '1'));
|
||||
}
|
||||
|
||||
async function isFeatureEnabled(key) {
|
||||
const now = Date.now();
|
||||
const hit = cache.get(key);
|
||||
if (hit && hit.expires > now) return hit.enabled;
|
||||
const row = await db('feature_flags').where({ key }).first();
|
||||
const enabled = flagEnabledFromRow(row);
|
||||
cache.set(key, { enabled, expires: now + TTL_MS });
|
||||
return enabled;
|
||||
}
|
||||
|
||||
/** Clear the flag cache — call after any write to feature_flags. */
|
||||
function invalidateFeatureFlagCache() {
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
function requireFeatureFlag(key, code) {
|
||||
return async (req, res, next) => {
|
||||
try {
|
||||
if (await isFeatureEnabled(key)) return next();
|
||||
return res.status(403).json({
|
||||
error: `${key} feature is disabled`,
|
||||
code: code || `${key.replace(/([a-z])([A-Z])/g, '$1_$2').toUpperCase()}_DISABLED`,
|
||||
});
|
||||
} catch (err) {
|
||||
return next(err);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { requireFeatureFlag, isFeatureEnabled, invalidateFeatureFlagCache };
|
||||
@@ -10,6 +10,13 @@ const express = require('express');
|
||||
const { body, param, query } = require('express-validator');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { requireFeatureFlag } = require('../middleware/requireFeatureFlag');
|
||||
|
||||
// Hour-entry routes are gated by the hoursLogging master so a direct API hit
|
||||
// can't read/edit/delete/bill logged hours while the feature is off (the
|
||||
// frontend already hides the surface). Per-customer enforcement stays in
|
||||
// customerHoursService.createEntry.
|
||||
const requireHoursLogging = requireFeatureFlag('hoursLogging', 'HOURS_LOGGING_DISABLED');
|
||||
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
|
||||
const customerAccountsService = require('../services/customerAccountsService');
|
||||
const customerHoursService = require('../services/customerHoursService');
|
||||
@@ -66,6 +73,9 @@ function transformCustomer(c) {
|
||||
// set one; the editor surfaces it as an empty input and forces a
|
||||
// per-entry override on every logged block.
|
||||
featureHoursLogging: c.feature_hours_logging === true || c.feature_hours_logging === 1,
|
||||
// Contracts override (migration 131). Opt-out: absent column (older row /
|
||||
// un-selected) reads as ON so existing customers keep the Contracts tab.
|
||||
featureContracts: c.feature_contracts === undefined ? true : (c.feature_contracts === true || c.feature_contracts === 1),
|
||||
hourlyRateMinor: c.hourly_rate_minor != null ? Number(c.hourly_rate_minor) : null,
|
||||
// Per-customer Skonto opt-out (migration 112). When true, none of
|
||||
// this customer's invoices qualify for an early-payment discount,
|
||||
@@ -387,6 +397,7 @@ router.put('/:id', [
|
||||
body('feature_calendar').optional().isBoolean(),
|
||||
body('feature_quotes').optional().isBoolean(),
|
||||
body('feature_bills').optional().isBoolean(),
|
||||
body('feature_contracts').optional().isBoolean(),
|
||||
// Hours logging (migration 129).
|
||||
body('feature_hours_logging').optional().isBoolean(),
|
||||
body('hourly_rate_minor').optional({ nullable: true }).isInt({ min: 0 }),
|
||||
@@ -541,6 +552,7 @@ router.put('/:id/events', [
|
||||
// can't collide with the int-validated :id pattern.
|
||||
router.get('/hour-entries/unbilled-summary', [
|
||||
adminAuth,
|
||||
requireHoursLogging,
|
||||
requirePermission('customers.view'),
|
||||
], handleAsync(async (req, res) => {
|
||||
const summary = await customerHoursService.getUnbilledSummaryByCustomer();
|
||||
@@ -549,6 +561,7 @@ router.get('/hour-entries/unbilled-summary', [
|
||||
|
||||
router.get('/:id/hour-entries', [
|
||||
adminAuth,
|
||||
requireHoursLogging,
|
||||
requirePermission('customers.view'),
|
||||
param('id').isInt({ min: 1 }),
|
||||
query('status').optional().isIn(['unbilled', 'billed', 'cancelled']),
|
||||
@@ -563,6 +576,7 @@ router.get('/:id/hour-entries', [
|
||||
|
||||
router.post('/:id/hour-entries', [
|
||||
adminAuth,
|
||||
requireHoursLogging,
|
||||
// Migration 134 — hour entries are customer-scoped writes; same scope
|
||||
// as customer record edits, narrower than invite/create.
|
||||
requirePermission('customers.edit'),
|
||||
@@ -585,6 +599,7 @@ router.post('/:id/hour-entries', [
|
||||
|
||||
router.put('/:id/hour-entries/:entryId', [
|
||||
adminAuth,
|
||||
requireHoursLogging,
|
||||
requirePermission('customers.edit'),
|
||||
param('id').isInt({ min: 1 }),
|
||||
param('entryId').isInt({ min: 1 }),
|
||||
@@ -605,6 +620,7 @@ router.put('/:id/hour-entries/:entryId', [
|
||||
|
||||
router.delete('/:id/hour-entries/:entryId', [
|
||||
adminAuth,
|
||||
requireHoursLogging,
|
||||
requirePermission('customers.edit'),
|
||||
param('id').isInt({ min: 1 }),
|
||||
param('entryId').isInt({ min: 1 }),
|
||||
@@ -619,6 +635,7 @@ router.delete('/:id/hour-entries/:entryId', [
|
||||
|
||||
router.post('/:id/hour-entries/bill', [
|
||||
adminAuth,
|
||||
requireHoursLogging,
|
||||
requirePermission('customers.edit'),
|
||||
param('id').isInt({ min: 1 }),
|
||||
], handleAsync(async (req, res) => {
|
||||
|
||||
@@ -21,6 +21,7 @@ const express = require('express');
|
||||
const { param, body } = require('express-validator');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { requireFeatureFlag } = require('../middleware/requireFeatureFlag');
|
||||
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
|
||||
const dealsService = require('../services/dealsService');
|
||||
const invoiceService = require('../services/invoiceService');
|
||||
@@ -56,6 +57,9 @@ router.get(
|
||||
*/
|
||||
router.put(
|
||||
'/:uuid/installment-plan',
|
||||
// Mutates invoices — gate on the bills flag like every other invoice
|
||||
// write path, so installment plans can't be reshaped with Bills off.
|
||||
requireFeatureFlag('bills', 'BILLS_DISABLED'),
|
||||
requirePermission('bills.manage'),
|
||||
[
|
||||
param('uuid').isString().isLength({ min: 32, max: 36 }),
|
||||
|
||||
@@ -118,6 +118,161 @@ router.post('/config', [
|
||||
}
|
||||
});
|
||||
|
||||
// ── Incoming mail (IMAP) config — a second block alongside outgoing SMTP ──
|
||||
router.get('/incoming-config', adminAuth, requirePermission('email.view'), async (req, res) => {
|
||||
try {
|
||||
const c = await db('email_configs').first();
|
||||
res.json({
|
||||
imap_host: c?.imap_host || '',
|
||||
imap_port: c?.imap_port || 993,
|
||||
imap_secure: c?.imap_secure !== false,
|
||||
imap_user: c?.imap_user || '',
|
||||
imap_pass: c?.imap_pass ? '********' : '', // never send the real password
|
||||
imap_folder: c?.imap_folder || 'INBOX',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Incoming mail config fetch error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch incoming mail configuration' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/incoming-config', [
|
||||
adminAuth,
|
||||
requirePermission('email.edit'),
|
||||
body('imap_host').notEmpty().withMessage('IMAP host is required'),
|
||||
body('imap_port').isInt({ min: 1, max: 65535 }).withMessage('Invalid port number'),
|
||||
// IMAP always needs a login (unlike SMTP relay) — the poller's
|
||||
// getImapConfig() returns null without a username, so require it.
|
||||
body('imap_user').notEmpty().withMessage('IMAP username is required'),
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });
|
||||
const { imap_host, imap_port, imap_secure, imap_user, imap_pass, imap_folder } = req.body;
|
||||
const { isPrivateIP } = require('../utils/networkValidation');
|
||||
if (isPrivateIP(imap_host)) {
|
||||
return res.status(400).json({ error: 'IMAP host cannot point to a private or internal network address' });
|
||||
}
|
||||
const existing = await db('email_configs').first();
|
||||
const data = {
|
||||
imap_host,
|
||||
imap_port: parseInt(imap_port),
|
||||
imap_secure: imap_secure || false,
|
||||
imap_user: imap_user || '',
|
||||
imap_folder: imap_folder || 'INBOX',
|
||||
updated_at: new Date(),
|
||||
};
|
||||
if (imap_pass && imap_pass !== '********') data.imap_pass = imap_pass;
|
||||
if (existing) await db('email_configs').where('id', existing.id).update(data);
|
||||
else await db('email_configs').insert(data);
|
||||
await logActivity('incoming_mail_config_updated', { imap_host }, null, { type: 'admin', id: req.admin.id, name: req.admin.username });
|
||||
res.json({ message: 'Incoming mail configuration updated successfully' });
|
||||
} catch (error) {
|
||||
console.error('Incoming mail config update error:', error);
|
||||
res.status(500).json({ error: 'Failed to update incoming mail configuration' });
|
||||
}
|
||||
});
|
||||
|
||||
// Received-emails log (the IMAP poller's audit trail) — "Received emails" tab.
|
||||
// List IMAP folders so the UI can offer a dropdown (auto-detect) instead of a
|
||||
// free-text path. Accepts optional creds in the body to detect before saving;
|
||||
// falls back to the stored config (and stored password when masked).
|
||||
router.post('/incoming-config/folders', adminAuth, requirePermission('email.view'), async (req, res) => {
|
||||
try {
|
||||
const { imap_host, imap_port, imap_secure, imap_user, imap_pass } = req.body || {};
|
||||
if (imap_host) {
|
||||
const { isPrivateIP } = require('../utils/networkValidation');
|
||||
if (isPrivateIP(imap_host)) {
|
||||
return res.status(400).json({ error: 'IMAP host cannot point to a private or internal network address' });
|
||||
}
|
||||
}
|
||||
const emailIntakeService = require('../services/emailIntakeService');
|
||||
const folders = await emailIntakeService.listFolders(
|
||||
imap_host ? { host: imap_host, port: imap_port, secure: imap_secure, user: imap_user, pass: imap_pass } : undefined
|
||||
);
|
||||
res.json({ folders });
|
||||
} catch (error) {
|
||||
console.error('IMAP folder detection error:', error);
|
||||
res.status(422).json({ error: `Could not connect to the mailbox (${error.message}). Check host, port (IMAP is usually 993) and credentials.` });
|
||||
}
|
||||
});
|
||||
|
||||
// Test the incoming-mail connection: log in + open the configured folder and
|
||||
// report message/unread counts. Accepts current form creds (test before save).
|
||||
router.post('/incoming-config/test', adminAuth, requirePermission('email.view'), async (req, res) => {
|
||||
try {
|
||||
const { imap_host, imap_port, imap_secure, imap_user, imap_pass, imap_folder } = req.body || {};
|
||||
if (imap_host) {
|
||||
const { isPrivateIP } = require('../utils/networkValidation');
|
||||
if (isPrivateIP(imap_host)) {
|
||||
return res.status(400).json({ error: 'IMAP host cannot point to a private or internal network address' });
|
||||
}
|
||||
}
|
||||
const emailIntakeService = require('../services/emailIntakeService');
|
||||
const result = await emailIntakeService.testConnection(
|
||||
imap_host ? { host: imap_host, port: imap_port, secure: imap_secure, user: imap_user, pass: imap_pass, folder: imap_folder } : undefined
|
||||
);
|
||||
if (result && result.ok === false) {
|
||||
return res.status(400).json({ error: 'Incoming mail is not configured yet — enter host, username and password first.' });
|
||||
}
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('IMAP connection test error:', error);
|
||||
res.status(422).json({ error: `Could not connect to the mailbox (${error.message}). Check host, port (IMAP is usually 993), credentials and folder.` });
|
||||
}
|
||||
});
|
||||
|
||||
// End-to-end round-trip: send via SMTP to the IMAP mailbox, then confirm it
|
||||
// arrives. Uses saved config for both sides (real passwords needed).
|
||||
router.post('/incoming-config/roundtrip', adminAuth, requirePermission('email.send'), async (req, res) => {
|
||||
try {
|
||||
const emailIntakeService = require('../services/emailIntakeService');
|
||||
const result = await emailIntakeService.roundTripTest();
|
||||
if (result.ok) return res.json(result);
|
||||
const map = {
|
||||
smtp_unconfigured: 'Configure and save the outgoing SMTP settings first.',
|
||||
imap_unconfigured: 'Configure and save the incoming IMAP settings first.',
|
||||
recipient_not_email: `The IMAP username (“${result.recipient || ''}”) isn’t an email address, so the round-trip test can’t auto-address itself. Use a mailbox whose username is its email, or send a test email there manually and use “Test connection”.`,
|
||||
send_failed: `Could not send the test email${result.error ? `: ${result.error}` : ''}.`,
|
||||
not_received: 'The email was sent but did not arrive within 30s — possible delivery delay/greylisting. Check the Received emails tab in a moment.',
|
||||
};
|
||||
return res.status(result.reason === 'not_received' ? 504 : 400)
|
||||
.json({ error: map[result.reason] || 'Round-trip test failed.', sent: !!result.sent, recipient: result.recipient });
|
||||
} catch (error) {
|
||||
console.error('Round-trip test error:', error);
|
||||
res.status(422).json({ error: `Round-trip test failed (${error.message}) — check both SMTP and IMAP settings.` });
|
||||
}
|
||||
});
|
||||
|
||||
// Run the incoming-mail poller on demand (instead of waiting for the 60s loop)
|
||||
// so the admin can verify ingestion + see why nothing arrived. Respects the
|
||||
// incomingMail flag — a manual run still won't ingest when the feature is off.
|
||||
router.post('/incoming-config/poll', adminAuth, requirePermission('email.view'), async (req, res) => {
|
||||
try {
|
||||
const emailIntakeService = require('../services/emailIntakeService');
|
||||
const result = await emailIntakeService.pollOnce();
|
||||
res.json(result); // { processed } or { skipped: 'disabled'|'unconfigured'|'busy' }
|
||||
} catch (error) {
|
||||
console.error('Manual poll error:', error);
|
||||
res.status(422).json({ error: `Mailbox poll failed (${error.message}).` });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/received', adminAuth, requirePermission('email.view'), async (req, res) => {
|
||||
try {
|
||||
const page = Math.max(1, parseInt(req.query.page, 10) || 1);
|
||||
const pageSize = Math.min(100, Math.max(1, parseInt(req.query.pageSize, 10) || 25));
|
||||
const base = db('received_emails');
|
||||
const countRow = await base.clone().count({ c: '*' }).first();
|
||||
const total = parseInt(countRow?.c || 0, 10);
|
||||
const items = await base.clone().orderBy('received_at', 'desc').limit(pageSize).offset((page - 1) * pageSize);
|
||||
res.json({ items, pagination: { page, pageSize, total, totalPages: Math.ceil(total / pageSize) } });
|
||||
} catch (error) {
|
||||
console.error('Received emails fetch error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch received emails' });
|
||||
}
|
||||
});
|
||||
|
||||
// Test email configuration
|
||||
router.post('/test', adminAuth, requirePermission('email.send'), async (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* Admin Accounting routes.
|
||||
*
|
||||
* /inbound/* → Incoming invoices (external supplier invoices). Gated by the
|
||||
* `incomingInvoices` flag. Disposition, supplier-payment and
|
||||
* re-bill all act on the document itself.
|
||||
* / → Expenses (internal). Gated by the `expenses` flag. Create
|
||||
* accepts an optional proof upload (required when the accounting
|
||||
* setting says so).
|
||||
* /categories → expense categories. Gated by the `accounting` master.
|
||||
*
|
||||
* camelCase API; money in integer minor units.
|
||||
*/
|
||||
const express = require('express');
|
||||
const { body, param, query } = require('express-validator');
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const { createReadStream } = require('fs');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
|
||||
const { getStoragePath } = require('../config/storage');
|
||||
const { assertPathInside } = require('../utils/safePath');
|
||||
const { db } = require('../database/db');
|
||||
const expenseService = require('../services/expenseService');
|
||||
const expenseCategoriesService = require('../services/expenseCategoriesService');
|
||||
const rasterizeService = require('../services/rasterizeService');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const ALLOWED_MIME = ['application/pdf', 'image/jpeg', 'image/png'];
|
||||
|
||||
function diskUpload(subdir) {
|
||||
return multer({
|
||||
storage: multer.diskStorage({
|
||||
destination: async (_req, _file, cb) => {
|
||||
const dir = path.join(getStoragePath(), 'business-docs', subdir, String(new Date().getFullYear()));
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
cb(null, dir);
|
||||
},
|
||||
filename: (_req, file, cb) => cb(null, `${subdir.split('/').pop()}-${Date.now()}${path.extname(file.originalname) || ''}`),
|
||||
}),
|
||||
limits: { fileSize: 15 * 1024 * 1024 },
|
||||
fileFilter: (_req, file, cb) => (ALLOWED_MIME.includes(file.mimetype) ? cb(null, true) : cb(new Error('Only PDF, JPEG or PNG files are allowed'))),
|
||||
});
|
||||
}
|
||||
const inboundUpload = diskUpload('inbound');
|
||||
const proofUpload = diskUpload('expenses/proof');
|
||||
|
||||
// Shared cached feature gate (PR #622 nit 2) — replaces the former local copy.
|
||||
const { requireFeatureFlag } = require('../middleware/requireFeatureFlag');
|
||||
const requireIncoming = requireFeatureFlag('incomingInvoices', 'INCOMING_INVOICES_DISABLED');
|
||||
const requireExpenses = requireFeatureFlag('expenses', 'EXPENSES_DISABLED');
|
||||
const requireAccounting = requireFeatureFlag('accounting', 'ACCOUNTING_DISABLED');
|
||||
|
||||
router.use(adminAuth);
|
||||
|
||||
const toInt = (v) => { const n = parseInt(v, 10); return Number.isFinite(n) ? n : undefined; };
|
||||
|
||||
// ── Expense categories (accounting master) ──────────────────────────────────
|
||||
router.get('/categories', requireAccounting, requirePermission('accounting.view'), handleAsync(async (_req, res) =>
|
||||
successResponse(res, { items: await expenseCategoriesService.list() })));
|
||||
|
||||
router.post('/categories', requireAccounting, requirePermission('accounting.manage'),
|
||||
[body('name').isString().isLength({ min: 1, max: 128 }), body('color').optional({ nullable: true }).isString()],
|
||||
handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
return successResponse(res, { category: await expenseCategoriesService.create(req.body, req.admin.id) }, 201, 'Category created');
|
||||
}));
|
||||
|
||||
router.patch('/categories/:id', requireAccounting, requirePermission('accounting.manage'),
|
||||
[param('id').isInt({ min: 1 })],
|
||||
handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
return successResponse(res, { category: await expenseCategoriesService.update(toInt(req.params.id), req.body) });
|
||||
}));
|
||||
|
||||
router.delete('/categories/:id', requireAccounting, requirePermission('accounting.manage'),
|
||||
[param('id').isInt({ min: 1 })],
|
||||
handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
return successResponse(res, await expenseCategoriesService.remove(toInt(req.params.id)));
|
||||
}));
|
||||
|
||||
// ── Incoming invoices (external) ────────────────────────────────────────────
|
||||
router.post('/inbound', requireIncoming, requirePermission('accounting.manage'),
|
||||
inboundUpload.single('file'),
|
||||
[body('source').optional().isIn(['upload', 'camera', 'email', 'manual'])],
|
||||
handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
if (!req.file) return res.status(400).json({ error: 'No file uploaded', code: 'NO_FILE' });
|
||||
const document = await expenseService.recordInboundDocument({
|
||||
source: req.body.source || 'upload', filePath: req.file.path,
|
||||
originalFilename: req.file.originalname, mimeType: req.file.mimetype,
|
||||
}, req.admin.id);
|
||||
return successResponse(res, { document }, 201, 'Document captured');
|
||||
}));
|
||||
|
||||
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)); }));
|
||||
|
||||
router.get('/inbound/:id/file', requireIncoming, requirePermission('accounting.view'),
|
||||
[param('id').isInt({ min: 1 })],
|
||||
handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
const row = await db('inbound_documents').where({ id: toInt(req.params.id) }).first('file_path', 'mime_type');
|
||||
if (!row || !row.file_path) return res.status(404).json({ error: 'File not found', code: 'NO_FILE' });
|
||||
const safe = assertPathInside(row.file_path, [path.join(getStoragePath(), 'business-docs')]);
|
||||
const isPdf = (row.mime_type || '').includes('pdf');
|
||||
res.setHeader('Content-Type', row.mime_type || 'application/octet-stream');
|
||||
res.setHeader('Content-Disposition', isPdf ? 'attachment' : 'inline');
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
if (!isPdf) res.setHeader('Content-Security-Policy', "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'");
|
||||
createReadStream(safe).pipe(res);
|
||||
}));
|
||||
|
||||
router.get('/inbound/:id/page/:n', requireIncoming, requirePermission('accounting.view'),
|
||||
[param('id').isInt({ min: 1 }), param('n').isInt({ min: 1 })],
|
||||
handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
const id = toInt(req.params.id);
|
||||
const row = await db('inbound_documents').where({ id }).first('file_path', 'mime_type', 'page_count');
|
||||
if (!row || !row.file_path) return res.status(404).json({ error: 'File not found', code: 'NO_FILE' });
|
||||
if (!(row.mime_type || '').includes('pdf')) return res.status(415).json({ error: 'Not a PDF', code: 'NOT_PDF' });
|
||||
const page = Math.min(Math.max(1, toInt(req.params.n)), row.page_count || 1);
|
||||
const srcPdf = assertPathInside(row.file_path, [path.join(getStoragePath(), 'business-docs')]);
|
||||
const pngPath = await rasterizeService.getRenderedPagePath(id, srcPdf, page);
|
||||
const safePng = assertPathInside(pngPath, [path.join(getStoragePath(), 'business-docs')]);
|
||||
res.setHeader('Content-Type', 'image/png');
|
||||
res.setHeader('Content-Disposition', 'inline');
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.setHeader('Content-Security-Policy', "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'");
|
||||
createReadStream(safePng).pipe(res);
|
||||
}));
|
||||
|
||||
router.get('/inbound/:id', requireIncoming, requirePermission('accounting.view'),
|
||||
[param('id').isInt({ min: 1 })],
|
||||
handleAsync(async (req, res) => { validateRequest(req); return successResponse(res, { document: await expenseService.getInbound(toInt(req.params.id)) }); }));
|
||||
|
||||
router.patch('/inbound/:id', requireIncoming, requirePermission('accounting.manage'),
|
||||
[param('id').isInt({ min: 1 })],
|
||||
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)],
|
||||
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'),
|
||||
[param('id').isInt({ min: 1 }), body('customerAccountId').isInt({ min: 1 }),
|
||||
body('eventId').optional({ nullable: true }).isInt({ min: 1 }), body('contractId').optional({ nullable: true }).isInt({ min: 1 }),
|
||||
body('markupType').optional().isIn(expenseService.MARKUP_TYPES)],
|
||||
handleAsync(async (req, res) => { validateRequest(req); return successResponse(res, await expenseService.rebillInbound(toInt(req.params.id), req.body, req.admin.id), 201, 'Re-billed'); }));
|
||||
|
||||
router.post('/inbound/:id/supplier-payment', requireIncoming, requirePermission('accounting.manage'),
|
||||
[param('id').isInt({ min: 1 }), body('paid').isBoolean(), body('paymentMethod').optional({ nullable: true }).isIn(expenseService.PAYMENT_METHODS)],
|
||||
handleAsync(async (req, res) => { validateRequest(req); return successResponse(res, { document: await expenseService.markInboundSupplierPayment(toInt(req.params.id), req.body, req.admin.id) }); }));
|
||||
|
||||
// ── Expenses (internal) ─────────────────────────────────────────────────────
|
||||
router.get('/', requireExpenses, requirePermission('accounting.view'),
|
||||
[query('kind').optional().isIn(expenseService.EXPENSE_KINDS), query('categoryId').optional().isInt({ min: 1 }),
|
||||
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.listExpenses(req.query)); }));
|
||||
|
||||
router.post('/', requireExpenses, requirePermission('accounting.manage'),
|
||||
proofUpload.single('proof'),
|
||||
[body('kind').optional().isIn(expenseService.EXPENSE_KINDS)],
|
||||
handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
const b = req.body;
|
||||
const payload = {
|
||||
kind: b.kind || 'amount',
|
||||
quantity: b.quantity !== undefined && b.quantity !== '' ? Number(b.quantity) : undefined,
|
||||
rateMinor: toInt(b.rateMinor),
|
||||
chfAmountMinor: toInt(b.chfAmountMinor),
|
||||
eventId: toInt(b.eventId) || null,
|
||||
categoryId: toInt(b.categoryId) || null,
|
||||
supplierName: b.supplierName || null,
|
||||
description: b.description || null,
|
||||
taxTreatment: b.taxTreatment,
|
||||
};
|
||||
const expense = await expenseService.createExpense(payload, req.admin.id, { receiptPath: req.file ? req.file.path : null });
|
||||
return successResponse(res, { expense }, 201, 'Expense created');
|
||||
}));
|
||||
|
||||
router.get('/:id/proof', requireExpenses, requirePermission('accounting.view'),
|
||||
[param('id').isInt({ min: 1 })],
|
||||
handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
const row = await db('expenses').where({ id: toInt(req.params.id) }).first('receipt_path');
|
||||
if (!row || !row.receipt_path) return res.status(404).json({ error: 'No proof', code: 'NO_PROOF' });
|
||||
const safe = assertPathInside(row.receipt_path, [path.join(getStoragePath(), 'business-docs')]);
|
||||
const isPdf = safe.toLowerCase().endsWith('.pdf');
|
||||
res.setHeader('Content-Type', isPdf ? 'application/pdf' : 'application/octet-stream');
|
||||
res.setHeader('Content-Disposition', isPdf ? 'attachment' : 'inline');
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
if (!isPdf) res.setHeader('Content-Security-Policy', "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'");
|
||||
createReadStream(safe).pipe(res);
|
||||
}));
|
||||
|
||||
router.get('/:id', requireExpenses, requirePermission('accounting.view'),
|
||||
[param('id').isInt({ min: 1 })],
|
||||
handleAsync(async (req, res) => { validateRequest(req); return successResponse(res, { expense: await expenseService.getExpense(toInt(req.params.id)) }); }));
|
||||
|
||||
router.patch('/:id', requireExpenses, requirePermission('accounting.manage'),
|
||||
proofUpload.single('proof'),
|
||||
[param('id').isInt({ min: 1 })],
|
||||
handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
const expense = await expenseService.updateExpense(toInt(req.params.id), req.body, req.admin.id, { receiptPath: req.file ? req.file.path : null });
|
||||
return successResponse(res, { expense });
|
||||
}));
|
||||
|
||||
// Add an expense onto a client invoice -> marks it invoiced (locks editing).
|
||||
router.post('/:id/invoice', requireExpenses, requirePermission('accounting.manage'),
|
||||
[param('id').isInt({ min: 1 }), body('customerAccountId').isInt({ min: 1 }),
|
||||
body('eventId').optional({ nullable: true }).isInt({ min: 1 }), body('contractId').optional({ nullable: true }).isInt({ min: 1 }),
|
||||
body('markupType').optional().isIn(expenseService.MARKUP_TYPES)],
|
||||
handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
return successResponse(res, await expenseService.rebillExpense(toInt(req.params.id), req.body, req.admin.id), 201, 'Expense invoiced');
|
||||
}));
|
||||
|
||||
// Mark an expense paid / settled (manual).
|
||||
router.post('/:id/paid', requireExpenses, requirePermission('accounting.manage'),
|
||||
[param('id').isInt({ min: 1 }), body('paid').isBoolean(), body('paymentMethod').optional({ nullable: true }).isIn(expenseService.PAYMENT_METHODS)],
|
||||
handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
const expense = await expenseService.markExpensePaid(toInt(req.params.id), req.body, req.admin.id);
|
||||
return successResponse(res, { expense });
|
||||
}));
|
||||
|
||||
module.exports = router;
|
||||
@@ -18,6 +18,7 @@ const router = express.Router();
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { invalidateFeatureFlagCache } = require('../middleware/requireFeatureFlag');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
// Canonical flag list. Keep in sync with frontend
|
||||
@@ -25,6 +26,9 @@ const logger = require('../utils/logger');
|
||||
const KNOWN_FLAGS = [
|
||||
'galleries',
|
||||
'reminderEmails',
|
||||
// Incoming mail (migration 128) — IMAP polling of a dedicated mailbox into
|
||||
// the incoming-invoices inbox. Standalone toggle.
|
||||
'incomingMail',
|
||||
'calendar',
|
||||
'calendarBooking',
|
||||
'quotes',
|
||||
@@ -45,10 +49,9 @@ const KNOWN_FLAGS = [
|
||||
// payment-check email flow without waiting 30 days, etc.).
|
||||
// Strictly opt-in.
|
||||
'crmDevelopment',
|
||||
// Tax / Steuer report sub-tab under Clients. Independent toggle so
|
||||
// admins who use Bills but don't need the tax export (or aren't
|
||||
// ready to enable it yet) can leave it off. Forced off when `bills`
|
||||
// is off (no invoices → nothing to report).
|
||||
// Tax / Steuer report — an Accounting sub-feature (moved out of CRM).
|
||||
// Independent of `bills`; forced off when the `accounting` master is off
|
||||
// (see applyDependencyRules below).
|
||||
'taxReport',
|
||||
// Hours logging (migration 129). Master switch for the per-customer
|
||||
// Hours card + the auto-append into monthly draft / "Bill these
|
||||
@@ -62,6 +65,17 @@ const KNOWN_FLAGS = [
|
||||
// upload). Seeded block bodies are EXAMPLES ONLY; admins must have a
|
||||
// lawyer review before sending. See docs/crm-disclaimers.md.
|
||||
'contracts',
|
||||
// Accounting (migration 122). Top-level Accounting area — inbound
|
||||
// supplier invoices, expenses + re-bill, and the tax report (which
|
||||
// relocates here from CRM when this is on). Strictly opt-in.
|
||||
'accounting',
|
||||
// Incoming invoices (migration 124) — external supplier-invoice capture +
|
||||
// re-bill. Accounting sub-feature; forced off when the `accounting` master
|
||||
// is off.
|
||||
'incomingInvoices',
|
||||
// Expenses (migration 127) — internal expenses (mileage / per-diem / cash).
|
||||
// Separate Accounting sub-feature; forced off when `accounting` is off.
|
||||
'expenses',
|
||||
// Projects (migration 120). Admin-only grouping layer above events +
|
||||
// the Project Overview cockpit ("book to project" hours control, 360°
|
||||
// rollup feed). Lights up the Clients section. Customers never see it.
|
||||
@@ -72,6 +86,7 @@ const KNOWN_FLAGS = [
|
||||
// new release that hasn't run its migration yet on this instance).
|
||||
const DEFAULT_FLAGS = {
|
||||
galleries: true,
|
||||
incomingMail: false,
|
||||
// F.3 — reminderEmails is a placeholder card in the Features tab
|
||||
// (lockedReason: NOT_YET_AVAILABLE). Default FALSE so it matches
|
||||
// the locked-but-off visual state of messaging / calendarBooking
|
||||
@@ -88,6 +103,9 @@ const DEFAULT_FLAGS = {
|
||||
taxReport: false,
|
||||
hoursLogging: false,
|
||||
contracts: false,
|
||||
accounting: false,
|
||||
incomingInvoices: false,
|
||||
expenses: false,
|
||||
projects: false,
|
||||
};
|
||||
|
||||
@@ -109,10 +127,14 @@ function applyDependencyRules(flags) {
|
||||
// Sub-features can't outlive their parents.
|
||||
if (out.quotes === false) out.bills = false;
|
||||
if (out.calendar === false) out.calendarBooking = false;
|
||||
// Tax report only makes sense when bills are on — turning bills off
|
||||
// implicitly turns the tax report off too. Admins enabling tax
|
||||
// report must first enable bills.
|
||||
if (out.bills === false) out.taxReport = false;
|
||||
// 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).
|
||||
if (out.accounting === false) {
|
||||
out.taxReport = false;
|
||||
out.incomingInvoices = false;
|
||||
out.expenses = false;
|
||||
}
|
||||
// Clients parent flag is DERIVED from its children. Admins don't
|
||||
// toggle it directly in the Features tab — they enable a specific
|
||||
// sub-feature (Accounts today; Calendar/Quotes/Bills/Messaging
|
||||
@@ -125,9 +147,10 @@ function applyDependencyRules(flags) {
|
||||
|| out.crmDevelopment
|
||||
|| out.quotes
|
||||
|| out.bills
|
||||
|| out.taxReport
|
||||
|| out.hoursLogging
|
||||
|| out.contracts
|
||||
// NOTE: taxReport intentionally removed — Tax export moved to the
|
||||
// Accounting section (its own master), no longer a CRM sub-feature.
|
||||
// Migration 120 — admin-only Project Overview cockpit lives under Clients.
|
||||
|| out.projects
|
||||
// Migration 137 — admin calendar lights up the Clients section.
|
||||
@@ -204,6 +227,10 @@ router.put('/', adminAuth, requirePermission('settings.edit'), async (req, res)
|
||||
}
|
||||
});
|
||||
|
||||
// Drop the requireFeatureFlag middleware's short-TTL cache so a toggle takes
|
||||
// effect immediately instead of after ≤10s.
|
||||
invalidateFeatureFlagCache();
|
||||
|
||||
await logActivity(
|
||||
'feature_flags_updated',
|
||||
{ changed, actor: adminUsername },
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Admin → Ledger (Accounting Layer A) routes. Mounted at /api/admin/ledger.
|
||||
*
|
||||
* /accounts CRUD chart of accounts (Swiss/LI KMU-Kontenrahmen)
|
||||
* /vat-codes CRUD MWST codes
|
||||
* /mappings GET/PATCH category→account + default-account/VAT settings
|
||||
* /export GET Treuhänder collective-journal CSV (generic|banana|bexio)
|
||||
*
|
||||
* Gated by the `accounting` master flag (all routes, incl. /export). Uses the
|
||||
* `accounting.*` permissions. Output is a GUIDELINE — the UI carries the
|
||||
* Treuhänder caveat.
|
||||
*/
|
||||
const express = require('express');
|
||||
const { body, param, query } = require('express-validator');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
|
||||
const { db } = require('../database/db');
|
||||
const ledgerService = require('../services/ledgerService');
|
||||
|
||||
const router = express.Router();
|
||||
const toInt = (v) => { const n = parseInt(v, 10); return Number.isFinite(n) ? n : undefined; };
|
||||
|
||||
// Shared cached feature gate (PR #622 nit 2) — replaces the former local copy.
|
||||
const { requireFeatureFlag } = require('../middleware/requireFeatureFlag');
|
||||
const requireAccounting = requireFeatureFlag('accounting', 'ACCOUNTING_DISABLED');
|
||||
|
||||
router.use(adminAuth);
|
||||
router.use(requireAccounting);
|
||||
|
||||
// ── chart of accounts ────────────────────────────────────────────────
|
||||
router.get('/accounts', requirePermission('accounting.view'), handleAsync(async (_req, res) =>
|
||||
successResponse(res, { items: await ledgerService.listAccounts() })));
|
||||
|
||||
router.post('/accounts', requirePermission('accounting.manage'),
|
||||
[body('number').isString().isLength({ min: 1, max: 16 }), body('name').isString().isLength({ min: 1, max: 200 }),
|
||||
body('type').isIn(ledgerService.ACCOUNT_TYPES)],
|
||||
handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
return successResponse(res, { account: await ledgerService.createAccount(req.body) }, 201, 'Account created');
|
||||
}));
|
||||
|
||||
router.patch('/accounts/:id', requirePermission('accounting.manage'),
|
||||
[param('id').isInt({ min: 1 }), body('type').optional().isIn(ledgerService.ACCOUNT_TYPES)],
|
||||
handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
return successResponse(res, { account: await ledgerService.updateAccount(toInt(req.params.id), req.body) });
|
||||
}));
|
||||
|
||||
router.delete('/accounts/:id', requirePermission('accounting.manage'),
|
||||
[param('id').isInt({ min: 1 })],
|
||||
handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
return successResponse(res, await ledgerService.deleteAccount(toInt(req.params.id)));
|
||||
}));
|
||||
|
||||
// ── VAT codes ────────────────────────────────────────────────────────
|
||||
router.get('/vat-codes', requirePermission('accounting.view'), handleAsync(async (_req, res) =>
|
||||
successResponse(res, { items: await ledgerService.listVatCodes() })));
|
||||
|
||||
router.post('/vat-codes', requirePermission('accounting.manage'),
|
||||
[body('code').isString().isLength({ min: 1, max: 16 }), body('name').isString().isLength({ min: 1, max: 200 }),
|
||||
body('rate').optional().isFloat({ min: 0 }), body('direction').isIn(ledgerService.VAT_DIRECTIONS),
|
||||
body('accountId').optional({ nullable: true }).isInt({ min: 1 })],
|
||||
handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
return successResponse(res, { vatCode: await ledgerService.createVatCode(req.body) }, 201, 'VAT code created');
|
||||
}));
|
||||
|
||||
router.patch('/vat-codes/:id', requirePermission('accounting.manage'),
|
||||
[param('id').isInt({ min: 1 }), body('direction').optional().isIn(ledgerService.VAT_DIRECTIONS),
|
||||
body('rate').optional().isFloat({ min: 0 }), body('accountId').optional({ nullable: true }).isInt({ min: 1 })],
|
||||
handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
return successResponse(res, { vatCode: await ledgerService.updateVatCode(toInt(req.params.id), req.body) });
|
||||
}));
|
||||
|
||||
router.delete('/vat-codes/:id', requirePermission('accounting.manage'),
|
||||
[param('id').isInt({ min: 1 })],
|
||||
handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
return successResponse(res, await ledgerService.deleteVatCode(toInt(req.params.id)));
|
||||
}));
|
||||
|
||||
// ── mappings (category→account + default accounts / VAT maps) ─────────
|
||||
router.get('/mappings', requirePermission('accounting.view'), handleAsync(async (_req, res) =>
|
||||
successResponse(res, await ledgerService.getMappings())));
|
||||
|
||||
router.patch('/mappings/category/:id', requirePermission('accounting.manage'),
|
||||
[param('id').isInt({ min: 1 }), body('ledgerAccountId').optional({ nullable: true }).isInt({ min: 1 })],
|
||||
handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
return successResponse(res, { category: await ledgerService.setCategoryAccount(toInt(req.params.id), req.body.ledgerAccountId ?? null) });
|
||||
}));
|
||||
|
||||
router.patch('/mappings/settings', requirePermission('accounting.manage'), handleAsync(async (req, res) => {
|
||||
return successResponse(res, await ledgerService.updateSettings(req.body || {}));
|
||||
}));
|
||||
|
||||
// ── Treuhänder export ────────────────────────────────────────────────
|
||||
// Gated by the router-level `accounting` flag only — the export lives on the
|
||||
// Tax page now but is an accounting-layer feature (needs the chart-of-accounts
|
||||
// mapping), so it no longer requires the `taxReport` sub-flag.
|
||||
router.get('/export', requirePermission('bills.view'),
|
||||
[query('from').matches(/^\d{4}-\d{2}-\d{2}$/), query('to').matches(/^\d{4}-\d{2}-\d{2}$/),
|
||||
query('currency').matches(/^[A-Za-z]{3}$/), query('format').optional().isIn(ledgerService.EXPORT_FORMATS)],
|
||||
handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
const { content, filename, contentType } = await ledgerService.exportPostings({
|
||||
from: req.query.from, to: req.query.to,
|
||||
currency: String(req.query.currency).toUpperCase(),
|
||||
format: req.query.format || 'generic',
|
||||
});
|
||||
res.setHeader('Content-Type', contentType);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
// UTF-8 BOM (EF BB BF) so Banana / Excel detect the encoding — without it
|
||||
// the file is read as the local charset and "·" / umlauts become mojibake
|
||||
// ("·"). Mirrors the tax-report CSV route.
|
||||
const bom = Buffer.from([0xEF, 0xBB, 0xBF]);
|
||||
const body = Buffer.concat([bom, Buffer.from(content, 'utf8')]);
|
||||
res.setHeader('Content-Length', String(body.length));
|
||||
return res.end(body);
|
||||
}));
|
||||
|
||||
module.exports = router;
|
||||
@@ -19,6 +19,7 @@ const {
|
||||
getRawPublicSiteSettings,
|
||||
} = require('../services/publicSiteService');
|
||||
const { sanitizeCss } = require('../utils/cssSanitizer');
|
||||
const { upsertAppSetting } = require('../utils/appSettings');
|
||||
const { clearShareLinkSettingsCache } = require('../services/shareLinkService');
|
||||
const { resetSecurityConfigCache } = require('../utils/authSecurity');
|
||||
const router = express.Router();
|
||||
@@ -211,16 +212,7 @@ router.put('/customer-surface', adminAuth, requirePermission('settings.edit'), a
|
||||
}
|
||||
|
||||
for (const u of updates) {
|
||||
const existing = await db('app_settings').where('setting_key', u.setting_key).first();
|
||||
if (existing) {
|
||||
await db('app_settings').where('setting_key', u.setting_key).update({
|
||||
setting_value: u.setting_value,
|
||||
setting_type: u.setting_type,
|
||||
updated_at: new Date(),
|
||||
});
|
||||
} else {
|
||||
await db('app_settings').insert({ ...u, created_at: new Date(), updated_at: new Date() });
|
||||
}
|
||||
await upsertAppSetting(u.setting_key, u.setting_value, u.setting_type);
|
||||
}
|
||||
|
||||
// Clear the public-site cache so any consumer relying on it
|
||||
@@ -234,6 +226,60 @@ router.put('/customer-surface', adminAuth, requirePermission('settings.edit'), a
|
||||
}
|
||||
});
|
||||
|
||||
// Accounting settings (km rate, per-diem rate, require-proof). Read via the
|
||||
// generic GET /:type ('accounting'); this is the typed write. Rates are
|
||||
// integer minor units; verify legal/tax guidance with a Treuhaender.
|
||||
router.put('/accounting', adminAuth, requirePermission('settings.edit'), async (req, res) => {
|
||||
try {
|
||||
const updates = [];
|
||||
const setInt = (key) => {
|
||||
if (Object.prototype.hasOwnProperty.call(req.body, key)) {
|
||||
const n = Math.max(0, Math.round(Number(req.body[key]) || 0));
|
||||
updates.push({ setting_key: key, setting_value: JSON.stringify(n), setting_type: 'accounting' });
|
||||
}
|
||||
};
|
||||
setInt('accounting_km_rate_minor');
|
||||
setInt('accounting_per_diem_rate_minor');
|
||||
if (Object.prototype.hasOwnProperty.call(req.body, 'accounting_require_proof')) {
|
||||
updates.push({
|
||||
setting_key: 'accounting_require_proof',
|
||||
setting_value: JSON.stringify(!!req.body.accounting_require_proof),
|
||||
setting_type: 'accounting',
|
||||
});
|
||||
}
|
||||
// VAT registration + reclaim. `registered` drives whether output VAT applies
|
||||
// + whether input VAT is deductible; `reclaim_countries` = the ISO-2 list of
|
||||
// countries whose input VAT can be reclaimed (drives cost tax-treatment +
|
||||
// the report's VAT-payable).
|
||||
if (Object.prototype.hasOwnProperty.call(req.body, 'accounting_vat_registered')) {
|
||||
updates.push({
|
||||
setting_key: 'accounting_vat_registered',
|
||||
setting_value: JSON.stringify(!!req.body.accounting_vat_registered),
|
||||
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
|
||||
.map((c) => String(c || '').toUpperCase().trim())
|
||||
.filter((c) => /^[A-Z]{2}$/.test(c))
|
||||
: [];
|
||||
updates.push({
|
||||
setting_key: 'accounting_vat_reclaim_countries',
|
||||
setting_value: JSON.stringify(arr),
|
||||
setting_type: 'accounting',
|
||||
});
|
||||
}
|
||||
for (const u of updates) {
|
||||
await upsertAppSetting(u.setting_key, u.setting_value, u.setting_type);
|
||||
}
|
||||
res.json({ message: 'Accounting settings updated', updated: updates.map((u) => u.setting_key) });
|
||||
} catch (error) {
|
||||
console.error('Accounting settings save error:', error);
|
||||
res.status(500).json({ error: 'Failed to save accounting settings' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get settings by type
|
||||
router.get('/:type', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -8,9 +8,11 @@
|
||||
* GET /pdf → landscape A4 PDF, Content-Disposition: attachment
|
||||
* GET /csv → RFC-4180 CSV, Content-Disposition: attachment
|
||||
*
|
||||
* Reuses the existing `bills` feature flag + `bills.view` permission.
|
||||
* Tax data is just a different lens on invoice data — admins who can
|
||||
* read invoices can read the tax report; no new RBAC surface needed.
|
||||
* Gated by the Accounting master flag + the `taxReport` sub-flag
|
||||
* (independent of `bills` — Tax export was moved out of CRM into
|
||||
* Accounting). Still uses the `bills.view` permission: tax data is just
|
||||
* a different lens on invoice data, so admins who can read invoices can
|
||||
* read the tax report; no new RBAC surface needed.
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
@@ -23,19 +25,19 @@ const { db } = require('../database/db');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// The tax report has its own dedicated flag (taxReport) — independent
|
||||
// from `bills` so admins can leave it off until they actually need to
|
||||
// run the export. The frontend mirrors the dependency rule (bills off
|
||||
// → taxReport off) but we re-check both server-side for defence in
|
||||
// depth.
|
||||
// The tax report now lives under the Accounting master flag and has its
|
||||
// own dedicated `taxReport` sub-flag — it is INDEPENDENT of `bills`
|
||||
// (Tax export was moved permanently out of CRM into Accounting). The
|
||||
// frontend mirrors the dependency rule (accounting off → taxReport off)
|
||||
// but we re-check both server-side for defence in depth.
|
||||
async function requireTaxReportFlag(req, res, next) {
|
||||
try {
|
||||
const rows = await db('feature_flags').whereIn('key', ['bills', 'taxReport']).select('key', 'value');
|
||||
const rows = await db('feature_flags').whereIn('key', ['accounting', 'taxReport']).select('key', 'value');
|
||||
const isOn = (row) => row && (row.value === true || row.value === 1 || row.value === '1');
|
||||
const bills = isOn(rows.find((r) => r.key === 'bills'));
|
||||
const accounting = isOn(rows.find((r) => r.key === 'accounting'));
|
||||
const taxReport = isOn(rows.find((r) => r.key === 'taxReport'));
|
||||
if (!bills) {
|
||||
return res.status(403).json({ error: 'Bills feature is disabled', code: 'BILLS_DISABLED' });
|
||||
if (!accounting) {
|
||||
return res.status(403).json({ error: 'Accounting feature is disabled', code: 'ACCOUNTING_DISABLED' });
|
||||
}
|
||||
if (!taxReport) {
|
||||
return res.status(403).json({ error: 'Tax report feature is disabled', code: 'TAX_REPORT_DISABLED' });
|
||||
@@ -63,11 +65,15 @@ const QUERY_VALIDATORS = [
|
||||
];
|
||||
|
||||
function parseParams(req) {
|
||||
// scope (export only): all | income | cost. Anything else → 'all'.
|
||||
const rawScope = String(req.query.scope || 'all');
|
||||
const scope = ['all', 'income', 'cost'].includes(rawScope) ? rawScope : 'all';
|
||||
return {
|
||||
from: req.query.from,
|
||||
to: req.query.to,
|
||||
currency: String(req.query.currency || '').toUpperCase(),
|
||||
locale: req.query.locale || undefined,
|
||||
scope,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -92,7 +98,8 @@ router.get(
|
||||
validateRequest(req);
|
||||
const params = parseParams(req);
|
||||
const buffer = await taxReportService.renderTaxReportPdf(params);
|
||||
const filename = `tax_report_${params.from}_to_${params.to}_${params.currency}.pdf`;
|
||||
const scopeTag = params.scope && params.scope !== 'all' ? `${params.scope}_` : '';
|
||||
const filename = `tax_report_${scopeTag}${params.from}_to_${params.to}_${params.currency}.pdf`;
|
||||
res.set('Content-Type', 'application/pdf');
|
||||
res.set('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
res.set('Content-Length', String(buffer.length));
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Read-only VAT-code registry for the invoice / quote editors.
|
||||
*
|
||||
* Mounted at /api/admin/vat-codes. UN-gated by the `accounting` flag on purpose:
|
||||
* invoices need their preset VAT codes even when the accounting layer is off, so
|
||||
* the editor must be able to read the list regardless. Any authenticated admin
|
||||
* may read the (innocuous) tax-code list. MANAGEMENT (create/update/delete) stays
|
||||
* in the accounting-gated /api/admin/ledger routes — this is read-only.
|
||||
*
|
||||
* GET / [?direction=output|input] → { items: [{ id, code, name, rate, direction }] }
|
||||
*/
|
||||
const express = require('express');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { handleAsync, successResponse } = require('../utils/routeHelpers');
|
||||
const ledgerService = require('../services/ledgerService');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/', adminAuth, handleAsync(async (req, res) => {
|
||||
const items = await ledgerService.listVatCodes();
|
||||
const active = items.filter((v) => v.active !== false);
|
||||
const { direction } = req.query;
|
||||
const filtered = (direction === 'output' || direction === 'input')
|
||||
? active.filter((v) => v.direction === direction)
|
||||
: active;
|
||||
return successResponse(res, {
|
||||
items: filtered.map((v) => ({
|
||||
id: v.id, code: v.code, name: v.name, rate: Number(v.rate) || 0, direction: v.direction,
|
||||
})),
|
||||
});
|
||||
}));
|
||||
|
||||
module.exports = router;
|
||||
@@ -24,6 +24,19 @@ const { customerAuth } = require('../middleware/customerAuth');
|
||||
const { setGalleryAuthCookies } = require('../utils/tokenUtils');
|
||||
const customerAccountsService = require('../services/customerAccountsService');
|
||||
|
||||
// Gate a customer-facing route on BOTH the global master flag AND the
|
||||
// per-customer override — getEffectiveFeaturesForCustomer combines them, so an
|
||||
// admin disabling e.g. Bills globally is honoured even when feature_bills=true
|
||||
// on the row. Sends the 403 and returns false on denial; true if allowed.
|
||||
async function customerFeatureAllowed(req, res, featureKey, label) {
|
||||
const eff = await customerAccountsService.getEffectiveFeaturesForCustomer(req.customer.id);
|
||||
if (!eff || !eff[featureKey]) {
|
||||
res.status(403).json({ error: `${label} are disabled for this account`, code: 'CUSTOMER_FEATURE_DISABLED' });
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Customer-side password policy mirrors the one in customerAuth.js — kept
|
||||
* deliberately simple (8 chars, one uppercase, one digit) since a customer
|
||||
@@ -380,11 +393,8 @@ router.post('/profile/password', [
|
||||
router.get('/quotes', customerAuth, async (req, res) => {
|
||||
try {
|
||||
const { db: dbi } = require('../database/db');
|
||||
// Customer-feature gate. is_active is enforced by customerAuth.
|
||||
const customer = await dbi('customer_accounts').where({ id: req.customer.id }).first();
|
||||
if (!customer || customer.feature_quotes === false || customer.feature_quotes === 0) {
|
||||
return res.status(403).json({ error: 'Quotes are disabled for this account', code: 'CUSTOMER_FEATURE_DISABLED' });
|
||||
}
|
||||
// Customer-feature gate — master flag AND per-customer override.
|
||||
if (!(await customerFeatureAllowed(req, res, 'quotes', 'Quotes'))) return;
|
||||
const rows = await dbi('quotes')
|
||||
.where({ customer_account_id: req.customer.id })
|
||||
// Hide drafts — they're admin scratch work; nothing has been
|
||||
@@ -459,10 +469,8 @@ router.get('/quotes', customerAuth, async (req, res) => {
|
||||
router.get('/invoices', customerAuth, async (req, res) => {
|
||||
try {
|
||||
const { db: dbi } = require('../database/db');
|
||||
const customer = await dbi('customer_accounts').where({ id: req.customer.id }).first();
|
||||
if (!customer || customer.feature_bills === false || customer.feature_bills === 0) {
|
||||
return res.status(403).json({ error: 'Invoices are disabled for this account', code: 'CUSTOMER_FEATURE_DISABLED' });
|
||||
}
|
||||
// Customer-feature gate — master flag AND per-customer override.
|
||||
if (!(await customerFeatureAllowed(req, res, 'bills', 'Invoices'))) return;
|
||||
// Visibility rules for the customer-facing list:
|
||||
// - Hide `scheduled` always (drafts the admin is still tweaking).
|
||||
// - Show `sent`, `overdue`, `paid` always (the customer's
|
||||
@@ -550,11 +558,9 @@ router.get('/invoices', customerAuth, async (req, res) => {
|
||||
*/
|
||||
router.get('/quotes/:id/pdf', customerAuth, async (req, res) => {
|
||||
try {
|
||||
// Feature-gate identically to /quotes (list endpoint).
|
||||
if (req.customer.feature_quotes === false || req.customer.feature_quotes === 0 || req.customer.feature_quotes === '0') {
|
||||
return res.status(403).json({ error: 'Quotes are disabled for this account' });
|
||||
}
|
||||
const { db: dbi } = require('../database/db');
|
||||
// Feature-gate — master flag AND per-customer override.
|
||||
if (!(await customerFeatureAllowed(req, res, 'quotes', 'Quotes'))) return;
|
||||
const quote = await dbi('quotes')
|
||||
.where({ id: parseInt(req.params.id, 10), customer_account_id: req.customer.id })
|
||||
.first();
|
||||
@@ -584,6 +590,8 @@ router.get('/quotes/:id/pdf', customerAuth, async (req, res) => {
|
||||
router.get('/invoices/:id/pdf', customerAuth, async (req, res) => {
|
||||
try {
|
||||
const { db: dbi } = require('../database/db');
|
||||
// Feature-gate — master flag AND per-customer override.
|
||||
if (!(await customerFeatureAllowed(req, res, 'bills', 'Invoices'))) return;
|
||||
const invoice = await dbi('invoices')
|
||||
.where({ id: parseInt(req.params.id, 10), customer_account_id: req.customer.id })
|
||||
.first();
|
||||
@@ -623,6 +631,8 @@ router.get('/contracts', customerAuth, async (req, res) => {
|
||||
// Feature not migrated on this install yet.
|
||||
return res.json({ contracts: [] });
|
||||
}
|
||||
// Contracts gate — master flag AND per-customer override (migration 131).
|
||||
if (!(await customerFeatureAllowed(req, res, 'contracts', 'Contracts'))) return;
|
||||
const rows = await dbi('contracts')
|
||||
.where({ customer_account_id: req.customer.id })
|
||||
.whereNotIn('status', ['draft'])
|
||||
@@ -680,6 +690,8 @@ router.get('/contracts/:id/pdf', customerAuth, async (req, res) => {
|
||||
if (!(await dbi.schema.hasTable('contracts'))) {
|
||||
return res.status(404).json({ error: 'Contract not found' });
|
||||
}
|
||||
// Contracts gate — master flag AND per-customer override.
|
||||
if (!(await customerFeatureAllowed(req, res, 'contracts', 'Contracts'))) return;
|
||||
const contract = await dbi('contracts')
|
||||
.where({ id: parseInt(req.params.id, 10), customer_account_id: req.customer.id })
|
||||
.first();
|
||||
|
||||
@@ -480,6 +480,7 @@ async function listCustomers({ search } = {}) {
|
||||
'customer_accounts.feature_quotes',
|
||||
'customer_accounts.feature_bills',
|
||||
'customer_accounts.feature_hours_logging',
|
||||
'customer_accounts.feature_contracts',
|
||||
'customer_accounts.hourly_rate_minor',
|
||||
'customer_accounts.last_login',
|
||||
'customer_accounts.created_at',
|
||||
@@ -548,6 +549,9 @@ async function updateCustomer(id, updates, updatedByAdminId) {
|
||||
// Per-customer feature flags (#354 follow-up). Booleans below are
|
||||
// coerced via formatBoolean for SQLite compatibility.
|
||||
'feature_calendar', 'feature_quotes', 'feature_bills', 'feature_hours_logging',
|
||||
// Per-customer contracts override (migration 131). Defaults TRUE so
|
||||
// existing customers keep their Contracts tab.
|
||||
'feature_contracts',
|
||||
// CRM billing cadence (migration 102). 'per_event' (default) keeps
|
||||
// each invoice firing on its own schedule; monthly/quarterly snap
|
||||
// every scheduled invoice to billing_cycle_day of the next period.
|
||||
@@ -570,6 +574,7 @@ async function updateCustomer(id, updates, updatedByAdminId) {
|
||||
} else if (
|
||||
f === 'feature_calendar' || f === 'feature_quotes'
|
||||
|| f === 'feature_bills' || f === 'feature_hours_logging'
|
||||
|| f === 'feature_contracts'
|
||||
|| f === 'skonto_disabled'
|
||||
) {
|
||||
allowed[f] = formatBoolean(updates[f]);
|
||||
@@ -1267,9 +1272,9 @@ async function getEffectiveFeaturesForCustomer(customerOrId) {
|
||||
// for hours, so we skip the third gate the bills/quotes use.
|
||||
const hoursMaster = await db('feature_flags').where({ key: 'hoursLogging' }).first();
|
||||
const hoursLoggingMaster = hoursMaster ? Boolean(hoursMaster.value) : true;
|
||||
// Contracts (migration 130): no per-customer flag, just the global
|
||||
// feature_flags row. When on, every customer with an active account
|
||||
// sees the Contracts tab on their portal.
|
||||
// Contracts: global feature_flags row AND the per-customer override
|
||||
// (migration 131). feature_contracts defaults TRUE, so existing customers
|
||||
// keep their Contracts tab; an admin can hide it per customer.
|
||||
const contractsMaster = await db('feature_flags').where({ key: 'contracts' }).first();
|
||||
const contractsEnabled = contractsMaster ? Boolean(contractsMaster.value) : false;
|
||||
return {
|
||||
@@ -1277,7 +1282,7 @@ async function getEffectiveFeaturesForCustomer(customerOrId) {
|
||||
quotes: globals.quotesEnabled && truthy(customer.feature_quotes),
|
||||
bills: globals.billsEnabled && truthy(customer.feature_bills),
|
||||
hoursLogging: hoursLoggingMaster && truthy(customer.feature_hours_logging),
|
||||
contracts: contractsEnabled,
|
||||
contracts: contractsEnabled && truthy(customer.feature_contracts),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
/**
|
||||
* Incoming-mail intake (migration 128). Polls the configured IMAP mailbox
|
||||
* every minute, parses each unseen message, and drops PDF/image attachments
|
||||
* into the incoming-invoices inbox (inbound_documents, source='email').
|
||||
*
|
||||
* Gated by the `incomingMail` feature flag. Idempotent: each message is logged
|
||||
* in received_emails keyed by message-id (skip if seen); duplicate attachments
|
||||
* are caught downstream by the inbound_documents SHA-256 dedup. Handles
|
||||
* forwarded messages because mailparser flattens nested attachments.
|
||||
*/
|
||||
const fsp = require('fs').promises;
|
||||
const path = require('path');
|
||||
const { ImapFlow } = require('imapflow');
|
||||
const { simpleParser } = require('mailparser');
|
||||
const { db } = require('../database/db');
|
||||
const logger = require('../utils/logger');
|
||||
const { getStoragePath } = require('../config/storage');
|
||||
const expenseService = require('./expenseService');
|
||||
const { isUniqueViolation } = require('../utils/dbErrors');
|
||||
|
||||
const ALLOWED_MIME = ['application/pdf', 'image/jpeg', 'image/png'];
|
||||
let polling = false;
|
||||
|
||||
// Fail fast instead of hanging on a wrong host/port (e.g. IMAP pointed at an
|
||||
// SMTP port). Without these, ImapFlow waits indefinitely and the HTTP request
|
||||
// dies at the proxy as a 502 with no useful message.
|
||||
const IMAP_TIMEOUTS = { connectionTimeout: 10000, greetingTimeout: 10000, socketTimeout: 30000 };
|
||||
// Look back this far so the Received log captures mail already read in another
|
||||
// client (the unseen-only fetch missed those). Dedup by message-id keeps each
|
||||
// poll cheap — only un-logged messages are downloaded + processed.
|
||||
const LOOKBACK_DAYS = 90;
|
||||
|
||||
function makeImapClient(cfg) {
|
||||
return new ImapFlow({ host: cfg.host, port: cfg.port, secure: cfg.secure, auth: cfg.auth, logger: false, ...IMAP_TIMEOUTS });
|
||||
}
|
||||
|
||||
/** Connect with a hard ceiling, so a stuck TLS handshake can't hang forever. */
|
||||
async function connectWithTimeout(client, ms = 12000) {
|
||||
let timer;
|
||||
const timeout = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('IMAP connection timed out')), ms); });
|
||||
try {
|
||||
await Promise.race([client.connect(), timeout]);
|
||||
} catch (err) {
|
||||
// Best-effort teardown if connect lost the race but is still pending.
|
||||
try { await client.logout(); } catch (_) { /* noop */ }
|
||||
try { client.close(); } catch (_) { /* noop */ }
|
||||
throw err;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function isEnabled() {
|
||||
const flag = await db('feature_flags').where({ key: 'incomingMail' }).first();
|
||||
return !!(flag && (flag.value === true || flag.value === 1 || flag.value === '1'));
|
||||
}
|
||||
|
||||
async function getImapConfig() {
|
||||
const c = await db('email_configs').first();
|
||||
if (!c || !c.imap_host || !c.imap_user) return null;
|
||||
return {
|
||||
host: c.imap_host,
|
||||
port: c.imap_port || 993,
|
||||
secure: c.imap_secure !== false && c.imap_secure !== 0,
|
||||
auth: { user: c.imap_user, pass: c.imap_pass || '' },
|
||||
folder: c.imap_folder || 'INBOX',
|
||||
};
|
||||
}
|
||||
|
||||
async function saveAttachment(att) {
|
||||
const year = new Date().getFullYear();
|
||||
const dir = path.join(getStoragePath(), 'business-docs', 'inbound', String(year));
|
||||
await fsp.mkdir(dir, { recursive: true });
|
||||
const ext = path.extname(att.filename || '')
|
||||
|| (att.contentType === 'application/pdf' ? '.pdf' : att.contentType === 'image/png' ? '.png' : '.jpg');
|
||||
const filePath = path.join(dir, `email-${Date.now()}-${Math.floor(Math.random() * 1e6)}${ext}`);
|
||||
await fsp.writeFile(filePath, att.content);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* List the mailbox folders on the IMAP server so the UI can offer a
|
||||
* dropdown instead of a free-text path. Uses the saved config; an
|
||||
* `override` ({ host, port, secure, user, pass }) lets the admin detect
|
||||
* folders BEFORE saving. A masked/blank override password falls back to
|
||||
* the stored one. Returns [{ path, name, specialUse }] (specialUse like
|
||||
* '\\Inbox' lets the caller auto-select the inbox).
|
||||
*/
|
||||
async function listFolders(override) {
|
||||
let cfg;
|
||||
if (override && override.host && override.user) {
|
||||
cfg = {
|
||||
host: override.host,
|
||||
port: override.port || 993,
|
||||
secure: override.secure !== false && override.secure !== 0,
|
||||
auth: { user: override.user, pass: override.pass || '' },
|
||||
};
|
||||
if (!cfg.auth.pass || cfg.auth.pass === '********') {
|
||||
const stored = await getImapConfig();
|
||||
cfg.auth.pass = stored?.auth?.pass || '';
|
||||
}
|
||||
} else {
|
||||
cfg = await getImapConfig();
|
||||
}
|
||||
if (!cfg) return [];
|
||||
const client = makeImapClient(cfg);
|
||||
await connectWithTimeout(client);
|
||||
try {
|
||||
const list = await client.list();
|
||||
return (list || []).map((m) => ({ path: m.path, name: m.name, specialUse: m.specialUse || null }));
|
||||
} finally {
|
||||
await client.logout().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test the IMAP connection: log in, open the configured folder, and report
|
||||
* the message + unread counts. Non-destructive (marks nothing seen, ingests
|
||||
* nothing) — proves host/port/user/pass AND that the chosen folder opens.
|
||||
* Accepts an `override` ({ host, port, secure, user, pass, folder }) so the
|
||||
* admin can test before saving; a masked/blank password falls back to stored.
|
||||
*/
|
||||
async function testConnection(override) {
|
||||
let cfg; let folder;
|
||||
if (override && override.host && override.user) {
|
||||
cfg = {
|
||||
host: override.host,
|
||||
port: override.port || 993,
|
||||
secure: override.secure !== false && override.secure !== 0,
|
||||
auth: { user: override.user, pass: override.pass || '' },
|
||||
};
|
||||
folder = override.folder || 'INBOX';
|
||||
if (!cfg.auth.pass || cfg.auth.pass === '********') {
|
||||
const stored = await getImapConfig();
|
||||
cfg.auth.pass = stored?.auth?.pass || '';
|
||||
}
|
||||
} else {
|
||||
const c = await getImapConfig();
|
||||
if (!c) return { ok: false, error: 'unconfigured' };
|
||||
cfg = { host: c.host, port: c.port, secure: c.secure, auth: c.auth };
|
||||
folder = c.folder;
|
||||
}
|
||||
const client = makeImapClient(cfg);
|
||||
await connectWithTimeout(client);
|
||||
try {
|
||||
const status = await client.status(folder, { messages: true, unseen: true });
|
||||
return { ok: true, folder, messages: status.messages || 0, unseen: status.unseen || 0 };
|
||||
} finally {
|
||||
await client.logout().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* End-to-end round-trip test: send a uniquely-tagged email through the saved
|
||||
* SMTP (outgoing) config TO the IMAP mailbox, then poll IMAP until it arrives.
|
||||
* Proves the whole pipeline (outgoing delivery → incoming reception) in one
|
||||
* click. Uses SAVED config for both sides (real passwords needed to send +
|
||||
* read). Cleans up: the test message is deleted once found, so it never
|
||||
* reaches the accounting inbox.
|
||||
*
|
||||
* Returns { ok, seconds, recipient } on success, or { ok:false, sent, reason }.
|
||||
*/
|
||||
async function roundTripTest({ timeoutMs = 30000, intervalMs = 3000 } = {}) {
|
||||
const nodemailer = require('nodemailer');
|
||||
const crypto = require('crypto');
|
||||
const c = await db('email_configs').first();
|
||||
if (!c || !c.smtp_host || !c.smtp_port) return { ok: false, sent: false, reason: 'smtp_unconfigured' };
|
||||
if (!c.imap_host || !c.imap_user) return { ok: false, sent: false, reason: 'imap_unconfigured' };
|
||||
|
||||
// Recipient = the mailbox we poll. imap_user is the mailbox address in the
|
||||
// typical setup (e.g. rechnungen@…). NOT hardcoded — but some hosts use a
|
||||
// non-email IMAP login, in which case we can't auto-address the test.
|
||||
const recipient = c.imap_user;
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(recipient || '')) {
|
||||
return { ok: false, sent: false, reason: 'recipient_not_email', recipient };
|
||||
}
|
||||
const token = `ppk-rt-${Date.now()}-${crypto.randomBytes(5).toString('hex')}`;
|
||||
const subject = `picpeak round-trip test ${token}`;
|
||||
|
||||
// 1) Send via the saved SMTP config (mirror the /test route's transport).
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: c.smtp_host,
|
||||
port: parseInt(c.smtp_port, 10),
|
||||
secure: c.smtp_secure === true || c.smtp_secure === 1,
|
||||
auth: c.smtp_user && c.smtp_pass ? { user: c.smtp_user, pass: c.smtp_pass } : undefined,
|
||||
tls: { rejectUnauthorized: c.tls_reject_unauthorized !== false },
|
||||
});
|
||||
try {
|
||||
await transporter.sendMail({
|
||||
from: `${c.from_name || 'picpeak'} <${c.from_email || c.smtp_user}>`,
|
||||
to: recipient,
|
||||
subject,
|
||||
text: `This is an automated picpeak round-trip test. Token: ${token}. Safe to ignore — it is deleted automatically.`,
|
||||
});
|
||||
} catch (err) {
|
||||
return { ok: false, sent: false, reason: 'send_failed', error: err.message };
|
||||
}
|
||||
|
||||
// 2) Poll IMAP for the tagged message until timeout.
|
||||
const cfg = await getImapConfig();
|
||||
const folder = cfg?.folder || 'INBOX';
|
||||
const client = makeImapClient(cfg);
|
||||
await connectWithTimeout(client);
|
||||
const started = Date.now();
|
||||
// Backoff (PR #622 nit 4): some IMAP servers throttle frequent SELECT/SEARCH.
|
||||
// Grow the gap ×1.5 (cap 8s) so a 30s test does ~5 polls, not ~10.
|
||||
let delay = intervalMs;
|
||||
try {
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
const lock = await client.getMailboxLock(folder);
|
||||
try {
|
||||
const uids = await client.search({ subject: token }, { uid: true });
|
||||
if (uids && uids.length) {
|
||||
await client.messageDelete(uids, { uid: true }).catch(() => {});
|
||||
return { ok: true, seconds: Math.round((Date.now() - started) / 1000), recipient };
|
||||
}
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
if (Date.now() - started > timeoutMs) {
|
||||
return { ok: false, sent: true, reason: 'not_received', recipient };
|
||||
}
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await new Promise((r) => setTimeout(r, delay));
|
||||
delay = Math.min(Math.round(delay * 1.5), 8000);
|
||||
}
|
||||
} finally {
|
||||
await client.logout().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/** Poll the mailbox once. Safe to call repeatedly; self-skips when busy/off. */
|
||||
async function pollOnce() {
|
||||
if (polling) return { skipped: 'busy' };
|
||||
if (!(await isEnabled())) return { skipped: 'disabled' };
|
||||
const cfg = await getImapConfig();
|
||||
if (!cfg) return { skipped: 'unconfigured' };
|
||||
|
||||
polling = true;
|
||||
const client = makeImapClient(cfg);
|
||||
let processed = 0;
|
||||
try {
|
||||
await connectWithTimeout(client);
|
||||
const lock = await client.getMailboxLock(cfg.folder);
|
||||
/* eslint-disable no-await-in-loop */
|
||||
try {
|
||||
// 1) Candidate UIDs within the lookback window — regardless of \Seen, so
|
||||
// mail already read elsewhere is still logged. Fall back to unseen-only
|
||||
// if the server rejects a SINCE search.
|
||||
const since = new Date(Date.now() - LOOKBACK_DAYS * 86400000);
|
||||
let uids = [];
|
||||
try { uids = (await client.search({ since }, { uid: true })) || []; } catch (_) { uids = []; }
|
||||
if (!uids.length) { try { uids = (await client.search({ seen: false }, { uid: true })) || []; } catch (_) { uids = []; } }
|
||||
|
||||
// 2) Cheap envelope-only pass → uid + message-id (no source download).
|
||||
const candidates = [];
|
||||
if (uids.length) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
for await (const m of client.fetch(uids, { uid: true, envelope: true }, { uid: true })) {
|
||||
candidates.push({ uid: m.uid, messageId: (m.envelope && m.envelope.messageId) || `uid-${cfg.folder}-${m.uid}` });
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Drop ones we've already logged (so each poll only does new work).
|
||||
const logged = new Set();
|
||||
for (let i = 0; i < candidates.length; i += 500) {
|
||||
const chunk = candidates.slice(i, i + 500).map((c) => c.messageId);
|
||||
const rows = await db('received_emails').whereIn('message_id', chunk).select('message_id');
|
||||
rows.forEach((r) => logged.add(r.message_id));
|
||||
}
|
||||
const fresh = candidates.filter((c) => !logged.has(c.messageId));
|
||||
|
||||
// 4) Download + process each fresh message.
|
||||
for (const cand of fresh) {
|
||||
let messageId = cand.messageId;
|
||||
let claimKey = null;
|
||||
let claimed = false;
|
||||
try {
|
||||
const one = await client.fetchOne(String(cand.uid), { source: true }, { uid: true });
|
||||
if (!one || !one.source) continue;
|
||||
const parsed = await simpleParser(one.source);
|
||||
messageId = parsed.messageId || cand.messageId;
|
||||
// Claim key: a no-Message-ID mail still needs a non-null, per-message
|
||||
// key so two pollers converge — fall back to the mailbox uid.
|
||||
claimKey = messageId || `nomsgid-${cand.uid}`;
|
||||
|
||||
// Fast-path: already processed. Recover a row left 'processing' by a
|
||||
// worker that crashed mid-ingest (>10 min) so the attachment isn't
|
||||
// orphaned — otherwise skip + mark seen.
|
||||
const existing = await db('received_emails').where({ message_id: claimKey }).first();
|
||||
if (existing) {
|
||||
const staleProcessing = existing.status === 'processing'
|
||||
&& existing.created_at
|
||||
&& (Date.now() - new Date(existing.created_at).getTime() > 10 * 60 * 1000);
|
||||
if (!staleProcessing) { await client.messageFlagsAdd(cand.uid, ['\\Seen'], { uid: true }); continue; }
|
||||
await db('received_emails').where({ id: existing.id }).del();
|
||||
}
|
||||
|
||||
// CLAIM the message atomically BEFORE any ingest. The message_id UNIQUE
|
||||
// index (migration 128) makes this the real guard: if a second poller
|
||||
// (multi-replica / rolling deploy) already claimed it, the insert hits
|
||||
// the unique constraint and we skip cleanly — no double-ingest.
|
||||
try {
|
||||
await db('received_emails').insert({
|
||||
message_id: claimKey,
|
||||
status: 'processing',
|
||||
attachment_count: 0,
|
||||
received_at: new Date(),
|
||||
created_at: new Date(),
|
||||
});
|
||||
claimed = true;
|
||||
} catch (ce) {
|
||||
if (isUniqueViolation(ce)) { await client.messageFlagsAdd(cand.uid, ['\\Seen'], { uid: true }); continue; }
|
||||
throw ce;
|
||||
}
|
||||
|
||||
// Ingest attachments. Isolate each so one bad file can't prevent the
|
||||
// audit row (the symptom: doc lands in Incoming invoices but the
|
||||
// email never shows under Received).
|
||||
const atts = (parsed.attachments || []).filter((a) => ALLOWED_MIME.includes(a.contentType));
|
||||
let inboundId = null;
|
||||
let count = 0;
|
||||
const attErrors = [];
|
||||
for (const att of atts) {
|
||||
try {
|
||||
const filePath = await saveAttachment(att);
|
||||
const doc = await expenseService.recordInboundDocument({ source: 'email', filePath, originalFilename: att.filename || 'attachment', mimeType: att.contentType }, null);
|
||||
inboundId = doc.id; count += 1;
|
||||
} catch (ae) {
|
||||
attErrors.push(ae.message);
|
||||
logger.error?.(`emailIntake: attachment "${att.filename}" failed: ${ae.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// A malformed Date: header yields an Invalid Date, which throws on a
|
||||
// Postgres timestamp insert — coerce to now.
|
||||
const receivedAt = (parsed.date instanceof Date && !Number.isNaN(parsed.date.getTime())) ? parsed.date : new Date();
|
||||
const status = count > 0 ? 'ingested' : (attErrors.length ? 'error' : 'no_attachment');
|
||||
// Finalise the claimed row — every processed message ends up in the
|
||||
// Received tab, even attachment-less ones.
|
||||
await db('received_emails').where({ message_id: claimKey }).update({
|
||||
from_address: ((parsed.from && parsed.from.text) || '').slice(0, 512) || null,
|
||||
subject: parsed.subject || null,
|
||||
received_at: receivedAt,
|
||||
attachment_count: count,
|
||||
status,
|
||||
inbound_document_id: inboundId,
|
||||
error: attErrors.length ? attErrors.join('; ').slice(0, 2000) : null,
|
||||
});
|
||||
await client.messageFlagsAdd(cand.uid, ['\\Seen'], { uid: true });
|
||||
processed += 1;
|
||||
} catch (e) {
|
||||
// Loud: this is exactly where a silent failure would hide a missing
|
||||
// Received row.
|
||||
logger.error?.(`emailIntake: message uid ${cand.uid} (${messageId}) failed: ${e.message}`);
|
||||
try {
|
||||
if (claimed && claimKey) {
|
||||
// We already claimed the row — mark it errored rather than orphan it.
|
||||
await db('received_emails').where({ message_id: claimKey })
|
||||
.update({ status: 'error', error: String(e.message).slice(0, 2000) });
|
||||
} else {
|
||||
await db('received_emails').insert({ message_id: `err-${cand.uid}-${Date.now()}`, status: 'error', error: e.message, attachment_count: 0, received_at: new Date(), created_at: new Date() });
|
||||
}
|
||||
} catch (ie) {
|
||||
logger.error?.(`emailIntake: could not even write the error row (received_emails insert failing): ${ie.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
/* eslint-enable no-await-in-loop */
|
||||
await client.logout();
|
||||
} catch (e) {
|
||||
logger.error?.(`emailIntake: poll failed: ${e.message}`);
|
||||
try { await client.close(); } catch (_e) { /* ignore */ }
|
||||
} finally {
|
||||
polling = false;
|
||||
}
|
||||
return { processed };
|
||||
}
|
||||
|
||||
/** Start the 1-minute poll loop (mirrors the outgoing queue cadence). */
|
||||
function startIncomingMailPoller() {
|
||||
const run = () => pollOnce().catch((e) => logger.error?.(`emailIntake: ${e.message}`));
|
||||
setTimeout(run, 15000); // first run shortly after boot
|
||||
setInterval(run, 60 * 1000);
|
||||
logger.info?.('Incoming-mail poller started (every 60s when enabled)');
|
||||
}
|
||||
|
||||
module.exports = { pollOnce, startIncomingMailPoller, listFolders, testConnection, roundTripTest, _internal: { getImapConfig, isEnabled, saveAttachment } };
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Expense categories (migration 124).
|
||||
*
|
||||
* Seeded colored labels that classify "eigener Aufwand" expenses and feed
|
||||
* the future Erfolgsrechnung. Seed rows can be renamed/recolored but not
|
||||
* deleted (they back the reporting chart of accounts).
|
||||
*/
|
||||
const { db } = require('../database/db');
|
||||
const { AppError } = require('../utils/errors');
|
||||
|
||||
async function list() {
|
||||
return db('expense_categories')
|
||||
.orderBy('display_order', 'asc')
|
||||
.orderBy('name', 'asc');
|
||||
}
|
||||
|
||||
async function getById(id) {
|
||||
const row = await db('expense_categories').where({ id }).first();
|
||||
if (!row) throw new AppError('Expense category not found', 404, 'CATEGORY_NOT_FOUND');
|
||||
return row;
|
||||
}
|
||||
|
||||
async function create({ name, color, displayOrder }, adminId) {
|
||||
if (!name || !String(name).trim()) {
|
||||
throw new AppError('Category name is required', 400, 'NAME_REQUIRED');
|
||||
}
|
||||
const now = new Date();
|
||||
const row = {
|
||||
name: String(name).trim(),
|
||||
color: color || null,
|
||||
is_seed: false,
|
||||
display_order: Number.isInteger(displayOrder) ? displayOrder : 0,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
const inserted = await db('expense_categories').insert(row).returning('id');
|
||||
const id = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
|
||||
return getById(id);
|
||||
}
|
||||
|
||||
async function update(id, { name, color, displayOrder }) {
|
||||
const existing = await getById(id);
|
||||
const patch = { updated_at: new Date() };
|
||||
if (name !== undefined) {
|
||||
if (!String(name).trim()) throw new AppError('Category name is required', 400, 'NAME_REQUIRED');
|
||||
patch.name = String(name).trim();
|
||||
}
|
||||
if (color !== undefined) patch.color = color || null;
|
||||
if (displayOrder !== undefined && Number.isInteger(displayOrder)) patch.display_order = displayOrder;
|
||||
await db('expense_categories').where({ id: existing.id }).update(patch);
|
||||
return getById(id);
|
||||
}
|
||||
|
||||
async function remove(id) {
|
||||
const existing = await getById(id);
|
||||
if (existing.is_seed) {
|
||||
throw new AppError('Seed categories cannot be deleted', 409, 'SEED_CATEGORY_PROTECTED');
|
||||
}
|
||||
// FK on expenses.category_id is ON DELETE SET NULL — orphaned expenses keep working.
|
||||
await db('expense_categories').where({ id: existing.id }).del();
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
module.exports = { list, getById, create, update, remove };
|
||||
@@ -0,0 +1,535 @@
|
||||
/**
|
||||
* Accounting service — two separate concepts (split in migration 126):
|
||||
*
|
||||
* INCOMING INVOICES (external) → the `inbound_documents` row IS the payable.
|
||||
* It carries its own disposition, supplier-payment, booking (event_id, NULL
|
||||
* = company) and re-bill linkage. A supplier invoice never creates an
|
||||
* `expenses` row, so it appears ONLY in the incoming-invoices surface.
|
||||
*
|
||||
* EXPENSES (internal) → `expenses` rows are own costs entered by
|
||||
* staff: kind = amount | mileage(km) | per_diem, amount = quantity x rate
|
||||
* (rate from accounting settings, per-entry override), optional proof file,
|
||||
* booked to an event or the company. No supplier payment (you incur these).
|
||||
*
|
||||
* Money is integer minor units. VAT/tax handling is v1 (capture only) — verify
|
||||
* with a Treuhaender.
|
||||
*/
|
||||
const crypto = require('crypto');
|
||||
const fsp = require('fs').promises;
|
||||
const { PDFDocument } = require('pdf-lib');
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const { AppError } = require('../utils/errors');
|
||||
const logger = require('../utils/logger');
|
||||
const invoiceService = require('./invoiceService');
|
||||
|
||||
const DISPOSITIONS = ['rebill', 'durchlaufend', 'eigener_aufwand', 'duplikat', 'abgelehnt'];
|
||||
const TAX_TREATMENTS = ['domestic', 'reverse_charge_service', 'foreign_vat_non_reclaimable', 'import_goods'];
|
||||
const MARKUP_TYPES = ['none', 'percent', 'flat'];
|
||||
const PAYMENT_METHODS = ['bank_transfer', 'cash', 'twint', 'paypal', 'card', 'other'];
|
||||
const EXPENSE_KINDS = ['amount', 'mileage', 'per_diem'];
|
||||
|
||||
const DISPOSITION_DOC_STATUS = {
|
||||
rebill: 'categorized',
|
||||
durchlaufend: 'categorized',
|
||||
eigener_aufwand: 'categorized',
|
||||
duplikat: 'duplicate',
|
||||
abgelehnt: 'declined',
|
||||
};
|
||||
|
||||
function toIsoDate(v) {
|
||||
if (!v) return null;
|
||||
if (v instanceof Date) return v.toISOString().slice(0, 10);
|
||||
return String(v).slice(0, 10);
|
||||
}
|
||||
|
||||
// ── Accounting settings (app_settings, type 'accounting') ───────────────────
|
||||
async function getAccountingSettings() {
|
||||
const keys = ['accounting_km_rate_minor', 'accounting_per_diem_rate_minor', 'accounting_require_proof'];
|
||||
let rows = [];
|
||||
try {
|
||||
rows = await db('app_settings').whereIn('setting_key', keys).select('setting_key', 'setting_value');
|
||||
} catch (_e) { /* table may not exist in some test harnesses */ }
|
||||
const map = {};
|
||||
for (const r of rows) {
|
||||
let v = r.setting_value;
|
||||
if (typeof v === 'string') { try { v = JSON.parse(v); } catch (_e) { /* keep raw */ } }
|
||||
map[r.setting_key] = v;
|
||||
}
|
||||
return {
|
||||
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',
|
||||
};
|
||||
}
|
||||
|
||||
// ── Incoming invoices (inbound_documents) ───────────────────────────────────
|
||||
function transformInbound(row) {
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: row.id,
|
||||
source: row.source,
|
||||
originalFilename: row.original_filename,
|
||||
mimeType: row.mime_type,
|
||||
status: row.status,
|
||||
parseStatus: row.parse_status,
|
||||
parseMethod: row.parse_method,
|
||||
pageCount: row.page_count,
|
||||
supplierName: row.supplier_name,
|
||||
invoiceNumber: row.invoice_number,
|
||||
invoiceDate: toIsoDate(row.invoice_date),
|
||||
dueDate: toIsoDate(row.due_date),
|
||||
currency: row.currency,
|
||||
netAmountMinor: row.net_amount_minor,
|
||||
vatAmountMinor: row.vat_amount_minor,
|
||||
totalAmountMinor: row.total_amount_minor,
|
||||
qrAmountMinor: row.qr_amount_minor,
|
||||
iban: row.iban,
|
||||
paymentReference: row.payment_reference,
|
||||
duplicateOfId: row.duplicate_of_id,
|
||||
// classification + booking (migration 126)
|
||||
disposition: row.disposition,
|
||||
taxTreatment: row.tax_treatment,
|
||||
eventId: row.event_id,
|
||||
categoryId: row.category_id,
|
||||
markupType: row.markup_type,
|
||||
markupPercent: row.markup_percent != null ? Number(row.markup_percent) : null,
|
||||
markupFlatMinor: row.markup_flat_minor,
|
||||
billedInvoiceId: row.billed_invoice_id,
|
||||
billedInvoiceLineItemId: row.billed_invoice_line_item_id,
|
||||
// supplier payment (paid on the incoming invoice itself)
|
||||
supplierPaid: !!row.supplier_paid,
|
||||
supplierPaidAt: row.supplier_paid_at,
|
||||
supplierPaymentMethod: row.supplier_payment_method,
|
||||
supplierPaymentRef: row.supplier_payment_ref,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
function clampPage(page, pageSize) {
|
||||
const p = Math.max(1, parseInt(page, 10) || 1);
|
||||
const ps = Math.min(100, Math.max(1, parseInt(pageSize, 10) || 25));
|
||||
return { p, ps };
|
||||
}
|
||||
|
||||
async function inspectFile(filePath, mimeType) {
|
||||
const buf = await fsp.readFile(filePath);
|
||||
const sha = crypto.createHash('sha256').update(buf).digest('hex');
|
||||
let pageCount = null;
|
||||
if ((mimeType || '').includes('pdf')) {
|
||||
try {
|
||||
const pdf = await PDFDocument.load(buf, { updateMetadata: false });
|
||||
pageCount = pdf.getPageCount();
|
||||
} catch (e) {
|
||||
logger.warn?.(`expenseService: PDF page count failed for ${filePath}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
return { sha, pageCount };
|
||||
}
|
||||
|
||||
async function recordInboundDocument({ source, filePath, originalFilename, mimeType }, adminId) {
|
||||
let fileSha256 = null;
|
||||
let pageCount = null;
|
||||
try {
|
||||
const info = await inspectFile(filePath, mimeType);
|
||||
fileSha256 = info.sha; pageCount = info.pageCount;
|
||||
} catch (e) {
|
||||
logger.warn?.(`expenseService: could not inspect ${filePath}: ${e.message}`);
|
||||
}
|
||||
|
||||
let duplicateOfId = null;
|
||||
if (fileSha256) {
|
||||
const dup = await db('inbound_documents').where({ file_sha256: fileSha256 }).first('id');
|
||||
if (dup) duplicateOfId = dup.id;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const row = {
|
||||
source: source || 'upload',
|
||||
original_filename: originalFilename || null,
|
||||
file_path: filePath,
|
||||
mime_type: mimeType || null,
|
||||
file_sha256: fileSha256,
|
||||
status: duplicateOfId ? 'duplicate' : 'unsorted',
|
||||
parse_status: 'pending',
|
||||
parse_method: 'none',
|
||||
// Cap stored page_count to the renderable max (rasterizeService
|
||||
// MAX_RENDERABLE_PAGES) so a hostile high-page PDF can't drive an
|
||||
// unbounded inbox pager (PR #622 concern 6).
|
||||
page_count: pageCount != null ? Math.min(pageCount, 200) : null,
|
||||
duplicate_of_id: duplicateOfId,
|
||||
created_by_admin_id: adminId || null,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
const inserted = await db('inbound_documents').insert(row).returning('id');
|
||||
const id = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
|
||||
await logActivity('incoming_invoice_captured', { inboundDocumentId: id, source: row.source, duplicate: !!duplicateOfId }, adminId);
|
||||
return getInbound(id);
|
||||
}
|
||||
|
||||
async function getInbound(id) {
|
||||
const row = await db('inbound_documents').where({ id }).first();
|
||||
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 total = parseInt(countRow?.count || 0, 10);
|
||||
const rows = await base.clone().orderBy('created_at', 'desc').limit(ps).offset((p - 1) * ps);
|
||||
return { items: rows.map(transformInbound), pagination: { page: p, pageSize: ps, total, totalPages: Math.ceil(total / ps) } };
|
||||
}
|
||||
|
||||
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',
|
||||
};
|
||||
|
||||
async function updateInbound(id, payload, adminId) {
|
||||
await getInbound(id);
|
||||
const patch = { updated_at: new Date(), parse_status: 'manual' };
|
||||
for (const [camel, snake] of Object.entries(INBOUND_EDITABLE)) {
|
||||
if (payload[camel] !== undefined) patch[snake] = payload[camel] === '' ? null : payload[camel];
|
||||
}
|
||||
await db('inbound_documents').where({ id }).update(patch);
|
||||
await logActivity('incoming_invoice_updated', { inboundDocumentId: id }, adminId);
|
||||
return getInbound(id);
|
||||
}
|
||||
|
||||
// markup helpers (shared with re-bill)
|
||||
async function resolveMarkup(source, override, contractId, trx) {
|
||||
const pick = (type, percent, flatMinor) => ({
|
||||
type: MARKUP_TYPES.includes(type) ? type : 'none',
|
||||
percent: percent != null ? Number(percent) : null,
|
||||
flatMinor: Number.isInteger(flatMinor) ? flatMinor : null,
|
||||
});
|
||||
if (override && override.markupType && override.markupType !== 'none') {
|
||||
return pick(override.markupType, override.markupPercent, override.markupFlatMinor);
|
||||
}
|
||||
if (source && source.markupType && source.markupType !== 'none') {
|
||||
return pick(source.markupType, source.markupPercent, source.markupFlatMinor);
|
||||
}
|
||||
const { hasColumnCached } = require('../utils/schemaCache');
|
||||
if (contractId && (await hasColumnCached('contracts', 'expense_markup_type'))) {
|
||||
const c = await (trx || db)('contracts').where({ id: contractId })
|
||||
.first('expense_markup_type', 'expense_markup_percent', 'expense_markup_flat_minor');
|
||||
if (c && c.expense_markup_type && c.expense_markup_type !== 'none') {
|
||||
return pick(c.expense_markup_type, c.expense_markup_percent, c.expense_markup_flat_minor);
|
||||
}
|
||||
}
|
||||
return pick('none', null, null);
|
||||
}
|
||||
|
||||
function computeMarkupMinor(baseMinor, markup) {
|
||||
if (markup.type === 'percent' && markup.percent != null) return Math.round(baseMinor * Number(markup.percent) / 100);
|
||||
if (markup.type === 'flat' && Number.isInteger(markup.flatMinor)) return markup.flatMinor;
|
||||
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');
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
/** Give an incoming invoice a disposition (updates the document, no expense row). */
|
||||
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,
|
||||
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(),
|
||||
};
|
||||
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);
|
||||
}
|
||||
|
||||
/** Mark the supplier paid on the incoming invoice (the payable lives here). */
|
||||
async function markInboundSupplierPayment(id, { paid, paidAt, paymentMethod, paymentReference }, adminId) {
|
||||
await getInbound(id);
|
||||
if (paymentMethod && !PAYMENT_METHODS.includes(paymentMethod)) {
|
||||
throw new AppError(`paymentMethod must be one of ${PAYMENT_METHODS.join(', ')}`, 400, 'BAD_PAYMENT_METHOD');
|
||||
}
|
||||
await db('inbound_documents').where({ id }).update({
|
||||
supplier_paid: !!paid,
|
||||
supplier_paid_at: paid ? (paidAt ? new Date(paidAt) : new Date()) : null,
|
||||
supplier_payment_method: paid ? (paymentMethod || null) : null,
|
||||
supplier_payment_ref: paid ? (paymentReference || null) : null,
|
||||
updated_at: new Date(),
|
||||
});
|
||||
await logActivity('incoming_invoice_supplier_payment', { inboundDocumentId: id, paid: !!paid }, adminId);
|
||||
return getInbound(id);
|
||||
}
|
||||
|
||||
// ── Expenses (internal) ─────────────────────────────────────────────────────
|
||||
function transformExpense(row) {
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: row.id,
|
||||
kind: row.kind || 'amount',
|
||||
quantity: row.quantity != null ? Number(row.quantity) : null,
|
||||
rateMinor: row.rate_minor,
|
||||
eventId: row.event_id, // null = company
|
||||
supplierName: row.supplier_name,
|
||||
description: row.description,
|
||||
chfAmountMinor: row.chf_amount_minor,
|
||||
categoryId: row.category_id,
|
||||
receiptPath: row.receipt_path,
|
||||
hasProof: !!row.receipt_path,
|
||||
taxTreatment: row.tax_treatment,
|
||||
// invoiced = added to a real client invoice (locks editing); paid = settled.
|
||||
billedInvoiceId: row.billed_invoice_id,
|
||||
billedInvoiceLineItemId: row.billed_invoice_line_item_id,
|
||||
invoiced: !!row.billed_invoice_id,
|
||||
customerAccountId: row.customer_account_id,
|
||||
paid: !!row.supplier_paid,
|
||||
paidAt: row.supplier_paid_at,
|
||||
paymentMethod: row.payment_method,
|
||||
status: row.status,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
/** Compute the booked amount (minor) for an internal expense. */
|
||||
function computeExpenseAmount(kind, quantity, rateMinor, amountMinor) {
|
||||
if (kind === 'mileage' || kind === 'per_diem') {
|
||||
if (quantity != null && rateMinor != null) return Math.round(Number(quantity) * Number(rateMinor));
|
||||
return null;
|
||||
}
|
||||
return Number.isInteger(amountMinor) ? amountMinor : null;
|
||||
}
|
||||
|
||||
function buildExpenseInsert(payload, adminId, opts = {}) {
|
||||
const now = new Date();
|
||||
const kind = EXPENSE_KINDS.includes(payload.kind) ? payload.kind : 'amount';
|
||||
let rateMinor = null;
|
||||
if (kind === 'mileage') rateMinor = Number.isInteger(payload.rateMinor) ? payload.rateMinor : (opts.kmRateMinor ?? null);
|
||||
else if (kind === 'per_diem') rateMinor = Number.isInteger(payload.rateMinor) ? payload.rateMinor : (opts.perDiemRateMinor ?? null);
|
||||
const quantity = (kind === 'mileage' || kind === 'per_diem') && payload.quantity != null ? Number(payload.quantity) : null;
|
||||
const chf = computeExpenseAmount(kind, quantity, rateMinor, payload.chfAmountMinor);
|
||||
return {
|
||||
inbound_document_id: null,
|
||||
disposition: 'eigener_aufwand', // internal expenses are always own-cost
|
||||
tax_treatment: TAX_TREATMENTS.includes(payload.taxTreatment) ? payload.taxTreatment : 'domestic',
|
||||
event_id: payload.eventId || null, // null = company
|
||||
supplier_name: payload.supplierName || null,
|
||||
description: payload.description || null,
|
||||
kind,
|
||||
quantity,
|
||||
rate_minor: rateMinor,
|
||||
chf_amount_minor: chf,
|
||||
gross_amount_minor: chf,
|
||||
category_id: payload.categoryId || null,
|
||||
receipt_path: opts.receiptPath || null,
|
||||
status: 'open',
|
||||
created_by_admin_id: adminId || null,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
}
|
||||
|
||||
async function getExpense(id) {
|
||||
const row = await db('expenses').where({ id }).first();
|
||||
if (!row) throw new AppError('Expense not found', 404, 'EXPENSE_NOT_FOUND');
|
||||
return transformExpense(row);
|
||||
}
|
||||
|
||||
async function createExpense(payload, adminId, { receiptPath } = {}) {
|
||||
const settings = await getAccountingSettings();
|
||||
if (settings.requireProof && !receiptPath) {
|
||||
throw new AppError('A proof file is required for expenses', 400, 'PROOF_REQUIRED');
|
||||
}
|
||||
const row = buildExpenseInsert(payload, adminId, {
|
||||
receiptPath,
|
||||
kmRateMinor: settings.kmRateMinor,
|
||||
perDiemRateMinor: settings.perDiemRateMinor,
|
||||
});
|
||||
const inserted = await db('expenses').insert(row).returning('id');
|
||||
const id = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
|
||||
await logActivity('expense_created', { expenseId: id, kind: row.kind }, adminId);
|
||||
return getExpense(id);
|
||||
}
|
||||
|
||||
async function listExpenses({ kind, eventId, categoryId, page, pageSize } = {}) {
|
||||
const { p, ps } = clampPage(page, pageSize);
|
||||
const base = db('expenses').where({ inbound_document_id: null }); // internal only
|
||||
if (kind) base.where({ kind });
|
||||
if (categoryId) base.where({ category_id: categoryId });
|
||||
if (eventId === 'company') base.whereNull('event_id');
|
||||
else if (eventId) base.where({ event_id: eventId });
|
||||
const countRow = await base.clone().count({ count: '*' }).first();
|
||||
const total = parseInt(countRow?.count || 0, 10);
|
||||
const rows = await base.clone().orderBy('created_at', 'desc').limit(ps).offset((p - 1) * ps);
|
||||
return { items: rows.map(transformExpense), pagination: { page: p, pageSize: ps, total, totalPages: Math.ceil(total / ps) } };
|
||||
}
|
||||
|
||||
const EXPENSE_EDITABLE = {
|
||||
supplierName: 'supplier_name', description: 'description', taxTreatment: 'tax_treatment',
|
||||
eventId: 'event_id', categoryId: 'category_id',
|
||||
};
|
||||
|
||||
async function updateExpense(id, payload, adminId, { receiptPath } = {}) {
|
||||
const existing = await getExpense(id);
|
||||
if (existing.invoiced) {
|
||||
throw new AppError('Expense is invoiced — editing is locked', 409, 'EXPENSE_LOCKED');
|
||||
}
|
||||
const patch = { updated_at: new Date() };
|
||||
for (const [camel, snake] of Object.entries(EXPENSE_EDITABLE)) {
|
||||
if (payload[camel] !== undefined) patch[snake] = payload[camel] === '' ? null : payload[camel];
|
||||
}
|
||||
if (receiptPath) patch.receipt_path = receiptPath;
|
||||
await db('expenses').where({ id }).update(patch);
|
||||
await logActivity('expense_updated', { expenseId: id }, adminId);
|
||||
return getExpense(id);
|
||||
}
|
||||
|
||||
/** Add an internal expense onto a client invoice (mints a line). Marks it
|
||||
* invoiced (locks editing) + links the invoice. base = chf amount + markup. */
|
||||
async function rebillExpense(id, payload, adminId, trx0) {
|
||||
const run = async (trx) => {
|
||||
const row = await trx('expenses').where({ id }).first();
|
||||
if (!row) throw new AppError('Expense not found', 404, 'EXPENSE_NOT_FOUND');
|
||||
const exp = transformExpense(row);
|
||||
if (exp.invoiced) throw new AppError('Expense already invoiced', 409, 'ALREADY_INVOICED');
|
||||
if (!payload.customerAccountId) throw new AppError('customerAccountId is required', 400, 'CUSTOMER_REQUIRED');
|
||||
const base = exp.chfAmountMinor;
|
||||
if (base == null) throw new AppError('Expense has no amount to invoice', 400, 'AMOUNT_REQUIRED');
|
||||
const markup = await resolveMarkup(
|
||||
{ markupType: row.markup_type, markupPercent: row.markup_percent, markupFlatMinor: row.markup_flat_minor },
|
||||
payload, payload.contractId, trx,
|
||||
);
|
||||
const lineTotal = base + computeMarkupMinor(base, markup);
|
||||
const label = exp.description || exp.supplierName || 'Aufwand';
|
||||
const { invoiceIds } = await invoiceService.createInvoice({
|
||||
customerAccountId: payload.customerAccountId,
|
||||
eventId: payload.eventId || exp.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 invoice', 500, 'INVOICE_FAILED');
|
||||
const line = await trx('invoice_line_items').where({ invoice_id: invoiceId }).orderBy('id', 'desc').first('id');
|
||||
await trx('expenses').where({ id }).update({
|
||||
billed_invoice_id: invoiceId,
|
||||
billed_invoice_line_item_id: line ? line.id : null,
|
||||
billed_at: new Date(),
|
||||
customer_account_id: payload.customerAccountId,
|
||||
markup_type: markup.type,
|
||||
markup_percent: markup.type === 'percent' ? markup.percent : null,
|
||||
markup_flat_minor: markup.type === 'flat' ? markup.flatMinor : null,
|
||||
status: 'invoiced',
|
||||
updated_at: new Date(),
|
||||
});
|
||||
await logActivity('expense_invoiced', { expenseId: id, invoiceId }, adminId);
|
||||
return invoiceId;
|
||||
};
|
||||
const invoiceId = trx0 ? await run(trx0) : await db.transaction(run);
|
||||
return { expense: await getExpense(id), invoiceId };
|
||||
}
|
||||
|
||||
/** Mark an expense paid/settled (manual). */
|
||||
async function markExpensePaid(id, { paid, paidAt, paymentMethod, paymentReference }, adminId) {
|
||||
await getExpense(id);
|
||||
if (paymentMethod && !PAYMENT_METHODS.includes(paymentMethod)) {
|
||||
throw new AppError(`paymentMethod must be one of ${PAYMENT_METHODS.join(', ')}`, 400, 'BAD_PAYMENT_METHOD');
|
||||
}
|
||||
await db('expenses').where({ id }).update({
|
||||
supplier_paid: !!paid,
|
||||
supplier_paid_at: paid ? (paidAt ? new Date(paidAt) : new Date()) : null,
|
||||
payment_method: paid ? (paymentMethod || null) : null,
|
||||
payment_reference: paid ? (paymentReference || null) : null,
|
||||
updated_at: new Date(),
|
||||
});
|
||||
await logActivity('expense_paid', { expenseId: id, paid: !!paid }, adminId);
|
||||
return getExpense(id);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getAccountingSettings,
|
||||
rebillExpense,
|
||||
markExpensePaid,
|
||||
// incoming invoices
|
||||
recordInboundDocument,
|
||||
getInbound,
|
||||
listInbound,
|
||||
updateInbound,
|
||||
categorizeInbound,
|
||||
rebillInbound,
|
||||
markInboundSupplierPayment,
|
||||
// expenses
|
||||
createExpense,
|
||||
getExpense,
|
||||
listExpenses,
|
||||
updateExpense,
|
||||
// constants
|
||||
DISPOSITIONS,
|
||||
TAX_TREATMENTS,
|
||||
MARKUP_TYPES,
|
||||
PAYMENT_METHODS,
|
||||
EXPENSE_KINDS,
|
||||
// unit-test surface
|
||||
_internal: { computeMarkupMinor, resolveMarkup, computeExpenseAmount, buildExpenseInsert, transformExpense, transformInbound },
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Inbound-document field extraction — the assist ladder.
|
||||
*
|
||||
* Lightest-first: Swiss QR-bill decode → digital-PDF text layer → OCR for
|
||||
* true scans. Returns BEST-EFFORT fields only; the admin always confirms
|
||||
* them in the inbox. The QR amount is returned SEPARATELY as `qrAmountMinor`
|
||||
* and must NEVER be treated as the authoritative total (it is the
|
||||
* attacker-controllable "pay this" field) — the authoritative total comes
|
||||
* from the text/line items and the admin's confirmation.
|
||||
*
|
||||
* ─────────────────────────────────────────────────────────────────────────
|
||||
* STATUS: interface + plumbing only. The heavy extractors require infra that
|
||||
* is intentionally deferred to a follow-up:
|
||||
* - Swiss QR decode → a 2D-barcode decoder (zxing/jsQR class) + rasterise.
|
||||
* - PDF text layer → a text extractor (NOT a 3rd PDF lib — see memory
|
||||
* `feedback_pdf_libraries`; revisit the approach).
|
||||
* - OCR → Tesseract installed as an OS package in the Docker
|
||||
* image and shelled out, run inside a NETWORK-ISOLATED
|
||||
* worker (no egress) per the locked design.
|
||||
* Until those land, extract() returns { parsed: false, method: 'none' } and
|
||||
* the document stays in `parse_status='pending'` for manual entry.
|
||||
* ─────────────────────────────────────────────────────────────────────────
|
||||
*/
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
/**
|
||||
* @returns {Promise<{
|
||||
* parsed: boolean,
|
||||
* method: 'qr'|'pdf_text'|'ocr'|'none',
|
||||
* fields: {
|
||||
* supplierName?, invoiceNumber?, invoiceDate?, dueDate?, currency?,
|
||||
* netAmountMinor?, vatAmountMinor?, totalAmountMinor?,
|
||||
* qrAmountMinor?, iban?, paymentReference?
|
||||
* },
|
||||
* raw?: object,
|
||||
* error?: string
|
||||
* }>}
|
||||
*/
|
||||
async function extract(filePath, mimeType) {
|
||||
try {
|
||||
// 1) Swiss QR-bill (structured) — DEFERRED.
|
||||
// 2) Digital-PDF text layer — DEFERRED.
|
||||
// 3) OCR for scans/photos — DEFERRED.
|
||||
// Plumbing is in place so the upload route can call this best-effort
|
||||
// today and richer extractors can slot in without touching callers.
|
||||
logger.debug?.(`extractionService: no extractor wired yet for ${mimeType || 'unknown'} (${filePath})`);
|
||||
return { parsed: false, method: 'none', fields: {} };
|
||||
} catch (err) {
|
||||
logger.error?.(`extractionService.extract failed: ${err.message}`);
|
||||
return { parsed: false, method: 'none', fields: {}, error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { extract };
|
||||
@@ -43,6 +43,7 @@ function getHierarchyHelpers() {
|
||||
|
||||
// D.2 — `ensureInt` + `ensureNumber` consolidated into utils/numericHelpers.
|
||||
const { ensureInt, ensureNumber } = require('../utils/numericHelpers');
|
||||
const { hasColumnCached } = require('../utils/schemaCache');
|
||||
|
||||
function formatNumberInTemplate(format, year, seq) {
|
||||
return format
|
||||
@@ -955,6 +956,11 @@ async function createInvoice(payload, adminId, trx = db) {
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
};
|
||||
// Migration 130 — snapshot the chosen output VAT code (immutable; the
|
||||
// accounting export emits exactly this rather than re-deriving from the map).
|
||||
if (payload.vatCode !== undefined && await hasColumnCached('invoices', 'vat_code')) {
|
||||
row.vat_code = payload.vatCode ? String(payload.vatCode).slice(0, 16) : null;
|
||||
}
|
||||
const inserted = await trx('invoices').insert(row).returning('id');
|
||||
const invoiceId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
|
||||
|
||||
@@ -1774,6 +1780,8 @@ async function buildInvoiceRenderContext(invoice, lineItems) {
|
||||
totals: {
|
||||
netAmountMinor: invoice.net_amount_minor,
|
||||
vatRate: invoice.vat_rate,
|
||||
// Migration 130 — VAT-code snapshot (so re-editing preserves it).
|
||||
vatCode: invoice.vat_code ?? null,
|
||||
vatAmountMinor: invoice.vat_amount_minor,
|
||||
shippingAmountMinor: invoice.shipping_amount_minor,
|
||||
totalAmountMinor: invoice.total_amount_minor,
|
||||
@@ -2212,6 +2220,10 @@ async function createStorno(originalId, adminId, trx = db) {
|
||||
currency: original.currency,
|
||||
language: original.language,
|
||||
vat_rate: original.vat_rate,
|
||||
// Migration 130 — carry the original's VAT-code snapshot onto the Storno so
|
||||
// both documents export the same code. Conditional spread = safe on pre-130
|
||||
// DBs (undefined → omitted).
|
||||
...(original.vat_code ? { vat_code: original.vat_code } : {}),
|
||||
shipping_amount_minor: -ensureInt(original.shipping_amount_minor || 0),
|
||||
net_amount_minor: -ensureInt(original.net_amount_minor),
|
||||
vat_amount_minor: -ensureInt(original.vat_amount_minor),
|
||||
|
||||
@@ -0,0 +1,507 @@
|
||||
/**
|
||||
* ledgerService — Accounting Layer A: chart of accounts + VAT codes + a
|
||||
* Treuhänder export.
|
||||
*
|
||||
* picpeak is NOT a double-entry ledger (that's Layer B). This service:
|
||||
* 1. CRUD for `ledger_accounts` (Swiss/LI KMU-Kontenrahmen) + `vat_codes`,
|
||||
* plus the category→account and tax_treatment→VAT-code mappings.
|
||||
* 2. buildPostings(): turns the data we already capture (revenue invoices,
|
||||
* incoming supplier invoices, internal expenses) into balanced
|
||||
* "Buchungssätze" — accrual-dated, single-row Soll/Haben entries with a
|
||||
* VAT code the target software expands.
|
||||
* 3. Export formatters (generic / Banana / bexio) so a Treuhänder can import
|
||||
* the collective journal.
|
||||
*
|
||||
* Accrual basis only — payment/bank postings are Layer B (bank reconciliation).
|
||||
* Legal/financial output is a GUIDELINE: every surface must point the user at a
|
||||
* Treuhänder ([[feedback_legal_financial_examples_only]]).
|
||||
*/
|
||||
|
||||
const { db, withRetry } = require('../database/db');
|
||||
const { getAppSetting } = require('../utils/appSettings');
|
||||
const { buildCustomerLabel } = require('./taxReportService')._internal;
|
||||
const { ensureInt } = require('../utils/numericHelpers');
|
||||
const { neutralizeSpreadsheetFormula } = require('../utils/spreadsheetSafe');
|
||||
|
||||
const ACCOUNT_TYPES = ['asset', 'liability', 'equity', 'revenue', 'expense'];
|
||||
const VAT_DIRECTIONS = ['output', 'input'];
|
||||
|
||||
// Statuses we book. Mirrors taxReportService: cancelled originals are excluded
|
||||
// (the storno reissue, a negative-total row, carries the reversal).
|
||||
const REVENUE_STATUSES = ['sent', 'paid', 'overdue', 'pending_delivery'];
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────
|
||||
function rateKey(rate) {
|
||||
// Normalise 8.10 → '8.1', 0 → '0' so it matches the seeded output-VAT map.
|
||||
const n = Number(rate);
|
||||
if (!Number.isFinite(n)) return '0';
|
||||
return String(Number(n.toFixed(2)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve all the config the posting engine needs in one shot: account
|
||||
* lookup maps + VAT-code lookup + the default-account / VAT-mapping settings.
|
||||
*/
|
||||
async function getConfig() {
|
||||
const [accounts, vatCodes] = await Promise.all([
|
||||
db('ledger_accounts').select('id', 'number', 'name', 'type', 'active'),
|
||||
db('vat_codes').select('id', 'code', 'name', 'rate', 'direction', 'account_id', 'active'),
|
||||
]);
|
||||
const accountByNumber = new Map(accounts.map((a) => [a.number, a]));
|
||||
const accountById = new Map(accounts.map((a) => [a.id, a]));
|
||||
|
||||
const [
|
||||
debitoren, kreditoren, defaultRevenue, defaultExpense, mileage, perDiem, rebilled,
|
||||
vatMap, outputVatMap,
|
||||
] = await Promise.all([
|
||||
getAppSetting('ledger_account_debitoren', '1100'),
|
||||
getAppSetting('ledger_account_kreditoren', '2000'),
|
||||
getAppSetting('ledger_account_default_revenue', '3400'),
|
||||
getAppSetting('ledger_account_default_expense', '6700'),
|
||||
getAppSetting('ledger_account_mileage', '6200'),
|
||||
getAppSetting('ledger_account_per_diem', '6640'),
|
||||
getAppSetting('ledger_account_rebilled_revenue', '3940'),
|
||||
getAppSetting('ledger_vat_map', {}),
|
||||
getAppSetting('ledger_output_vat_map', {}),
|
||||
]);
|
||||
|
||||
return {
|
||||
accounts, vatCodes, accountByNumber, accountById,
|
||||
settings: {
|
||||
debitoren, kreditoren, defaultRevenue, defaultExpense, mileage, perDiem, rebilled,
|
||||
},
|
||||
vatMap: vatMap || {},
|
||||
outputVatMap: outputVatMap || {},
|
||||
};
|
||||
}
|
||||
|
||||
// ── CRUD: accounts ───────────────────────────────────────────────────
|
||||
async function listAccounts() {
|
||||
return db('ledger_accounts').orderBy('number', 'asc').select('*');
|
||||
}
|
||||
|
||||
async function createAccount({ number, name, type }) {
|
||||
if (!number || !name) throw httpError(400, 'number and name are required', 'VALIDATION');
|
||||
if (!ACCOUNT_TYPES.includes(type)) throw httpError(400, 'invalid account type', 'VALIDATION');
|
||||
const exists = await db('ledger_accounts').where({ number }).first();
|
||||
if (exists) throw httpError(409, 'an account with this number already exists', 'DUPLICATE');
|
||||
const [row] = await db('ledger_accounts')
|
||||
.insert({ number, name, type, is_seed: false, active: true })
|
||||
.returning('*');
|
||||
return row || db('ledger_accounts').where({ number }).first();
|
||||
}
|
||||
|
||||
async function updateAccount(id, { number, name, type, active }) {
|
||||
const patch = { updated_at: new Date() };
|
||||
if (number !== undefined) patch.number = number;
|
||||
if (name !== undefined) patch.name = name;
|
||||
if (type !== undefined) {
|
||||
if (!ACCOUNT_TYPES.includes(type)) throw httpError(400, 'invalid account type', 'VALIDATION');
|
||||
patch.type = type;
|
||||
}
|
||||
if (active !== undefined) patch.active = !!active;
|
||||
if (patch.number) {
|
||||
const clash = await db('ledger_accounts').where({ number: patch.number }).whereNot({ id }).first();
|
||||
if (clash) throw httpError(409, 'an account with this number already exists', 'DUPLICATE');
|
||||
}
|
||||
await db('ledger_accounts').where({ id }).update(patch);
|
||||
return db('ledger_accounts').where({ id }).first();
|
||||
}
|
||||
|
||||
/** Hard-delete only when nothing references the account; otherwise tell the
|
||||
* caller to deactivate instead (keeps mappings + exports stable). */
|
||||
async function deleteAccount(id) {
|
||||
const acct = await db('ledger_accounts').where({ id }).first();
|
||||
if (!acct) throw httpError(404, 'account not found', 'NOT_FOUND');
|
||||
const refs = await accountReferences(acct);
|
||||
if (refs.length) throw httpError(409, `account is in use (${refs.join(', ')}) — deactivate it instead`, 'IN_USE');
|
||||
await db('ledger_accounts').where({ id }).del();
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
async function accountReferences(acct) {
|
||||
const refs = [];
|
||||
const vat = await db('vat_codes').where({ account_id: acct.id }).first();
|
||||
if (vat) refs.push('VAT code');
|
||||
if (await db.schema.hasColumn('expense_categories', 'ledger_account_id')) {
|
||||
const cat = await db('expense_categories').where({ ledger_account_id: acct.id }).first();
|
||||
if (cat) refs.push('expense category');
|
||||
}
|
||||
// Default-account settings reference accounts by NUMBER.
|
||||
const settingKeys = ['ledger_account_debitoren', 'ledger_account_kreditoren', 'ledger_account_bank',
|
||||
'ledger_account_cash', 'ledger_account_default_revenue', 'ledger_account_default_expense',
|
||||
'ledger_account_mileage', 'ledger_account_per_diem', 'ledger_account_rebilled_revenue'];
|
||||
const settingRows = await db('app_settings').whereIn('setting_key', settingKeys).select('setting_value');
|
||||
if (settingRows.some((r) => safeParse(r.setting_value) === acct.number)) refs.push('default-account setting');
|
||||
return refs;
|
||||
}
|
||||
|
||||
// ── CRUD: VAT codes ──────────────────────────────────────────────────
|
||||
async function listVatCodes() {
|
||||
return db('vat_codes').orderBy('display_order', 'asc').select('*');
|
||||
}
|
||||
|
||||
async function createVatCode({ code, name, rate, direction, accountId }) {
|
||||
if (!code || !name) throw httpError(400, 'code and name are required', 'VALIDATION');
|
||||
if (!VAT_DIRECTIONS.includes(direction)) throw httpError(400, 'invalid direction', 'VALIDATION');
|
||||
const exists = await db('vat_codes').where({ code }).first();
|
||||
if (exists) throw httpError(409, 'a VAT code with this code already exists', 'DUPLICATE');
|
||||
const [row] = await db('vat_codes')
|
||||
.insert({ code, name, rate: Number(rate) || 0, direction, account_id: accountId || null, is_seed: false, active: true })
|
||||
.returning('*');
|
||||
return row || db('vat_codes').where({ code }).first();
|
||||
}
|
||||
|
||||
async function updateVatCode(id, { code, name, rate, direction, accountId, active }) {
|
||||
const patch = { updated_at: new Date() };
|
||||
if (code !== undefined) patch.code = code;
|
||||
if (name !== undefined) patch.name = name;
|
||||
if (rate !== undefined) patch.rate = Number(rate) || 0;
|
||||
if (direction !== undefined) {
|
||||
if (!VAT_DIRECTIONS.includes(direction)) throw httpError(400, 'invalid direction', 'VALIDATION');
|
||||
patch.direction = direction;
|
||||
}
|
||||
if (accountId !== undefined) patch.account_id = accountId || null;
|
||||
if (active !== undefined) patch.active = !!active;
|
||||
if (patch.code) {
|
||||
const clash = await db('vat_codes').where({ code: patch.code }).whereNot({ id }).first();
|
||||
if (clash) throw httpError(409, 'a VAT code with this code already exists', 'DUPLICATE');
|
||||
}
|
||||
await db('vat_codes').where({ id }).update(patch);
|
||||
return db('vat_codes').where({ id }).first();
|
||||
}
|
||||
|
||||
async function deleteVatCode(id) {
|
||||
const vat = await db('vat_codes').where({ id }).first();
|
||||
if (!vat) throw httpError(404, 'VAT code not found', 'NOT_FOUND');
|
||||
// Referenced by the tax_treatment / output-rate maps?
|
||||
const [vatMap, outputVatMap] = await Promise.all([
|
||||
getAppSetting('ledger_vat_map', {}), getAppSetting('ledger_output_vat_map', {}),
|
||||
]);
|
||||
const used = Object.values(vatMap || {}).includes(vat.code) || Object.values(outputVatMap || {}).includes(vat.code);
|
||||
if (used) throw httpError(409, 'VAT code is referenced by a mapping — change the mapping first', 'IN_USE');
|
||||
await db('vat_codes').where({ id }).del();
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
// ── mappings (categories + settings) ─────────────────────────────────
|
||||
async function getMappings() {
|
||||
const hasCol = await db.schema.hasColumn('expense_categories', 'ledger_account_id');
|
||||
const categories = await db('expense_categories')
|
||||
.orderBy('display_order', 'asc')
|
||||
.select('id', 'name', 'color', hasCol ? 'ledger_account_id' : db.raw('NULL as ledger_account_id'));
|
||||
const settingKeys = ['ledger_account_debitoren', 'ledger_account_kreditoren', 'ledger_account_bank',
|
||||
'ledger_account_cash', 'ledger_account_default_revenue', 'ledger_account_default_expense',
|
||||
'ledger_account_mileage', 'ledger_account_per_diem', 'ledger_account_rebilled_revenue',
|
||||
'ledger_vat_map', 'ledger_output_vat_map'];
|
||||
const rows = await db('app_settings').whereIn('setting_key', settingKeys).select('setting_key', 'setting_value');
|
||||
const settings = {};
|
||||
for (const r of rows) settings[r.setting_key] = safeParse(r.setting_value);
|
||||
return { categories, settings };
|
||||
}
|
||||
|
||||
async function setCategoryAccount(categoryId, ledgerAccountId) {
|
||||
if (!(await db.schema.hasColumn('expense_categories', 'ledger_account_id'))) {
|
||||
throw httpError(409, 'category→account mapping column missing', 'SCHEMA');
|
||||
}
|
||||
await db('expense_categories').where({ id: categoryId }).update({ ledger_account_id: ledgerAccountId || null });
|
||||
return db('expense_categories').where({ id: categoryId }).first();
|
||||
}
|
||||
|
||||
/** Update the ledger_* app_settings (default accounts + VAT maps). Only
|
||||
* whitelisted keys; values stored JSON-stringified (matching the store). */
|
||||
async function updateSettings(patch) {
|
||||
const allowed = new Set(['ledger_account_debitoren', 'ledger_account_kreditoren', 'ledger_account_bank',
|
||||
'ledger_account_cash', 'ledger_account_default_revenue', 'ledger_account_default_expense',
|
||||
'ledger_account_mileage', 'ledger_account_per_diem', 'ledger_account_rebilled_revenue',
|
||||
'ledger_vat_map', 'ledger_output_vat_map']);
|
||||
const updated = [];
|
||||
for (const [key, value] of Object.entries(patch || {})) {
|
||||
if (!allowed.has(key)) continue;
|
||||
const existing = await db('app_settings').where({ setting_key: key }).first();
|
||||
if (existing) {
|
||||
await db('app_settings').where({ setting_key: key }).update({ setting_value: JSON.stringify(value) });
|
||||
} else {
|
||||
await db('app_settings').insert({ setting_key: key, setting_value: JSON.stringify(value), setting_type: 'accounting' });
|
||||
}
|
||||
updated.push(key);
|
||||
}
|
||||
return { updated };
|
||||
}
|
||||
|
||||
// ── posting engine ───────────────────────────────────────────────────
|
||||
/**
|
||||
* Build the accrual collective journal for [from, to] in `cur`.
|
||||
*
|
||||
* Returns { postings, currency, period }. Each posting is a single
|
||||
* Buchungssatz:
|
||||
* { date, docNumber, description, debitAccount, debitName, creditAccount,
|
||||
* creditName, grossMinor, netMinor, vatMinor, vatCode, vatRate, source,
|
||||
* eventName }
|
||||
* Amount is GROSS (the VAT code lets the target software expand net+VAT).
|
||||
* `netMinor`/`vatMinor` are included for tooling that imports net amounts.
|
||||
*/
|
||||
async function buildPostings({ from, to, currency } = {}) {
|
||||
if (!from || !to) throw httpError(400, '`from` and `to` are required (YYYY-MM-DD)', 'VALIDATION');
|
||||
if (!currency) throw httpError(400, '`currency` is required', 'VALIDATION');
|
||||
const cur = String(currency).toUpperCase();
|
||||
// Inclusive end-of-day bound; plain range comparison (no SQL date()) so it's
|
||||
// valid on both Postgres and SQLite.
|
||||
const toEnd = `${to} 23:59:59.999`;
|
||||
|
||||
return withRetry(async () => {
|
||||
const cfg = await getConfig();
|
||||
const nameOf = (number) => cfg.accountByNumber.get(number)?.name || '';
|
||||
const postings = [];
|
||||
|
||||
// 1) Revenue invoices → Dr Debitoren / Cr Ertrag (gross, output VAT code).
|
||||
// Migration 130 — prefer the snapshotted vat_code; guard for pre-130 DBs.
|
||||
const hasInvVatCode = await db.schema.hasColumn('invoices', 'vat_code');
|
||||
const invoices = await db('invoices')
|
||||
.leftJoin('customer_accounts', 'invoices.customer_account_id', 'customer_accounts.id')
|
||||
.leftJoin('events', 'invoices.event_id', 'events.id')
|
||||
.whereBetween('invoices.issue_date', [from, to])
|
||||
.where('invoices.currency', cur)
|
||||
.whereIn('invoices.status', REVENUE_STATUSES)
|
||||
.orderBy('invoices.issue_date', 'asc')
|
||||
.select(
|
||||
'invoices.id', 'invoices.invoice_number', 'invoices.issue_date', 'invoices.vat_rate',
|
||||
...(hasInvVatCode ? ['invoices.vat_code'] : []),
|
||||
'invoices.net_amount_minor', 'invoices.vat_amount_minor', 'invoices.total_amount_minor',
|
||||
'customer_accounts.company_name as customer_company_name',
|
||||
'customer_accounts.first_name as customer_first_name',
|
||||
'customer_accounts.last_name as customer_last_name',
|
||||
'customer_accounts.display_name as customer_display_name',
|
||||
'customer_accounts.email as customer_email',
|
||||
db.raw('COALESCE(invoices.event_name, events.event_name) AS event_name'),
|
||||
);
|
||||
for (const inv of invoices) {
|
||||
const revAcct = cfg.settings.defaultRevenue;
|
||||
// Prefer the snapshot taken at issue time; legacy rows fall back to the
|
||||
// (mutable) rate→code map.
|
||||
const vatCode = inv.vat_code || cfg.outputVatMap[rateKey(inv.vat_rate)] || '';
|
||||
const label = buildCustomerLabel(inv);
|
||||
postings.push({
|
||||
date: inv.issue_date,
|
||||
docNumber: inv.invoice_number || '',
|
||||
description: [inv.invoice_number, label].filter(Boolean).join(' · '),
|
||||
debitAccount: cfg.settings.debitoren, debitName: nameOf(cfg.settings.debitoren),
|
||||
creditAccount: revAcct, creditName: nameOf(revAcct),
|
||||
grossMinor: ensureInt(inv.total_amount_minor),
|
||||
netMinor: ensureInt(inv.net_amount_minor),
|
||||
vatMinor: ensureInt(inv.vat_amount_minor),
|
||||
vatCode, vatRate: Number(inv.vat_rate) || 0,
|
||||
source: 'revenue', eventName: inv.event_name || '',
|
||||
});
|
||||
}
|
||||
|
||||
// 2) Incoming supplier invoices → Dr Aufwand / Cr Kreditoren (input VAT).
|
||||
if (await db.schema.hasTable('inbound_documents')) {
|
||||
const hasCatCol = await db.schema.hasColumn('expense_categories', 'ledger_account_id');
|
||||
const inbound = await db('inbound_documents')
|
||||
.leftJoin('events', 'inbound_documents.event_id', 'events.id')
|
||||
.modify((q) => {
|
||||
if (hasCatCol) q.leftJoin('expense_categories', 'inbound_documents.category_id', 'expense_categories.id');
|
||||
})
|
||||
.where((qb) => {
|
||||
qb.whereBetween('inbound_documents.invoice_date', [from, to])
|
||||
.orWhere((q2) => q2.whereNull('inbound_documents.invoice_date')
|
||||
.andWhere('inbound_documents.created_at', '>=', from)
|
||||
.andWhere('inbound_documents.created_at', '<=', toEnd));
|
||||
})
|
||||
.where((qb) => { qb.where('inbound_documents.currency', cur).orWhereNull('inbound_documents.currency'); })
|
||||
.whereNotIn('inbound_documents.status', ['declined', 'duplicate'])
|
||||
.orderBy('inbound_documents.created_at', 'asc')
|
||||
.select(
|
||||
'inbound_documents.id', 'inbound_documents.invoice_number', 'inbound_documents.invoice_date',
|
||||
'inbound_documents.created_at', 'inbound_documents.supplier_name', 'inbound_documents.tax_treatment',
|
||||
'inbound_documents.net_amount_minor', 'inbound_documents.vat_amount_minor', 'inbound_documents.total_amount_minor',
|
||||
'inbound_documents.event_id',
|
||||
hasCatCol ? 'expense_categories.ledger_account_id as cat_account_id' : db.raw('NULL as cat_account_id'),
|
||||
'events.event_name as event_name',
|
||||
);
|
||||
for (const d of inbound) {
|
||||
const acctNumber = cfg.accountById.get(d.cat_account_id)?.number || cfg.settings.defaultExpense;
|
||||
const vatCode = cfg.vatMap[d.tax_treatment || 'domestic'] || '';
|
||||
const gross = ensureInt(d.total_amount_minor) || (ensureInt(d.net_amount_minor) + ensureInt(d.vat_amount_minor));
|
||||
postings.push({
|
||||
date: d.invoice_date || d.created_at,
|
||||
docNumber: d.invoice_number || '',
|
||||
description: [d.supplier_name, d.invoice_number].filter(Boolean).join(' · '),
|
||||
debitAccount: acctNumber, debitName: nameOf(acctNumber),
|
||||
creditAccount: cfg.settings.kreditoren, creditName: nameOf(cfg.settings.kreditoren),
|
||||
grossMinor: gross,
|
||||
netMinor: ensureInt(d.net_amount_minor) || (gross - ensureInt(d.vat_amount_minor)),
|
||||
vatMinor: ensureInt(d.vat_amount_minor),
|
||||
vatCode, vatRate: 0,
|
||||
source: 'incoming', eventName: d.event_id ? (d.event_name || '') : '',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Internal expenses → Dr Aufwand / Cr Kreditoren (input VAT).
|
||||
if (await db.schema.hasTable('expenses')) {
|
||||
const hasCatCol = await db.schema.hasColumn('expense_categories', 'ledger_account_id');
|
||||
const expenses = await db('expenses')
|
||||
.leftJoin('events', 'expenses.event_id', 'events.id')
|
||||
.modify((q) => {
|
||||
if (hasCatCol) q.leftJoin('expense_categories', 'expenses.category_id', 'expense_categories.id');
|
||||
})
|
||||
.whereRaw('expenses.created_at >= ? AND expenses.created_at <= ?', [from, toEnd])
|
||||
.whereNot('expenses.status', 'declined')
|
||||
.whereNotIn('expenses.disposition', ['duplikat', 'abgelehnt'])
|
||||
.modify((q) => { if (cur !== 'CHF') q.where('expenses.original_currency', cur); })
|
||||
.orderBy('expenses.created_at', 'asc')
|
||||
.select(
|
||||
'expenses.id', 'expenses.created_at', 'expenses.kind', 'expenses.supplier_name', 'expenses.description',
|
||||
'expenses.tax_treatment', 'expenses.event_id',
|
||||
'expenses.original_amount_minor', 'expenses.chf_amount_minor',
|
||||
'expenses.net_amount_minor', 'expenses.vat_amount_minor', 'expenses.gross_amount_minor',
|
||||
hasCatCol ? 'expense_categories.ledger_account_id as cat_account_id' : db.raw('NULL as cat_account_id'),
|
||||
'events.event_name as event_name',
|
||||
);
|
||||
const isChf = cur === 'CHF';
|
||||
for (const e of expenses) {
|
||||
// Account: category mapping → kind default (mileage/per-diem) → default expense.
|
||||
let acctNumber = cfg.accountById.get(e.cat_account_id)?.number;
|
||||
if (!acctNumber && e.kind === 'mileage') acctNumber = cfg.settings.mileage;
|
||||
if (!acctNumber && e.kind === 'per_diem') acctNumber = cfg.settings.perDiem;
|
||||
if (!acctNumber) acctNumber = cfg.settings.defaultExpense;
|
||||
const vatCode = cfg.vatMap[e.tax_treatment || 'domestic'] || '';
|
||||
const base = isChf ? ensureInt(e.chf_amount_minor) : ensureInt(e.original_amount_minor);
|
||||
const gross = ensureInt(e.gross_amount_minor) || ((ensureInt(e.net_amount_minor) || ensureInt(e.vat_amount_minor)) ? ensureInt(e.net_amount_minor) + ensureInt(e.vat_amount_minor) : base);
|
||||
postings.push({
|
||||
date: e.created_at,
|
||||
docNumber: `EXP-${e.id}`,
|
||||
description: e.description || e.supplier_name || `Expense #${e.id}`,
|
||||
debitAccount: acctNumber, debitName: nameOf(acctNumber),
|
||||
creditAccount: cfg.settings.kreditoren, creditName: nameOf(cfg.settings.kreditoren),
|
||||
grossMinor: gross,
|
||||
netMinor: ensureInt(e.net_amount_minor) || (gross - ensureInt(e.vat_amount_minor)),
|
||||
vatMinor: ensureInt(e.vat_amount_minor),
|
||||
vatCode, vatRate: 0,
|
||||
source: 'expense', eventName: e.event_id ? (e.event_name || '') : '',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
postings.sort((a, b) => String(a.date || '').localeCompare(String(b.date || '')));
|
||||
return { postings, currency: cur, period: { from, to } };
|
||||
});
|
||||
}
|
||||
|
||||
// ── export formatters ────────────────────────────────────────────────
|
||||
function csvEscape(cell) {
|
||||
// Formula-injection defence (Excel/Numbers/Banana) THEN RFC-4180 quote-wrap.
|
||||
const s = neutralizeSpreadsheetFormula(cell === null || cell === undefined ? '' : String(cell));
|
||||
return `"${s.replace(/"/g, '""')}"`;
|
||||
}
|
||||
function minorToDecimal(m) { return ((Number(m) || 0) / 100).toFixed(2); }
|
||||
// yyyy-mm-dd, robust to Postgres returning DATE/TIMESTAMP columns as JS Date
|
||||
// objects (SQLite returns strings). String(dateObj).slice(0,10) yields
|
||||
// "Thu Jan 15", which Banana / accounting tools reject — so format the calendar
|
||||
// parts explicitly. Uses local parts (DATE columns come back at local midnight).
|
||||
function dateOnly(d) {
|
||||
if (!d) return '';
|
||||
if (d instanceof Date) {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
return String(d).slice(0, 10);
|
||||
}
|
||||
|
||||
const EXPORT_FORMATS = ['generic', 'banana', 'banana_ie', 'bexio'];
|
||||
|
||||
/**
|
||||
* Render the collective journal in the requested format. Returns
|
||||
* { content, filename, contentType }. All are single-row Soll/Haben
|
||||
* ("two-account") layouts with a VAT-code column — the universal Swiss
|
||||
* import shape Banana + bexio both accept.
|
||||
*/
|
||||
async function exportPostings({ from, to, currency, format = 'generic' } = {}) {
|
||||
const fmt = EXPORT_FORMATS.includes(format) ? format : 'generic';
|
||||
const { postings, currency: cur, period } = await buildPostings({ from, to, currency });
|
||||
const eol = '\r\n';
|
||||
let headers; let rowOf;
|
||||
|
||||
if (fmt === 'banana') {
|
||||
// Banana "Conti doppia" import (Actions → Import into accounting → "Text
|
||||
// file with column headers"): Date, Doc, Description, AccountDebit,
|
||||
// AccountCredit, Amount, VatCode. Amount = gross; VatCode expands VAT.
|
||||
// Serialised TAB-separated + .txt below (Banana's required shape).
|
||||
headers = ['Date', 'Doc', 'Description', 'AccountDebit', 'AccountCredit', 'Amount', 'VatCode'];
|
||||
rowOf = (p) => [dateOnly(p.date), p.docNumber, p.description, p.debitAccount, p.creditAccount, minorToDecimal(p.grossMinor), p.vatCode];
|
||||
} else if (fmt === 'banana_ie') {
|
||||
// Banana Income & Expense (cash-book / Einnahmen-Ausgaben) import — for a
|
||||
// file that is NOT double-entry. Columns: Date, Doc, Description, Income,
|
||||
// Expenses, Category (the income/expense category account), VatCode. NOTE:
|
||||
// the category column's NameXml in an I&E file is "Category" (verified
|
||||
// against a real Banana file) — NOT the double-entry "ContraAccount".
|
||||
// Revenue → gross in Income + the revenue account; cost → gross in Expenses
|
||||
// + the expense account. VatCode warns harmlessly on a non-VAT file. Same
|
||||
// TAB-separated .txt shape as double-entry.
|
||||
headers = ['Date', 'Doc', 'Description', 'Income', 'Expenses', 'Category', 'VatCode'];
|
||||
rowOf = (p) => {
|
||||
const isRevenue = p.source === 'revenue';
|
||||
const amount = minorToDecimal(p.grossMinor);
|
||||
// Category = the P&L account: revenue account (credit side) for income,
|
||||
// expense account (debit side) for costs.
|
||||
const category = isRevenue ? p.creditAccount : p.debitAccount;
|
||||
return [dateOnly(p.date), p.docNumber, p.description,
|
||||
isRevenue ? amount : '', isRevenue ? '' : amount, category, p.vatCode];
|
||||
};
|
||||
} else if (fmt === 'bexio') {
|
||||
// bexio manual-entry import.
|
||||
headers = ['date', 'reference_nr', 'description', 'debit_account', 'credit_account', 'amount', 'tax_code', 'currency'];
|
||||
rowOf = (p) => [dateOnly(p.date), p.docNumber, p.description, p.debitAccount, p.creditAccount, minorToDecimal(p.grossMinor), p.vatCode, cur];
|
||||
} else {
|
||||
// Generic — every column a human or any tool could want.
|
||||
headers = ['Date', 'DocNumber', 'Description', 'Source', 'Event',
|
||||
'DebitAccount', 'DebitAccountName', 'CreditAccount', 'CreditAccountName',
|
||||
'VatCode', 'Currency', 'GrossAmount', 'NetAmount', 'VatAmount'];
|
||||
rowOf = (p) => [dateOnly(p.date), p.docNumber, p.description, p.source, p.eventName,
|
||||
p.debitAccount, p.debitName, p.creditAccount, p.creditName,
|
||||
p.vatCode, cur, minorToDecimal(p.grossMinor), minorToDecimal(p.netMinor), minorToDecimal(p.vatMinor)];
|
||||
}
|
||||
|
||||
// Banana's "Text file with column headers" import (banana.ch doc 9946/9947)
|
||||
// requires a TAB-separated .txt with UNQUOTED values — a comma .csv won't even
|
||||
// appear in its *.txt file picker. Both Banana variants use it; generic /
|
||||
// bexio stay comma-CSV (RFC 4180).
|
||||
const isTab = fmt === 'banana' || fmt === 'banana_ie';
|
||||
const sep = isTab ? '\t' : ',';
|
||||
// Tab layout: strip any tab/newline from a cell so it can't split the row;
|
||||
// CSV cells go through the RFC-4180 quoter instead.
|
||||
const fmtCell = isTab
|
||||
? (v) => neutralizeSpreadsheetFormula(String(v == null ? '' : v).replace(/[\t\r\n]+/g, ' '))
|
||||
: csvEscape;
|
||||
const lines = [headers.map(fmtCell).join(sep)];
|
||||
for (const p of postings) lines.push(rowOf(p).map(fmtCell).join(sep));
|
||||
const content = lines.join(eol) + eol;
|
||||
const ext = isTab ? 'txt' : 'csv';
|
||||
const filename = `journal_${period.from}_to_${period.to}_${cur}_${fmt}.${ext}`;
|
||||
const contentType = isTab ? 'text/plain; charset=utf-8' : 'text/csv; charset=utf-8';
|
||||
return { content, filename, contentType, count: postings.length };
|
||||
}
|
||||
|
||||
// ── small util ───────────────────────────────────────────────────────
|
||||
function httpError(status, message, code) {
|
||||
const err = new Error(message);
|
||||
err.status = status; err.statusCode = status; err.code = code;
|
||||
return err;
|
||||
}
|
||||
function safeParse(v) {
|
||||
if (v == null) return null;
|
||||
try { return JSON.parse(v); } catch (_) { return v; }
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ACCOUNT_TYPES, VAT_DIRECTIONS, EXPORT_FORMATS,
|
||||
listAccounts, createAccount, updateAccount, deleteAccount,
|
||||
listVatCodes, createVatCode, updateVatCode, deleteVatCode,
|
||||
getMappings, setCategoryAccount, updateSettings,
|
||||
getConfig, buildPostings, exportPostings,
|
||||
_internal: { rateKey, csvEscape, neutralizeSpreadsheetFormula, minorToDecimal },
|
||||
};
|
||||
@@ -82,6 +82,13 @@ const LABELS = {
|
||||
tax_col_total: 'Gross',
|
||||
tax_col_status: 'Status',
|
||||
tax_col_skonto: 'Skonto',
|
||||
tax_col_type: 'Type',
|
||||
tax_col_reference: 'Reference',
|
||||
tax_col_party: 'Customer / supplier',
|
||||
tax_col_tax: 'Tax',
|
||||
tax_type_outgoing: 'Outgoing invoice',
|
||||
tax_type_incoming: 'Incoming invoice',
|
||||
tax_type_expense: 'Expense',
|
||||
tax_status_cancelled: 'Cancelled',
|
||||
tax_totals_by_rate: 'Totals by VAT rate',
|
||||
tax_grand_total_net: 'Total net',
|
||||
@@ -89,6 +96,18 @@ const LABELS = {
|
||||
tax_grand_total_gross: 'Total gross',
|
||||
tax_cancelled_footnote: '{count} cancelled invoice(s) — amounts excluded from totals (shown for audit-trail continuity).',
|
||||
tax_no_invoices: 'No invoices in this period.',
|
||||
tax_costs_section: 'Costs (incoming invoices + expenses)',
|
||||
tax_summary_section: 'Summary (income / costs)',
|
||||
tax_cost_col_source: 'Type',
|
||||
tax_cost_col_supplier: 'Supplier / description',
|
||||
tax_cost_col_tax_treatment: 'Tax treatment',
|
||||
tax_cost_source_incoming: 'Incoming invoice',
|
||||
tax_cost_source_expense: 'Expense',
|
||||
tax_cost_total: 'Total costs',
|
||||
tax_summary_income: 'Income',
|
||||
tax_summary_costs: 'Costs',
|
||||
tax_summary_result: 'Result',
|
||||
tax_summary_vat_payable: 'VAT payable (output − input)',
|
||||
// Contracts (migration 130). Section labels stay in sync with the
|
||||
// SECTIONS_ORDER enum in contractService.
|
||||
contract_title: 'Contract',
|
||||
@@ -190,6 +209,13 @@ const LABELS = {
|
||||
tax_col_total: 'Brutto',
|
||||
tax_col_status: 'Status',
|
||||
tax_col_skonto: 'Skonto',
|
||||
tax_col_type: 'Typ',
|
||||
tax_col_reference: 'Referenz',
|
||||
tax_col_party: 'Kunde / Lieferant',
|
||||
tax_col_tax: 'Steuer',
|
||||
tax_type_outgoing: 'Ausgangsrechnung',
|
||||
tax_type_incoming: 'Eingangsrechnung',
|
||||
tax_type_expense: 'Aufwand',
|
||||
tax_status_cancelled: 'Storniert',
|
||||
tax_totals_by_rate: 'Summen nach MwSt-Satz',
|
||||
tax_grand_total_net: 'Gesamt Netto',
|
||||
@@ -197,6 +223,18 @@ const LABELS = {
|
||||
tax_grand_total_gross: 'Gesamt Brutto',
|
||||
tax_cancelled_footnote: '{count} stornierte Rechnung(en) — Beträge nicht in den Summen enthalten (für lückenlose Nummernfolge dargestellt).',
|
||||
tax_no_invoices: 'Keine Rechnungen in diesem Zeitraum.',
|
||||
tax_costs_section: 'Kosten (Eingangsrechnungen + Spesen)',
|
||||
tax_summary_section: 'Zusammenfassung (Einnahmen / Ausgaben)',
|
||||
tax_cost_col_source: 'Art',
|
||||
tax_cost_col_supplier: 'Lieferant / Beschreibung',
|
||||
tax_cost_col_tax_treatment: 'Steuerliche Behandlung',
|
||||
tax_cost_source_incoming: 'Eingangsrechnung',
|
||||
tax_cost_source_expense: 'Spese',
|
||||
tax_cost_total: 'Summe Kosten',
|
||||
tax_summary_income: 'Einnahmen',
|
||||
tax_summary_costs: 'Ausgaben',
|
||||
tax_summary_result: 'Ergebnis',
|
||||
tax_summary_vat_payable: 'MWST-Zahllast (Umsatz- − Vorsteuer)',
|
||||
contract_title: 'Vertrag',
|
||||
contract_number_label: 'Vertragsnummer',
|
||||
section_basics: 'Vertragsgrundlagen',
|
||||
@@ -291,6 +329,13 @@ const LABELS = {
|
||||
tax_col_total: 'Brut',
|
||||
tax_col_status: 'Statut',
|
||||
tax_col_skonto: 'Escompte',
|
||||
tax_col_type: 'Type',
|
||||
tax_col_reference: 'Reference',
|
||||
tax_col_party: 'Customer / supplier',
|
||||
tax_col_tax: 'Tax',
|
||||
tax_type_outgoing: 'Outgoing invoice',
|
||||
tax_type_incoming: 'Incoming invoice',
|
||||
tax_type_expense: 'Expense',
|
||||
tax_status_cancelled: 'Annulée',
|
||||
tax_totals_by_rate: 'Totaux par taux de TVA',
|
||||
tax_grand_total_net: 'Total net',
|
||||
@@ -298,6 +343,18 @@ const LABELS = {
|
||||
tax_grand_total_gross: 'Total brut',
|
||||
tax_cancelled_footnote: '{count} facture(s) annulée(s) — montants exclus des totaux (affichés pour la continuité de la piste d\'audit).',
|
||||
tax_no_invoices: 'Aucune facture sur cette période.',
|
||||
tax_costs_section: 'Charges (factures entrantes + frais)',
|
||||
tax_summary_section: 'Résumé (revenus / charges)',
|
||||
tax_cost_col_source: 'Type',
|
||||
tax_cost_col_supplier: 'Fournisseur / description',
|
||||
tax_cost_col_tax_treatment: 'Traitement fiscal',
|
||||
tax_cost_source_incoming: 'Facture entrante',
|
||||
tax_cost_source_expense: 'Frais',
|
||||
tax_cost_total: 'Total des charges',
|
||||
tax_summary_income: 'Revenus',
|
||||
tax_summary_costs: 'Charges',
|
||||
tax_summary_result: 'Résultat',
|
||||
tax_summary_vat_payable: 'TVA à payer (collectée − déductible)',
|
||||
},
|
||||
nl: {
|
||||
// Machine-translated, flagged for native review.
|
||||
@@ -361,6 +418,13 @@ const LABELS = {
|
||||
tax_col_total: 'Bruto',
|
||||
tax_col_status: 'Status',
|
||||
tax_col_skonto: 'Korting',
|
||||
tax_col_type: 'Type',
|
||||
tax_col_reference: 'Reference',
|
||||
tax_col_party: 'Customer / supplier',
|
||||
tax_col_tax: 'Tax',
|
||||
tax_type_outgoing: 'Outgoing invoice',
|
||||
tax_type_incoming: 'Incoming invoice',
|
||||
tax_type_expense: 'Expense',
|
||||
tax_status_cancelled: 'Geannuleerd',
|
||||
tax_totals_by_rate: 'Totalen per btw-tarief',
|
||||
tax_grand_total_net: 'Totaal netto',
|
||||
@@ -368,6 +432,18 @@ const LABELS = {
|
||||
tax_grand_total_gross: 'Totaal bruto',
|
||||
tax_cancelled_footnote: '{count} geannuleerde factu(u)r(en) — bedragen uitgesloten van totalen (getoond voor continuïteit van het audit-spoor).',
|
||||
tax_no_invoices: 'Geen facturen in deze periode.',
|
||||
tax_costs_section: 'Kosten (inkomende facturen + onkosten)',
|
||||
tax_summary_section: 'Samenvatting (inkomsten / kosten)',
|
||||
tax_cost_col_source: 'Type',
|
||||
tax_cost_col_supplier: 'Leverancier / omschrijving',
|
||||
tax_cost_col_tax_treatment: 'Fiscale behandeling',
|
||||
tax_cost_source_incoming: 'Inkomende factuur',
|
||||
tax_cost_source_expense: 'Onkosten',
|
||||
tax_cost_total: 'Totale kosten',
|
||||
tax_summary_income: 'Inkomsten',
|
||||
tax_summary_costs: 'Kosten',
|
||||
tax_summary_result: 'Resultaat',
|
||||
tax_summary_vat_payable: 'Te betalen btw (af − voor)',
|
||||
},
|
||||
pt: {
|
||||
// Machine-translated, flagged for native review.
|
||||
@@ -431,6 +507,13 @@ const LABELS = {
|
||||
tax_col_total: 'Bruto',
|
||||
tax_col_status: 'Estado',
|
||||
tax_col_skonto: 'Desconto',
|
||||
tax_col_type: 'Type',
|
||||
tax_col_reference: 'Reference',
|
||||
tax_col_party: 'Customer / supplier',
|
||||
tax_col_tax: 'Tax',
|
||||
tax_type_outgoing: 'Outgoing invoice',
|
||||
tax_type_incoming: 'Incoming invoice',
|
||||
tax_type_expense: 'Expense',
|
||||
tax_status_cancelled: 'Cancelada',
|
||||
tax_totals_by_rate: 'Totais por taxa de IVA',
|
||||
tax_grand_total_net: 'Total líquido',
|
||||
@@ -438,6 +521,18 @@ const LABELS = {
|
||||
tax_grand_total_gross: 'Total bruto',
|
||||
tax_cancelled_footnote: '{count} fatura(s) cancelada(s) — valores excluídos dos totais (apresentados para continuidade do rastro de auditoria).',
|
||||
tax_no_invoices: 'Sem faturas neste período.',
|
||||
tax_costs_section: 'Custos (faturas recebidas + despesas)',
|
||||
tax_summary_section: 'Resumo (receitas / custos)',
|
||||
tax_cost_col_source: 'Tipo',
|
||||
tax_cost_col_supplier: 'Fornecedor / descrição',
|
||||
tax_cost_col_tax_treatment: 'Tratamento fiscal',
|
||||
tax_cost_source_incoming: 'Fatura recebida',
|
||||
tax_cost_source_expense: 'Despesa',
|
||||
tax_cost_total: 'Total de custos',
|
||||
tax_summary_income: 'Receitas',
|
||||
tax_summary_costs: 'Custos',
|
||||
tax_summary_result: 'Resultado',
|
||||
tax_summary_vat_payable: 'IVA a pagar (cobrado − dedutível)',
|
||||
},
|
||||
ru: {
|
||||
// Machine-translated, flagged for native review.
|
||||
@@ -501,6 +596,13 @@ const LABELS = {
|
||||
tax_col_total: 'Брутто',
|
||||
tax_col_status: 'Статус',
|
||||
tax_col_skonto: 'Скидка',
|
||||
tax_col_type: 'Type',
|
||||
tax_col_reference: 'Reference',
|
||||
tax_col_party: 'Customer / supplier',
|
||||
tax_col_tax: 'Tax',
|
||||
tax_type_outgoing: 'Outgoing invoice',
|
||||
tax_type_incoming: 'Incoming invoice',
|
||||
tax_type_expense: 'Expense',
|
||||
tax_status_cancelled: 'Аннулирован',
|
||||
tax_totals_by_rate: 'Итоги по ставкам НДС',
|
||||
tax_grand_total_net: 'Итого нетто',
|
||||
@@ -508,6 +610,18 @@ const LABELS = {
|
||||
tax_grand_total_gross: 'Итого брутто',
|
||||
tax_cancelled_footnote: '{count} аннулированных счёт(а/ов) — суммы исключены из итогов (показаны для непрерывности аудиторской цепочки).',
|
||||
tax_no_invoices: 'Нет счетов за этот период.',
|
||||
tax_costs_section: 'Расходы (входящие счета + издержки)',
|
||||
tax_summary_section: 'Итоги (доходы / расходы)',
|
||||
tax_cost_col_source: 'Тип',
|
||||
tax_cost_col_supplier: 'Поставщик / описание',
|
||||
tax_cost_col_tax_treatment: 'Налоговый режим',
|
||||
tax_cost_source_incoming: 'Входящий счёт',
|
||||
tax_cost_source_expense: 'Расход',
|
||||
tax_cost_total: 'Итого расходы',
|
||||
tax_summary_income: 'Доходы',
|
||||
tax_summary_costs: 'Расходы',
|
||||
tax_summary_result: 'Результат',
|
||||
tax_summary_vat_payable: 'НДС к уплате (исходящий − входящий)',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -558,6 +558,11 @@ async function createQuote(payload, adminId) {
|
||||
if (payload.projectId !== undefined && await hasColumnCached('quotes', 'project_id')) {
|
||||
row.project_id = payload.projectId || null;
|
||||
}
|
||||
// Migration 130 — snapshot the chosen output VAT code (immutable; the export
|
||||
// emits exactly this rather than re-deriving from the mutable rate→code map).
|
||||
if (payload.vatCode !== undefined && await hasColumnCached('quotes', 'vat_code')) {
|
||||
row.vat_code = payload.vatCode ? String(payload.vatCode).slice(0, 16) : null;
|
||||
}
|
||||
const inserted = await trx('quotes').insert(row).returning('id');
|
||||
const quoteId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
|
||||
|
||||
@@ -682,6 +687,10 @@ async function updateQuote(id, payload, adminId) {
|
||||
if (Object.prototype.hasOwnProperty.call(payload, 'projectId') && await hasColumnCached('quotes', 'project_id')) {
|
||||
updates.project_id = payload.projectId || null;
|
||||
}
|
||||
// Migration 130 — VAT code snapshot.
|
||||
if (Object.prototype.hasOwnProperty.call(payload, 'vatCode') && await hasColumnCached('quotes', 'vat_code')) {
|
||||
updates.vat_code = payload.vatCode ? String(payload.vatCode).slice(0, 16) : null;
|
||||
}
|
||||
await trx('quotes').where({ id }).update(updates);
|
||||
|
||||
// When linked to a project, cascade across the deal lineage so the linked
|
||||
@@ -1553,6 +1562,8 @@ async function duplicateQuote(id, adminId) {
|
||||
expectedDurationHours: quote.expected_duration_hours,
|
||||
paymentTermTemplateId: quote.payment_term_template_id,
|
||||
vatRate: quote.vat_rate,
|
||||
// Migration 130 — VAT-code snapshot (so re-editing preserves it).
|
||||
vatCode: quote.vat_code ?? null,
|
||||
shippingAmountMinor: quote.shipping_amount_minor,
|
||||
introText: quote.intro_text,
|
||||
outroText: quote.outro_text,
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* PDF rasteriser for inbound supplier-invoice previews.
|
||||
*
|
||||
* Renders a single PDF page to a flat PNG via poppler's `pdftoppm` (an OS
|
||||
* package installed in the Docker image — NOT a Node PDF library, so it doesn't
|
||||
* count against the pdfkit+pdf-lib "no third PDF lib" rule). The admin UI shows
|
||||
* ONLY these rasterised images, never the raw PDF — pdftoppm executes no
|
||||
* embedded JavaScript and fetches no remote resources, so a malicious inbound
|
||||
* PDF can neither run code in the browser nor phone home (SSRF/exfil).
|
||||
*
|
||||
* Rendered pages are cached on disk under
|
||||
* storage/business-docs/inbound/rendered/<docId>/page-<n>.png
|
||||
* and regenerated on demand.
|
||||
*/
|
||||
const { execFile } = require('child_process');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const fsp = require('fs').promises;
|
||||
const { getStoragePath } = require('../config/storage');
|
||||
const { AppError } = require('../utils/errors');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
const RENDER_TIMEOUT_MS = 25000;
|
||||
const RENDER_DPI = 150;
|
||||
// Per-file resource bound (PR #622 concern 6): pages render one-per-request, so
|
||||
// a 1000-page hostile PDF could otherwise be walked page-by-page. Refuse to
|
||||
// render beyond this — the inbox pager is capped to match.
|
||||
const MAX_RENDERABLE_PAGES = 200;
|
||||
|
||||
function renderedDir(docId) {
|
||||
return path.join(getStoragePath(), 'business-docs', 'inbound', 'rendered', String(docId));
|
||||
}
|
||||
|
||||
function execFileAsync(cmd, args, opts) {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(cmd, args, opts, (err, stdout, stderr) => {
|
||||
if (err) return reject(err);
|
||||
return resolve({ stdout, stderr });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rasterise one page of `pdfPath` to a cached PNG; returns its absolute path.
|
||||
* @throws AppError 503 when pdftoppm is unavailable, 500 on render failure.
|
||||
*/
|
||||
async function getRenderedPagePath(docId, pdfPath, pageNum) {
|
||||
if (!Number.isInteger(pageNum) || pageNum < 1 || pageNum > MAX_RENDERABLE_PAGES) {
|
||||
throw new AppError(`Page out of range (1–${MAX_RENDERABLE_PAGES})`, 400, 'PAGE_OUT_OF_RANGE');
|
||||
}
|
||||
const dir = renderedDir(docId);
|
||||
const outPng = path.join(dir, `page-${pageNum}.png`);
|
||||
if (fs.existsSync(outPng)) return outPng;
|
||||
|
||||
await fsp.mkdir(dir, { recursive: true });
|
||||
const outPrefix = path.join(dir, `page-${pageNum}`); // pdftoppm -singlefile appends .png
|
||||
try {
|
||||
await execFileAsync('pdftoppm', [
|
||||
'-png', '-singlefile', '-r', String(RENDER_DPI),
|
||||
'-f', String(pageNum), '-l', String(pageNum),
|
||||
pdfPath, outPrefix,
|
||||
], { timeout: RENDER_TIMEOUT_MS, maxBuffer: 4 * 1024 * 1024 });
|
||||
} catch (e) {
|
||||
if (e && e.code === 'ENOENT') {
|
||||
throw new AppError('PDF rasteriser (pdftoppm) is not installed', 503, 'RASTERIZER_UNAVAILABLE');
|
||||
}
|
||||
logger.error?.(`rasterizeService: pdftoppm failed for ${pdfPath} p${pageNum}: ${e.message}`);
|
||||
throw new AppError('Failed to render PDF page', 500, 'RENDER_FAILED');
|
||||
}
|
||||
if (!fs.existsSync(outPng)) {
|
||||
throw new AppError('Failed to render PDF page', 500, 'RENDER_FAILED');
|
||||
}
|
||||
return outPng;
|
||||
}
|
||||
|
||||
module.exports = { getRenderedPagePath };
|
||||
@@ -49,6 +49,8 @@ const REPORTABLE_STATUSES = ['sent', 'paid', 'overdue', 'pending_delivery', 'can
|
||||
|
||||
// D.2 — `ensureInt` consolidated into utils/numericHelpers.
|
||||
const { ensureInt } = require('../utils/numericHelpers');
|
||||
const { neutralizeSpreadsheetFormula } = require('../utils/spreadsheetSafe');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
function ensureRate(v) {
|
||||
if (v === null || v === undefined || v === '') return 0;
|
||||
@@ -157,6 +159,198 @@ async function loadSkontoMap(invoiceIds) {
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cost side of the Milchbüchlein view (Einnahmen-Ausgaben-Rechnung).
|
||||
*
|
||||
* Aggregates the two cost entities the Accounting feature tracks, both
|
||||
* keyed on an accrual date inside [from, to] and scoped to `cur`:
|
||||
*
|
||||
* 1. incoming invoices (`inbound_documents`) — external supplier
|
||||
* payables. Accrual date = invoice_date, falling back to created_at.
|
||||
* Excludes declined + duplicate rows (not real costs).
|
||||
* 2. expenses (`expenses`) — internal own-costs (mileage / per-diem /
|
||||
* amount). Accrual date = created_at (no separate invoice date on
|
||||
* internal expenses). Excludes declined status + duplikat/abgelehnt
|
||||
* disposition.
|
||||
*
|
||||
* Both book to an event OR the company (event_id NULL = company); the
|
||||
* report surfaces every cost regardless so the total is the full
|
||||
* outflow for the period. Re-billed costs intentionally stay IN — the
|
||||
* matching re-bill revenue is already counted on the income side, so
|
||||
* keeping both sides nets correctly (a pure pass-through cancels out).
|
||||
*
|
||||
* Currency: the report is single-currency. Incoming invoices match on
|
||||
* their own `currency`. Internal expenses are stored in CHF base
|
||||
* (chf_amount_minor) plus an optional original-currency amount — for a
|
||||
* CHF report we use the CHF base; for a foreign-currency report we match
|
||||
* the expense's original_currency and use original_amount_minor.
|
||||
*
|
||||
* Tables are schema-guarded: a DB without the accounting migrations
|
||||
* yields an empty cost side rather than throwing.
|
||||
*
|
||||
* Returns { rows, totalNet, totalVat, totalGross } in minor units.
|
||||
*/
|
||||
function normMinor(v) { return ensureInt(v); }
|
||||
|
||||
async function loadCosts({ from, to, cur }) {
|
||||
const rows = [];
|
||||
let totalNet = 0;
|
||||
let totalVat = 0;
|
||||
let totalGross = 0;
|
||||
// Input VAT is only reclaimable for domestic-style treatments; foreign
|
||||
// non-reclaimable VAT is a cost, not a deduction. Feeds the report's
|
||||
// VAT-payable (output − reclaimable input).
|
||||
let reclaimableVat = 0;
|
||||
// Inclusive upper bound covering the whole `to` day. Plain range comparison
|
||||
// (no SQL date() function) so it's valid on both Postgres and SQLite — the
|
||||
// mocked unit tests can't catch a PG-only function error. invoice_date is a
|
||||
// DATE, created_at a TIMESTAMP; both compare correctly against ISO literals.
|
||||
const toEnd = `${to} 23:59:59.999`;
|
||||
|
||||
const push = (r) => {
|
||||
rows.push(r);
|
||||
totalNet += r.netMinor;
|
||||
totalVat += r.vatMinor;
|
||||
totalGross += r.totalMinor;
|
||||
if (r.taxTreatment !== 'foreign_vat_non_reclaimable') reclaimableVat += r.vatMinor;
|
||||
};
|
||||
|
||||
// 1) Incoming invoices (external supplier payables).
|
||||
if (await db.schema.hasTable('inbound_documents')) {
|
||||
const inbound = await db('inbound_documents')
|
||||
.leftJoin('events', 'inbound_documents.event_id', 'events.id')
|
||||
// Date in range: invoice_date (a DATE) when set, else created_at (a
|
||||
// TIMESTAMP). Split instead of COALESCE so we never compare mixed
|
||||
// date/timestamp types (a Postgres error the mocked tests can't see).
|
||||
.where((qb) => {
|
||||
qb.whereBetween('inbound_documents.invoice_date', [from, to])
|
||||
.orWhere((q2) => q2.whereNull('inbound_documents.invoice_date')
|
||||
.andWhere('inbound_documents.created_at', '>=', from)
|
||||
.andWhere('inbound_documents.created_at', '<=', toEnd));
|
||||
})
|
||||
// Currency match, but INCLUDE rows with no currency set — captured
|
||||
// invoices (email/upload) often have a null currency; treat them as the
|
||||
// report currency rather than silently dropping them from the cost side.
|
||||
.where((qb) => { qb.where('inbound_documents.currency', cur).orWhereNull('inbound_documents.currency'); })
|
||||
.whereNotIn('inbound_documents.status', ['declined', 'duplicate'])
|
||||
.orderBy('inbound_documents.created_at', 'asc')
|
||||
.select(
|
||||
'inbound_documents.id',
|
||||
'inbound_documents.invoice_date',
|
||||
'inbound_documents.created_at',
|
||||
'inbound_documents.supplier_name',
|
||||
// inbound_documents has no free-text `description` column (that lives on
|
||||
// `expenses`); use the supplier invoice number as the row descriptor so
|
||||
// the cost side aligns with the expense rows without a phantom column.
|
||||
'inbound_documents.invoice_number',
|
||||
'inbound_documents.disposition',
|
||||
'inbound_documents.tax_treatment',
|
||||
'inbound_documents.status',
|
||||
'inbound_documents.event_id',
|
||||
'inbound_documents.net_amount_minor',
|
||||
'inbound_documents.vat_amount_minor',
|
||||
'inbound_documents.total_amount_minor',
|
||||
'events.event_name as event_name',
|
||||
);
|
||||
for (const r of inbound) {
|
||||
const vat = normMinor(r.vat_amount_minor);
|
||||
let total = normMinor(r.total_amount_minor);
|
||||
let net = normMinor(r.net_amount_minor);
|
||||
if (!total && (net || vat)) total = net + vat;
|
||||
if (!net && total) net = total - vat;
|
||||
push({
|
||||
id: r.id,
|
||||
source: 'incoming',
|
||||
date: r.invoice_date || r.created_at,
|
||||
supplierLabel: (r.supplier_name && String(r.supplier_name).trim()) || '',
|
||||
description: r.invoice_number || '',
|
||||
eventName: r.event_id ? (r.event_name || '') : '',
|
||||
disposition: r.disposition || '',
|
||||
taxTreatment: r.tax_treatment || 'domestic',
|
||||
status: r.status || '',
|
||||
netMinor: net,
|
||||
vatMinor: vat,
|
||||
totalMinor: total,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Internal expenses (own-costs).
|
||||
if (await db.schema.hasTable('expenses')) {
|
||||
const isChf = cur === 'CHF';
|
||||
const q = db('expenses')
|
||||
.leftJoin('events', 'expenses.event_id', 'events.id')
|
||||
.whereRaw('expenses.created_at >= ? AND expenses.created_at <= ?', [from, toEnd])
|
||||
.whereNot('expenses.status', 'declined')
|
||||
.whereNotIn('expenses.disposition', ['duplikat', 'abgelehnt']);
|
||||
// CHF report includes every expense (all carry a CHF base). A
|
||||
// foreign-currency report matches the expense's original currency.
|
||||
if (!isChf) q.where('expenses.original_currency', cur);
|
||||
const expenses = await q
|
||||
.orderBy('expenses.created_at', 'asc')
|
||||
.select(
|
||||
'expenses.id',
|
||||
'expenses.created_at',
|
||||
'expenses.supplier_name',
|
||||
'expenses.description',
|
||||
'expenses.disposition',
|
||||
'expenses.tax_treatment',
|
||||
'expenses.status',
|
||||
'expenses.event_id',
|
||||
'expenses.original_currency',
|
||||
'expenses.original_amount_minor',
|
||||
'expenses.chf_amount_minor',
|
||||
'expenses.net_amount_minor',
|
||||
'expenses.vat_amount_minor',
|
||||
'expenses.gross_amount_minor',
|
||||
'events.event_name as event_name',
|
||||
);
|
||||
for (const r of expenses) {
|
||||
const vat = normMinor(r.vat_amount_minor);
|
||||
let net = normMinor(r.net_amount_minor);
|
||||
let total = normMinor(r.gross_amount_minor);
|
||||
// Fallback to the single stored amount when net/vat/gross are not
|
||||
// broken out (internal mileage/per-diem expenses carry only a base
|
||||
// amount, no VAT split).
|
||||
const base = isChf ? normMinor(r.chf_amount_minor) : normMinor(r.original_amount_minor);
|
||||
if (!total) total = (net || vat) ? net + vat : base;
|
||||
if (!net) net = total - vat;
|
||||
push({
|
||||
id: r.id,
|
||||
source: 'expense',
|
||||
date: r.created_at,
|
||||
supplierLabel: (r.supplier_name && String(r.supplier_name).trim()) || '',
|
||||
description: r.description || '',
|
||||
eventName: r.event_id ? (r.event_name || '') : '',
|
||||
disposition: r.disposition || '',
|
||||
taxTreatment: r.tax_treatment || 'domestic',
|
||||
status: r.status || '',
|
||||
netMinor: net,
|
||||
vatMinor: vat,
|
||||
totalMinor: total,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Stable chronological order across both sources.
|
||||
rows.sort((a, b) => String(a.date || '').localeCompare(String(b.date || '')));
|
||||
|
||||
return { rows, totalNet, totalVat, totalGross, reclaimableVat };
|
||||
}
|
||||
|
||||
// Is the business VAT-registered? (Settings → Accounting). Returns null when the
|
||||
// setting was never set, so the caller can fall back to a behaviour-preserving
|
||||
// heuristic (charged output VAT this period ⇒ treat as registered).
|
||||
async function getVatRegisteredSetting() {
|
||||
try {
|
||||
const v = await getAppSetting('accounting_vat_registered');
|
||||
if (v === undefined || v === null) return null;
|
||||
return v === true || v === 1 || v === '1' || v === 'true';
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The main entry point.
|
||||
*
|
||||
@@ -166,8 +360,13 @@ async function loadSkontoMap(invoiceIds) {
|
||||
* required and must match `invoices.currency` exactly — mixing
|
||||
* currencies in one report is unsound for tax filing, so the API
|
||||
* forces a single-currency view.
|
||||
*
|
||||
* `includeCosts` (default true) adds the Einnahmen-Ausgaben cost side
|
||||
* (incoming invoices + expenses) plus a `summary` block (income vs cost
|
||||
* vs result, and VAT payable = output VAT − input VAT). Pass false to
|
||||
* get the legacy revenue-only shape.
|
||||
*/
|
||||
async function getTaxReport({ from, to, currency } = {}) {
|
||||
async function getTaxReport({ from, to, currency, includeCosts = true } = {}) {
|
||||
if (!from || !to) {
|
||||
throw new Error('getTaxReport: `from` and `to` are required (YYYY-MM-DD)');
|
||||
}
|
||||
@@ -283,6 +482,104 @@ async function getTaxReport({ from, to, currency } = {}) {
|
||||
|
||||
const totalsByVatRate = Array.from(byRate.values()).sort((a, b) => a.vatRate - b.vatRate);
|
||||
|
||||
// Cost side (Einnahmen-Ausgaben). Optional so legacy callers that
|
||||
// only want the revenue listing can opt out. The cost side is
|
||||
// SUPPLEMENTARY — if it fails (e.g. an accounting table/column missing
|
||||
// on an older install) it must NOT take down the core revenue report.
|
||||
// Degrade to empty costs + log the real error for diagnosis.
|
||||
let costs = { rows: [], totalNet: 0, totalVat: 0, totalGross: 0, reclaimableVat: 0 };
|
||||
let costsError = null;
|
||||
if (includeCosts) {
|
||||
try {
|
||||
costs = await loadCosts({ from, to, cur });
|
||||
} catch (err) {
|
||||
costsError = err.message;
|
||||
logger.error?.(`taxReport: cost side failed (revenue still returned): ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// VAT-payable honours the accounting settings: when NOT VAT-registered the
|
||||
// business doesn't file VAT (payable = 0); when registered it's output VAT
|
||||
// minus the RECLAIMABLE input VAT only (foreign non-reclaimable cost VAT is
|
||||
// not deducted). Guideline figure — verify with your Treuhänder.
|
||||
// PR #622 concern 4: when VAT registration is UNSET we must NOT guess from
|
||||
// `grandTotalVat > 0` — a quarter with all-exempt cross-border sales has zero
|
||||
// output VAT and would silently flip to "not registered", hiding the reclaim.
|
||||
// Treat null as "not configured": refuse to compute a payable, surface a
|
||||
// warning in the UI instead.
|
||||
const vatRegisteredSetting = await getVatRegisteredSetting();
|
||||
const vatRegistrationConfigured = vatRegisteredSetting !== null;
|
||||
const vatRegistered = vatRegisteredSetting === true;
|
||||
const reclaimableInputVat = costs.reclaimableVat != null ? costs.reclaimableVat : costs.totalVat;
|
||||
|
||||
// Summary: income vs cost vs result. Result = a simplified
|
||||
// Einnahmen-Ausgaben surplus (net basis).
|
||||
const summary = {
|
||||
incomeNetMinor: grandTotalNet,
|
||||
incomeVatMinor: grandTotalVat,
|
||||
incomeGrossMinor: grandTotal,
|
||||
costNetMinor: costs.totalNet,
|
||||
costVatMinor: costs.totalVat,
|
||||
costGrossMinor: costs.totalGross,
|
||||
resultNetMinor: grandTotalNet - costs.totalNet,
|
||||
resultGrossMinor: grandTotal - costs.totalGross,
|
||||
vatRegistered,
|
||||
vatRegistrationConfigured,
|
||||
// null (not 0) when registration is unconfigured — the UI renders "—" + a
|
||||
// "configure VAT registration" warning rather than a misleading number.
|
||||
vatPayableMinor: !vatRegistrationConfigured
|
||||
? null
|
||||
: (vatRegistered ? (grandTotalVat - reclaimableInputVat) : 0),
|
||||
};
|
||||
|
||||
// Unified ledger (#5 — one typed, signed, sortable list). Outgoing
|
||||
// invoices carry POSITIVE amounts; incoming invoices + expenses are
|
||||
// NEGATIVE so sorting by value runs income → costs and the column
|
||||
// nets toward the Result. The legacy `rows` / `costs` shapes are
|
||||
// kept above for back-compat; this is the new canonical surface for
|
||||
// the on-screen table + PDF/CSV exports.
|
||||
const ledger = [
|
||||
...rows.map((r) => ({
|
||||
key: `out-${r.id}`,
|
||||
type: 'outgoing',
|
||||
date: r.issueDate,
|
||||
reference: r.invoiceNumber,
|
||||
party: r.customerLabel || '',
|
||||
eventName: r.eventName || '',
|
||||
vatRate: r.vatRate,
|
||||
taxTreatment: null,
|
||||
status: r.status,
|
||||
isCancelled: r.isCancelled,
|
||||
isReissue: r.isReissue,
|
||||
kind: r.kind,
|
||||
skontoApplied: r.skontoApplied,
|
||||
skontoAmountMinor: r.skontoAmountMinor,
|
||||
netMinor: r.netMinor,
|
||||
vatMinor: r.vatMinor,
|
||||
totalMinor: r.totalMinor,
|
||||
})),
|
||||
...costs.rows.map((c) => ({
|
||||
key: `${c.source}-${c.id}`,
|
||||
type: c.source === 'incoming' ? 'incoming' : 'expense',
|
||||
date: c.date,
|
||||
reference: c.description || '',
|
||||
party: c.supplierLabel || '',
|
||||
eventName: c.eventName || '',
|
||||
vatRate: null,
|
||||
taxTreatment: c.taxTreatment || 'domestic',
|
||||
status: c.status,
|
||||
isCancelled: false,
|
||||
isReissue: false,
|
||||
kind: null,
|
||||
skontoApplied: false,
|
||||
skontoAmountMinor: 0,
|
||||
netMinor: -Math.abs(c.netMinor),
|
||||
vatMinor: -Math.abs(c.vatMinor),
|
||||
totalMinor: -Math.abs(c.totalMinor),
|
||||
})),
|
||||
];
|
||||
ledger.sort((a, b) => String(a.date || '').localeCompare(String(b.date || '')));
|
||||
|
||||
return {
|
||||
rows,
|
||||
totalsByVatRate,
|
||||
@@ -290,6 +587,10 @@ async function getTaxReport({ from, to, currency } = {}) {
|
||||
grandTotalVat,
|
||||
grandTotal,
|
||||
cancelledCount,
|
||||
costs,
|
||||
costsError,
|
||||
summary,
|
||||
ledger,
|
||||
currency: cur,
|
||||
period: { from, to },
|
||||
};
|
||||
@@ -351,21 +652,19 @@ async function loadRenderContext(locale) {
|
||||
// uncluttered with just "R-2026-0001" — easier to scan for an
|
||||
// auditor looking at the sequence.
|
||||
const TAX_TABLE_COLS = [
|
||||
{ key: 'idx', labelKey: 'tax_col_no', width: 26, align: 'right' },
|
||||
{ key: 'date', labelKey: 'tax_col_date', width: 60, align: 'left' },
|
||||
{ key: 'invoice', labelKey: 'tax_col_invoice', width: 100, align: 'left' },
|
||||
{ key: 'customer', labelKey: 'tax_col_customer', width: 132, align: 'left' },
|
||||
{ key: 'event', labelKey: 'tax_col_event', width: 95, align: 'left' },
|
||||
{ key: 'vatRate', labelKey: 'tax_col_vat_rate', width: 42, align: 'right' },
|
||||
{ key: 'idx', labelKey: 'tax_col_no', width: 22, align: 'right' },
|
||||
{ key: 'type', labelKey: 'tax_col_type', width: 58, align: 'left' },
|
||||
{ key: 'date', labelKey: 'tax_col_date', width: 56, align: 'left' },
|
||||
{ key: 'reference', labelKey: 'tax_col_reference', width: 88, align: 'left' },
|
||||
{ key: 'party', labelKey: 'tax_col_party', width: 116, align: 'left' },
|
||||
{ key: 'event', labelKey: 'tax_col_event', width: 86, align: 'left' },
|
||||
{ key: 'tax', labelKey: 'tax_col_tax', width: 64, align: 'left' },
|
||||
{ key: 'net', labelKey: 'tax_col_net', width: 70, align: 'right' },
|
||||
{ key: 'vat', labelKey: 'tax_col_vat', width: 60, align: 'right' },
|
||||
{ key: 'vat', labelKey: 'tax_col_vat', width: 58, align: 'right' },
|
||||
{ key: 'total', labelKey: 'tax_col_total', width: 80, align: 'right' },
|
||||
// Skonto column (migration 126) — blank for non-Skonto rows so the
|
||||
// column reads quietly until it has data. Shrunk neighbouring text
|
||||
// columns slightly to make space without going over the landscape
|
||||
// content width.
|
||||
{ key: 'skonto', labelKey: 'tax_col_skonto', width: 56, align: 'right' },
|
||||
{ key: 'status', labelKey: 'tax_col_status', width: 58, align: 'left' },
|
||||
// column reads quietly until it has data.
|
||||
{ key: 'skonto', labelKey: 'tax_col_skonto', width: 50, align: 'right' },
|
||||
];
|
||||
|
||||
function colX(leftMargin, index) {
|
||||
@@ -400,22 +699,31 @@ function formatVatRate(rate, locale) {
|
||||
return `${formatted} %`;
|
||||
}
|
||||
|
||||
function rowCellValues(row, idx, locale, dateFormat) {
|
||||
function rowCellValues(row, idx, locale, dateFormat, currency) {
|
||||
const intlLocale = locale === 'de' ? 'de-CH' : 'en-GB';
|
||||
const typeLabel = t(
|
||||
locale,
|
||||
row.type === 'outgoing' ? 'tax_type_outgoing'
|
||||
: row.type === 'incoming' ? 'tax_type_incoming'
|
||||
: 'tax_type_expense',
|
||||
);
|
||||
const reference = row.isCancelled
|
||||
? `${row.reference || ''} (${t(locale, 'tax_status_cancelled')})`
|
||||
: (row.reference || '');
|
||||
return {
|
||||
idx: String(idx),
|
||||
date: formatDate(row.issueDate, dateFormat),
|
||||
invoice: row.invoiceNumber, // no inline "(Cancelled)" — keep the column tidy; status is its own column
|
||||
customer: row.customerLabel || '',
|
||||
type: typeLabel,
|
||||
date: formatDate(row.date, dateFormat),
|
||||
reference,
|
||||
party: row.party || '',
|
||||
event: row.eventName || '',
|
||||
vatRate: formatVatRate(row.vatRate, locale),
|
||||
net: formatMinor(row.netMinor, row.currency, intlLocale),
|
||||
vat: formatMinor(row.vatMinor, row.currency, intlLocale),
|
||||
total: formatMinor(row.totalMinor, row.currency, intlLocale),
|
||||
tax: row.type === 'outgoing' ? formatVatRate(row.vatRate, locale) : (row.taxTreatment || ''),
|
||||
net: formatMinor(row.netMinor, currency, intlLocale),
|
||||
vat: formatMinor(row.vatMinor, currency, intlLocale),
|
||||
total: formatMinor(row.totalMinor, currency, intlLocale),
|
||||
skonto: row.skontoApplied
|
||||
? formatMinor(row.skontoAmountMinor, row.currency, intlLocale)
|
||||
? formatMinor(row.skontoAmountMinor, currency, intlLocale)
|
||||
: '',
|
||||
status: row.isCancelled ? t(locale, 'tax_status_cancelled') : '',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -427,8 +735,29 @@ function rowCellValues(row, idx, locale, dateFormat) {
|
||||
* Currency is required and used to scope the data (same contract as
|
||||
* getTaxReport). Locale defaults to the business profile's default.
|
||||
*/
|
||||
async function renderTaxReportPdf({ from, to, currency, locale } = {}) {
|
||||
// Export scope (PR/Liechtenstein follow-up): the readable PDF/CSV exports can be
|
||||
// limited to the income or the cost side — handy when the Treuhänder only needs
|
||||
// one basis (e.g. income for the 20%-Gewinnungskosten flat deduction). The
|
||||
// on-screen report is unaffected; only the exports filter.
|
||||
const TAX_EXPORT_SCOPES = ['all', 'income', 'cost'];
|
||||
function normalizeScope(scope) {
|
||||
return TAX_EXPORT_SCOPES.includes(scope) ? scope : 'all';
|
||||
}
|
||||
function scopeLedger(ledger, scope) {
|
||||
if (scope === 'income') return (ledger || []).filter((r) => r.type === 'outgoing');
|
||||
if (scope === 'cost') return (ledger || []).filter((r) => r.type === 'incoming' || r.type === 'expense');
|
||||
return ledger || [];
|
||||
}
|
||||
|
||||
async function renderTaxReportPdf({ from, to, currency, locale, scope } = {}) {
|
||||
const report = await getTaxReport({ from, to, currency });
|
||||
const xScope = normalizeScope(scope);
|
||||
report.ledger = scopeLedger(report.ledger, xScope);
|
||||
// The per-rate breakdown is income-only — drop it from a cost-only export.
|
||||
if (xScope === 'cost') report.totalsByVatRate = [];
|
||||
const showIncome = xScope !== 'cost';
|
||||
const showCosts = xScope !== 'income';
|
||||
const showResult = xScope === 'all';
|
||||
const renderCtx = await loadRenderContext(locale);
|
||||
const useLocale = renderCtx.locale;
|
||||
const intlLocale = useLocale === 'de' ? 'de-CH' : 'en-GB';
|
||||
@@ -484,7 +813,7 @@ async function renderTaxReportPdf({ from, to, currency, locale } = {}) {
|
||||
const tableBottomLimit = page.height - page.marginBottom - 110; // leave room for totals
|
||||
const tableWidth = TAX_TABLE_COLS.reduce((s, c) => s + c.width, 0);
|
||||
|
||||
if (report.rows.length === 0) {
|
||||
if (report.ledger.length === 0) {
|
||||
doc.font(fonts.body).fontSize(10).fillColor('#555')
|
||||
.text(t(useLocale, 'tax_no_invoices'), leftMargin, y + 6, {
|
||||
width: tableWidth, align: 'center',
|
||||
@@ -507,7 +836,7 @@ async function renderTaxReportPdf({ from, to, currency, locale } = {}) {
|
||||
// wrap (they're either ints or money strings whose width we
|
||||
// budget for) — only text cells (customer, event, invoice,
|
||||
// status) opt into natural wrapping.
|
||||
const isWrappable = (col) => ['invoice', 'customer', 'event', 'status'].includes(col.key);
|
||||
const isWrappable = (col) => ['type', 'reference', 'party', 'event', 'tax'].includes(col.key);
|
||||
const measureCellHeight = (value, col) => {
|
||||
const s = safeStr(value);
|
||||
if (!s) return 0;
|
||||
@@ -520,9 +849,9 @@ async function renderTaxReportPdf({ from, to, currency, locale } = {}) {
|
||||
return doc.heightOfString(s, opts);
|
||||
};
|
||||
|
||||
for (let i = 0; i < report.rows.length; i += 1) {
|
||||
const row = report.rows[i];
|
||||
const cells = rowCellValues(row, i + 1, useLocale, renderCtx.dateFormat);
|
||||
for (let i = 0; i < report.ledger.length; i += 1) {
|
||||
const row = report.ledger[i];
|
||||
const cells = rowCellValues(row, i + 1, useLocale, renderCtx.dateFormat, report.currency);
|
||||
|
||||
// Set the font BEFORE measuring so heightOfString reads the
|
||||
// exact rendering state we'll use for doc.text below.
|
||||
@@ -585,7 +914,10 @@ async function renderTaxReportPdf({ from, to, currency, locale } = {}) {
|
||||
// otherwise PDFKit auto-paginates mid-totals, creating phantom
|
||||
// pages whose footer ends up at unexpected Y positions on the
|
||||
// subsequent bufferedPageRange loop.
|
||||
const totalsHeightEstimate = 16 + (report.totalsByVatRate.length * 13) + 8 + 39 + 12;
|
||||
// Header (16) + one line per VAT bucket (13) + divider (8) +
|
||||
// three income/costs/result summary rows (39) + a 12pt cushion.
|
||||
const summaryHeight = 8 + (3 * 13);
|
||||
const totalsHeightEstimate = 16 + (report.totalsByVatRate.length * 13) + 12 + summaryHeight;
|
||||
const footerReserve = 24; // 12 above + 12 of page-number text room
|
||||
if (y + 12 + totalsHeightEstimate + footerReserve > page.height - page.marginBottom) {
|
||||
doc.addPage({
|
||||
@@ -619,22 +951,25 @@ async function renderTaxReportPdf({ from, to, currency, locale } = {}) {
|
||||
totalsX + 270, ty, { width: 90, align: 'right' });
|
||||
ty += 13;
|
||||
}
|
||||
// Divider above grand totals.
|
||||
// Divider above the income / costs / result summary.
|
||||
doc.moveTo(totalsX, ty + 2).lineTo(totalsX + totalsBoxWidth, ty + 2)
|
||||
.lineWidth(0.6).strokeColor('#000').stroke();
|
||||
ty += 6;
|
||||
doc.font(fonts.bold);
|
||||
doc.text(t(useLocale, 'tax_grand_total_net'), totalsX, ty, { width: 170, align: 'left' });
|
||||
doc.text(formatMinor(report.grandTotalNet, report.currency, intlLocale),
|
||||
totalsX + 175, ty, { width: 90, align: 'right' });
|
||||
// Income / Costs / Result summary (mirrors the on-screen summary
|
||||
// box). Costs are shown NEGATIVE so the Result reads as a plain
|
||||
// sum of the column. Net / VAT / Gross across the three lines.
|
||||
const s = report.summary;
|
||||
const summaryLine = (labelKey, netMinor, vatMinor, grossMinor, bold) => {
|
||||
doc.font(bold ? fonts.bold : fonts.body).fontSize(9).fillColor('#000');
|
||||
doc.text(t(useLocale, labelKey), totalsX, ty, { width: 80, align: 'left' });
|
||||
doc.text(formatMinor(netMinor, report.currency, intlLocale), totalsX + 80, ty, { width: 90, align: 'right' });
|
||||
doc.text(formatMinor(vatMinor, report.currency, intlLocale), totalsX + 175, ty, { width: 90, align: 'right' });
|
||||
doc.text(formatMinor(grossMinor, report.currency, intlLocale), totalsX + 270, ty, { width: 90, align: 'right' });
|
||||
ty += 13;
|
||||
doc.text(t(useLocale, 'tax_grand_total_vat'), totalsX, ty, { width: 170, align: 'left' });
|
||||
doc.text(formatMinor(report.grandTotalVat, report.currency, intlLocale),
|
||||
totalsX + 175, ty, { width: 90, align: 'right' });
|
||||
ty += 13;
|
||||
doc.text(t(useLocale, 'tax_grand_total_gross'), totalsX, ty, { width: 170, align: 'left' });
|
||||
doc.text(formatMinor(report.grandTotal, report.currency, intlLocale),
|
||||
totalsX + 270, ty, { width: 90, align: 'right' });
|
||||
};
|
||||
if (showIncome) summaryLine('tax_summary_income', s.incomeNetMinor, s.incomeVatMinor, s.incomeGrossMinor, !showResult);
|
||||
if (showCosts) summaryLine('tax_summary_costs', -Math.abs(s.costNetMinor), -Math.abs(s.costVatMinor), -Math.abs(s.costGrossMinor), !showResult);
|
||||
if (showResult) summaryLine('tax_summary_result', s.resultNetMinor, s.vatPayableMinor, s.resultGrossMinor, true);
|
||||
|
||||
// Cancelled footnote (bottom-left). Only when there are any.
|
||||
if (report.cancelledCount > 0) {
|
||||
@@ -688,66 +1023,96 @@ async function renderTaxReportPdf({ from, to, currency, locale } = {}) {
|
||||
* renderTaxReportCsv({ from, to, currency, locale })
|
||||
* → Promise<{ content, filename, contentType }>
|
||||
*/
|
||||
async function renderTaxReportCsv({ from, to, currency, locale } = {}) {
|
||||
async function renderTaxReportCsv({ from, to, currency, locale, scope } = {}) {
|
||||
const report = await getTaxReport({ from, to, currency });
|
||||
const xScope = normalizeScope(scope);
|
||||
report.ledger = scopeLedger(report.ledger, xScope);
|
||||
const useLocale = locale || 'en';
|
||||
|
||||
const headers = [
|
||||
t(useLocale, 'tax_col_no'),
|
||||
t(useLocale, 'tax_col_date'),
|
||||
t(useLocale, 'tax_col_invoice'),
|
||||
t(useLocale, 'tax_col_customer'),
|
||||
t(useLocale, 'tax_col_event'),
|
||||
t(useLocale, 'tax_col_vat_rate'),
|
||||
`${t(useLocale, 'tax_col_net')} (${report.currency})`,
|
||||
`${t(useLocale, 'tax_col_vat')} (${report.currency})`,
|
||||
`${t(useLocale, 'tax_col_total')} (${report.currency})`,
|
||||
t(useLocale, 'tax_status_cancelled'),
|
||||
// Migration 126 — Skonto export. `tax_col_skonto` is the discount
|
||||
// amount in major units; admin's accountant reconciles the line.
|
||||
`${t(useLocale, 'tax_col_skonto')} (${report.currency})`,
|
||||
];
|
||||
|
||||
const escape = (cell) => {
|
||||
const s = cell === null || cell === undefined ? '' : String(cell);
|
||||
// RFC 4180: wrap in quotes when the value contains comma, quote,
|
||||
// or newline. We always wrap, simpler + bulletproof for Excel.
|
||||
// Formula-injection defence (Excel/Numbers) THEN RFC-4180 quote-wrap. The
|
||||
// quote wrap alone does NOT stop formula evaluation — only the leading
|
||||
// single-quote prefix does.
|
||||
const s = neutralizeSpreadsheetFormula(cell === null || cell === undefined ? '' : String(cell));
|
||||
return `"${s.replace(/"/g, '""')}"`;
|
||||
};
|
||||
|
||||
const minorToDotDecimal = (m) => ((Number(m) || 0) / 100).toFixed(2);
|
||||
|
||||
// yyyy-mm-dd, robust to Postgres returning dates as JS Date objects (SQLite
|
||||
// returns strings) — raw String(dateObj) is "Thu Jan 15", not an ISO date.
|
||||
const isoDate = (d) => {
|
||||
if (!d) return '';
|
||||
if (d instanceof Date) {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
return String(d).slice(0, 10);
|
||||
};
|
||||
|
||||
const typeLabelKey = (type) => (
|
||||
type === 'outgoing' ? 'tax_type_outgoing'
|
||||
: type === 'incoming' ? 'tax_type_incoming'
|
||||
: 'tax_type_expense'
|
||||
);
|
||||
|
||||
// ONE unified ledger table. Amounts are already signed in the ledger
|
||||
// (outgoing positive, costs negative) — emitted as-is.
|
||||
const headers = [
|
||||
t(useLocale, 'tax_col_no'),
|
||||
t(useLocale, 'tax_col_type'),
|
||||
t(useLocale, 'tax_col_date'),
|
||||
t(useLocale, 'tax_col_reference'),
|
||||
t(useLocale, 'tax_col_party'),
|
||||
t(useLocale, 'tax_col_event'),
|
||||
t(useLocale, 'tax_col_tax'),
|
||||
`${t(useLocale, 'tax_col_net')} (${report.currency})`,
|
||||
`${t(useLocale, 'tax_col_vat')} (${report.currency})`,
|
||||
`${t(useLocale, 'tax_col_total')} (${report.currency})`,
|
||||
// Migration 126 — Skonto export. `tax_col_skonto` is the discount
|
||||
// amount in major units; admin's accountant reconciles the line.
|
||||
`${t(useLocale, 'tax_col_skonto')} (${report.currency})`,
|
||||
];
|
||||
|
||||
const lines = [headers.map(escape).join(',')];
|
||||
report.rows.forEach((row, i) => {
|
||||
report.ledger.forEach((row, i) => {
|
||||
const reference = row.isCancelled
|
||||
? `${row.reference || ''} (${t(useLocale, 'tax_status_cancelled')})`
|
||||
: (row.reference || '');
|
||||
const tax = row.type === 'outgoing'
|
||||
? Number(row.vatRate).toFixed(2)
|
||||
: (row.taxTreatment || '');
|
||||
lines.push([
|
||||
i + 1,
|
||||
row.issueDate,
|
||||
row.invoiceNumber,
|
||||
row.customerLabel,
|
||||
t(useLocale, typeLabelKey(row.type)),
|
||||
isoDate(row.date),
|
||||
reference,
|
||||
row.party,
|
||||
row.eventName,
|
||||
Number(row.vatRate).toFixed(2),
|
||||
tax,
|
||||
minorToDotDecimal(row.netMinor),
|
||||
minorToDotDecimal(row.vatMinor),
|
||||
minorToDotDecimal(row.totalMinor),
|
||||
row.isCancelled ? '1' : '0',
|
||||
row.skontoApplied ? minorToDotDecimal(row.skontoAmountMinor) : '',
|
||||
].map(escape).join(','));
|
||||
});
|
||||
// Trailing totals row: blank cells + grand totals at the end so
|
||||
// the column alignment matches the data rows when opened in Excel.
|
||||
|
||||
// Trailing blank line, then the income / costs / result summary block.
|
||||
const summary = report.summary;
|
||||
if (summary) {
|
||||
lines.push('');
|
||||
lines.push([
|
||||
'', '', '',
|
||||
t(useLocale, 'tax_grand_total_gross'),
|
||||
'', '',
|
||||
minorToDotDecimal(report.grandTotalNet),
|
||||
minorToDotDecimal(report.grandTotalVat),
|
||||
minorToDotDecimal(report.grandTotal),
|
||||
'', '',
|
||||
lines.push(escape(t(useLocale, 'tax_summary_section')));
|
||||
const sline = (labelKey, net, vat, gross) => lines.push([
|
||||
'', '', '', t(useLocale, labelKey), '', '',
|
||||
minorToDotDecimal(net), minorToDotDecimal(vat), minorToDotDecimal(gross),
|
||||
].map(escape).join(','));
|
||||
if (xScope !== 'cost') sline('tax_summary_income', summary.incomeNetMinor, summary.incomeVatMinor, summary.incomeGrossMinor);
|
||||
if (xScope !== 'income') sline('tax_summary_costs', summary.costNetMinor, summary.costVatMinor, summary.costGrossMinor);
|
||||
if (xScope === 'all') sline('tax_summary_result', summary.resultNetMinor, summary.vatPayableMinor, summary.resultGrossMinor);
|
||||
}
|
||||
|
||||
const content = lines.join('\r\n') + '\r\n';
|
||||
const filename = `tax_report_${report.period.from}_to_${report.period.to}_${report.currency}.csv`;
|
||||
const scopeTag = xScope === 'all' ? '' : `${xScope}_`;
|
||||
const filename = `tax_report_${scopeTag}${report.period.from}_to_${report.period.to}_${report.currency}.csv`;
|
||||
return { content, filename, contentType: 'text/csv; charset=utf-8' };
|
||||
}
|
||||
|
||||
@@ -756,5 +1121,5 @@ module.exports = {
|
||||
renderTaxReportPdf,
|
||||
renderTaxReportCsv,
|
||||
// Exposed for unit tests.
|
||||
_internal: { grossUpLateFee, computeReportedAmounts, buildCustomerLabel, formatVatRate },
|
||||
_internal: { grossUpLateFee, computeReportedAmounts, buildCustomerLabel, formatVatRate, loadCosts, scopeLedger, normalizeScope },
|
||||
};
|
||||
|
||||
@@ -35,4 +35,22 @@ async function getAppSetting(key, defaultValue = null) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getAppSetting };
|
||||
/**
|
||||
* Schema-correct upsert into app_settings. The table has NO `created_at`
|
||||
* column (only setting_key/setting_value/setting_type + updated_at — see
|
||||
* src/database/db.js), so inserting created_at throws and silently breaks the
|
||||
* FIRST save of any new key. Centralised here so route authors can't
|
||||
* re-introduce that bug (PR #622 concern 5). `setting_value` is expected to be
|
||||
* already JSON-stringified, matching getAppSetting's JSON.parse on read.
|
||||
*/
|
||||
async function upsertAppSetting(setting_key, setting_value, setting_type, conn = db) {
|
||||
const existing = await conn('app_settings').where({ setting_key }).first();
|
||||
if (existing) {
|
||||
await conn('app_settings').where({ setting_key })
|
||||
.update({ setting_value, setting_type, updated_at: new Date() });
|
||||
} else {
|
||||
await conn('app_settings').insert({ setting_key, setting_value, setting_type, updated_at: new Date() });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getAppSetting, upsertAppSetting };
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Cross-driver detector for a unique-constraint violation. The error shape
|
||||
* varies by driver: Postgres → SQLSTATE `23505`; better-sqlite3 →
|
||||
* "UNIQUE constraint failed"; node-sqlite3 → `SQLITE_CONSTRAINT`. Used by the
|
||||
* claim-then-work concurrency patterns (document_sequences, monthly-draft, the
|
||||
* IMAP intake claim) to converge cleanly when a concurrent writer wins the race.
|
||||
*/
|
||||
function isUniqueViolation(err) {
|
||||
if (!err) return false;
|
||||
if (err.code === '23505' || err.code === 'SQLITE_CONSTRAINT') return true;
|
||||
const msg = String(err.message || '');
|
||||
return /unique/i.test(msg) || /sqlite_constraint/i.test(msg);
|
||||
}
|
||||
|
||||
module.exports = { isUniqueViolation };
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Formula-injection defence for spreadsheet / accounting exports (CSV + Banana).
|
||||
*
|
||||
* A cell whose first character is one of `= + - @ TAB CR` is evaluated as a
|
||||
* formula when the file is opened in Excel / Numbers / Banana. RFC-4180
|
||||
* quote-wrapping does NOT stop that evaluation — only prefixing a single quote
|
||||
* does. Vectors in picpeak are real: supplier_name, invoice_number,
|
||||
* payment_reference and description are admin-editable (and sender-controlled
|
||||
* once incoming-mail ingestion is live).
|
||||
*
|
||||
* Apply to BOTH the quoted CSV and the unquoted tab-separated Banana export —
|
||||
* the tab export has no surrounding quotes, so it's the more exposed of the two.
|
||||
*/
|
||||
function neutralizeSpreadsheetFormula(value) {
|
||||
const s = value === null || value === undefined ? '' : String(value);
|
||||
return /^[=+\-@\t\r]/.test(s) ? `'${s}` : s;
|
||||
}
|
||||
|
||||
module.exports = { neutralizeSpreadsheetFormula };
|
||||
@@ -1 +0,0 @@
|
||||
pdf
|
||||
@@ -0,0 +1,65 @@
|
||||
# Accounting — Inbound supplier invoices, expenses & re-bill (MVP)
|
||||
|
||||
> **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.
|
||||
|
||||
## 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.
|
||||
|
||||
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.
|
||||
|
||||
## 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.
|
||||
|
||||
## 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).
|
||||
|
||||
## 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+.
|
||||
|
||||
- **122** — seed `accounting` feature flag (default OFF).
|
||||
- **123** — seed `accounting.view` / `accounting.manage` permissions + grant to super_admin/admin.
|
||||
- **124** — `inbound_documents`, `expenses`, `expense_categories` (+ seed categories).
|
||||
- **125** — `contracts.expense_markup_type|_percent|_flat_minor` (the Spesen-Zuschlag clause).
|
||||
|
||||
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.
|
||||
|
||||
## 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.
|
||||
|
||||
## 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.
|
||||
|
||||
## 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.
|
||||
|
||||
## 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.
|
||||
+33
-4
@@ -66,6 +66,9 @@ import {
|
||||
import { CustomerAuthProvider } from './contexts/CustomerAuthContext';
|
||||
import { AdminLayout, AdminAuthWrapper } from './components/admin';
|
||||
import { ClientsLayout } from './components/admin/ClientsLayout';
|
||||
import { AccountingLayout, AccountingIndex } from './components/admin/AccountingLayout';
|
||||
import { AccountingInboxPage } from './pages/admin/accounting/AccountingInboxPage';
|
||||
import { ExpensesLedgerPage } from './pages/admin/accounting/ExpensesLedgerPage';
|
||||
import { RequireFeature } from './components/admin/RequireFeature';
|
||||
import { PageErrorBoundary, OfflineIndicator, SkipLink, DynamicFavicon, RobotsMetaTags, CMSContentBlock, Loading } from './components/common';
|
||||
import { MaintenanceWrapper } from './components/MaintenanceWrapper';
|
||||
@@ -251,10 +254,10 @@ function App() {
|
||||
/>
|
||||
</Route>
|
||||
|
||||
{/* Tax / Steuer report — gated by `taxReport`. */}
|
||||
<Route element={<RequireFeature flag="taxReport" />}>
|
||||
<Route path="tax-report" element={<TaxReportPage />} />
|
||||
</Route>
|
||||
{/* Tax export moved permanently to the Accounting
|
||||
section. Keep this path as a redirect so old
|
||||
bookmarks / links don't 404. */}
|
||||
<Route path="tax-report" element={<Navigate to="/admin/accounting/tax-report" replace />} />
|
||||
{/* Developer tools — gated by `crmDevelopment`. */}
|
||||
<Route element={<RequireFeature flag="crmDevelopment" />}>
|
||||
<Route path="development" element={<CrmDevelopmentPage />} />
|
||||
@@ -268,6 +271,32 @@ function App() {
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
{/* Accounting section (migration 122). Parent gated by
|
||||
the `accounting` flag. Hosts the Tax report — which
|
||||
relocates here from the CRM sub-nav when accounting
|
||||
is on — plus the future inbound-invoice / expenses
|
||||
pages. Each sub-route is independently flagged. */}
|
||||
<Route element={<RequireFeature flag="accounting" />}>
|
||||
<Route path="accounting" element={<AccountingLayout />}>
|
||||
<Route element={<RequireFeature flag="incomingInvoices" />}>
|
||||
<Route path="inbox" element={<AccountingInboxPage />} />
|
||||
</Route>
|
||||
<Route element={<RequireFeature flag="expenses" />}>
|
||||
<Route path="expenses" element={<ExpensesLedgerPage />} />
|
||||
</Route>
|
||||
<Route element={<RequireFeature flag="taxReport" />}>
|
||||
<Route path="tax-report" element={<TaxReportPage />} />
|
||||
{/* Treuhänder export moved onto the Tax page; keep
|
||||
the old path working for bookmarks. */}
|
||||
<Route path="export" element={<Navigate to="/admin/accounting/tax-report" replace />} />
|
||||
</Route>
|
||||
{/* Chart of accounts (Layer A) moved into Settings →
|
||||
Accounting; keep the old path working for bookmarks. */}
|
||||
<Route path="ledger" element={<Navigate to="/admin/settings?tab=accounting" replace />} />
|
||||
<Route index element={<AccountingIndex />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
{/* Old /admin/customers paths now live under
|
||||
/admin/clients/accounts. Kept indefinitely as
|
||||
redirects so existing bookmarks and email links
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Accounting section layout (migration 122).
|
||||
*
|
||||
* Wraps /admin/accounting/* routes with a Settings-style left sub-nav,
|
||||
* mirroring ClientsLayout. Today it hosts the Tax report (relocated here
|
||||
* from CRM when the `accounting` flag is on); the inbound-document inbox and
|
||||
* expenses pages slot in as additional sub-nav entries when their UIs land.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { NavLink, Outlet, Navigate, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Landmark, Calculator, Inbox, Wallet } from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { useFeatureFlags, type FeatureKey } from '../../contexts/FeatureFlagsContext';
|
||||
|
||||
interface NavItem {
|
||||
key: string;
|
||||
to: string;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
/** Feature flag that must be ON for this entry to render. */
|
||||
featureFlag: FeatureKey;
|
||||
}
|
||||
|
||||
export const AccountingLayout: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { flags } = useFeatureFlags();
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{
|
||||
key: 'inbox',
|
||||
to: '/admin/accounting/inbox',
|
||||
label: t('accounting.subnav.incomingInvoices', 'Incoming invoices'),
|
||||
icon: Inbox,
|
||||
featureFlag: 'incomingInvoices',
|
||||
},
|
||||
{
|
||||
key: 'expenses',
|
||||
to: '/admin/accounting/expenses',
|
||||
label: t('accounting.subnav.expenses', 'Expenses'),
|
||||
icon: Wallet,
|
||||
featureFlag: 'expenses',
|
||||
},
|
||||
{
|
||||
key: 'tax-report',
|
||||
// The Treuhänder export now lives ON the Tax page (same period/currency
|
||||
// filters, same data) instead of a separate sub-tab — see TaxReportPage.
|
||||
to: '/admin/accounting/tax-report',
|
||||
label: t('accounting.subnav.taxReport', 'Tax'),
|
||||
icon: Calculator,
|
||||
featureFlag: 'taxReport',
|
||||
},
|
||||
// Chart of accounts moved to Settings → Accounting (all accounting config
|
||||
// lives there now); this section keeps only the operational pages.
|
||||
// Future: Erfolgsrechnung (Layer B).
|
||||
];
|
||||
|
||||
const enabledItems = navItems.filter((item) => flags[item.featureFlag]);
|
||||
|
||||
const header = (
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">
|
||||
{t('accounting.title', 'Accounting')}
|
||||
</h1>
|
||||
<p className="text-neutral-600 dark:text-neutral-400 mt-1">
|
||||
{t('accounting.subtitle', 'Inbound supplier invoices, expenses and reporting.')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (enabledItems.length === 0) {
|
||||
return (
|
||||
<div>
|
||||
{header}
|
||||
<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">
|
||||
<Landmark className="w-10 h-10 mx-auto mb-3 text-neutral-400" />
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-1">
|
||||
{t('accounting.empty.title', 'No accounting features enabled')}
|
||||
</h2>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
{t('accounting.empty.body', 'Enable the Tax report (or another accounting sub-feature) under Settings → Features to get started.')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{header}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[220px_1fr] gap-6 lg:gap-8">
|
||||
{/* Mobile: native select dropdown */}
|
||||
<div className="lg:hidden">
|
||||
<label htmlFor="accounting-section" className="sr-only">
|
||||
{t('accounting.navAriaLabel', 'Accounting navigation')}
|
||||
</label>
|
||||
<select
|
||||
id="accounting-section"
|
||||
value={location.pathname}
|
||||
onChange={(e) => navigate(e.target.value)}
|
||||
className="w-full rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-3 py-2 text-sm font-medium text-neutral-900 dark:text-neutral-100 focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
{enabledItems.map((item) => (
|
||||
<option key={item.key} value={item.to}>{item.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Desktop: sticky left rail */}
|
||||
<aside className="hidden lg:block">
|
||||
<nav
|
||||
aria-label={t('accounting.navAriaLabel', 'Accounting navigation')}
|
||||
className="sticky top-6 space-y-1"
|
||||
>
|
||||
{enabledItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<NavLink
|
||||
key={item.key}
|
||||
to={item.to}
|
||||
className={({ isActive }) =>
|
||||
`group w-full flex items-center gap-2.5 px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? 'bg-accent-dark text-white'
|
||||
: 'text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800'
|
||||
}`
|
||||
}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<Icon
|
||||
className={`w-4 h-4 flex-shrink-0 ${
|
||||
isActive
|
||||
? 'text-white'
|
||||
: 'text-neutral-500 dark:text-neutral-400 group-hover:text-neutral-700 dark:group-hover:text-neutral-200'
|
||||
}`}
|
||||
/>
|
||||
<span className="truncate">{item.label}</span>
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<div className="min-w-0">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Index redirect for /admin/accounting — send to the first enabled
|
||||
* sub-feature (Incoming invoices preferred, then Tax export). When none
|
||||
* are on, render nothing; AccountingLayout shows its empty state.
|
||||
*/
|
||||
export const AccountingIndex: React.FC = () => {
|
||||
const { flags } = useFeatureFlags();
|
||||
if (flags.incomingInvoices) return <Navigate to="/admin/accounting/inbox" replace />;
|
||||
if (flags.expenses) return <Navigate to="/admin/accounting/expenses" replace />;
|
||||
if (flags.taxReport) return <Navigate to="/admin/accounting/tax-report" replace />;
|
||||
return null;
|
||||
};
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
X,
|
||||
Users,
|
||||
Briefcase,
|
||||
Landmark,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
} from 'lucide-react';
|
||||
@@ -91,11 +92,24 @@ const navigation: NavItem[] = [
|
||||
// can't disagree: any sub-feature on lights up the entry, all off
|
||||
// hides it. Future siblings (e.g. `messaging`) get appended here
|
||||
// AND in the context derivation.
|
||||
// taxReport intentionally excluded — Tax moved to the Accounting section
|
||||
// and is not a Clients sub-nav item, so it must not reveal Clients (would
|
||||
// open an empty ClientsLayout). Mirrors the context's `clients` derivation.
|
||||
featureFlagsAny: [
|
||||
'customerPortal', 'crmDevelopment', 'quotes', 'bills',
|
||||
'taxReport', 'hoursLogging', 'contracts', 'calendar',
|
||||
'hoursLogging', 'contracts', 'calendar', 'projects',
|
||||
],
|
||||
},
|
||||
// Accounting section (migration 122) — inbound supplier invoices,
|
||||
// expenses + re-bill, and the tax report (which relocates here from
|
||||
// the CRM sub-nav when `accounting` is on). Gated by the `accounting`
|
||||
// master flag; the sub-pages inside AccountingLayout are each
|
||||
// independently feature-gated.
|
||||
{
|
||||
nameKey: 'navigation.accounting', href: '/admin/accounting', icon: Landmark,
|
||||
permission: 'accounting.view',
|
||||
featureFlag: 'accounting',
|
||||
},
|
||||
];
|
||||
|
||||
export const AdminSidebar: React.FC<AdminSidebarProps> = ({ isOpen, onClose, collapsed = false, onToggleCollapse }) => {
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* Chart of accounts manager (Layer A) — embedded in Settings → Accounting.
|
||||
*
|
||||
* Full CRUD for the Swiss/LI KMU-Kontenrahmen accounts, plus the mappings the
|
||||
* Treuhänder export relies on: which account each expense category books to and
|
||||
* the default/system accounts. Sits alongside VatCodesManager so all accounting
|
||||
* configuration lives in one place.
|
||||
*
|
||||
* This data drives the export only — picpeak is not a double-entry ledger.
|
||||
*
|
||||
* NOTE: ledgerService.updateSettings is a PARTIAL merge, so this component saves
|
||||
* ONLY the account keys (SETTING_ACCOUNT_KEYS); the VAT maps are owned by
|
||||
* VatCodesManager. Scoping each patch keeps the two from reverting each other.
|
||||
*/
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'react-toastify';
|
||||
import { X, Plus, Pencil, Trash2, AlertCircle } from 'lucide-react';
|
||||
import { Button, Card, CardContent, Input, Loading } from '../common';
|
||||
import {
|
||||
ledgerService, type LedgerAccount, type AccountType, type LedgerSettings,
|
||||
} from '../../services/ledger.service';
|
||||
import { categoryLabel } from '../../services/accounting.service';
|
||||
|
||||
const ACCOUNT_TYPES: AccountType[] = ['asset', 'liability', 'equity', 'revenue', 'expense'];
|
||||
const labelCls = 'block text-xs font-medium text-neutral-700 dark:text-neutral-300 mb-1';
|
||||
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';
|
||||
const SETTING_ACCOUNT_KEYS: (keyof LedgerSettings)[] = [
|
||||
'ledger_account_debitoren', 'ledger_account_kreditoren', 'ledger_account_bank', 'ledger_account_cash',
|
||||
'ledger_account_default_revenue', 'ledger_account_default_expense',
|
||||
'ledger_account_mileage', 'ledger_account_per_diem', 'ledger_account_rebilled_revenue',
|
||||
];
|
||||
|
||||
// ── account modal ──────────────────────────────────────────────────────
|
||||
const AccountModal: React.FC<{ account?: LedgerAccount; onClose: () => void; onDone: () => void }> = ({ account, onClose, onDone }) => {
|
||||
const { t } = useTranslation();
|
||||
const isEdit = !!account;
|
||||
const [number, setNumber] = useState(account?.number ?? '');
|
||||
const [name, setName] = useState(account?.name ?? '');
|
||||
const [type, setType] = useState<AccountType>(account?.type ?? 'expense');
|
||||
const save = useMutation({
|
||||
mutationFn: () => isEdit ? ledgerService.updateAccount(account!.id, { number, name, type }) : ledgerService.createAccount({ number, name, type }),
|
||||
onSuccess: () => { toast.success(t('common.saved', 'Saved.')); onDone(); },
|
||||
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'),
|
||||
});
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-start justify-center bg-black/50 p-4">
|
||||
<div className="mt-20 w-full max-w-sm rounded-xl bg-white dark:bg-neutral-900 shadow-xl">
|
||||
<div className="flex items-center justify-between border-b border-neutral-200 dark:border-neutral-700 px-5 py-3">
|
||||
<h2 className="text-base font-semibold text-neutral-900 dark:text-neutral-100">{isEdit ? t('ledger.account.editTitle', 'Edit account') : t('ledger.account.addTitle', 'Add account')}</h2>
|
||||
<button onClick={onClose} className="text-neutral-400 hover:text-neutral-600"><X className="w-5 h-5" /></button>
|
||||
</div>
|
||||
<div className="px-5 py-4 space-y-3">
|
||||
<div><label className={labelCls}>{t('ledger.account.number', 'Account number')}</label><Input value={number} onChange={(e) => setNumber(e.target.value)} placeholder="6700" /></div>
|
||||
<div><label className={labelCls}>{t('ledger.account.name', 'Name')}</label><Input value={name} onChange={(e) => setName(e.target.value)} /></div>
|
||||
<div><label className={labelCls}>{t('ledger.account.type', 'Type')}</label>
|
||||
<select value={type} onChange={(e) => setType(e.target.value as AccountType)} className={selectCls}>
|
||||
{ACCOUNT_TYPES.map((tp) => <option key={tp} value={tp}>{t(`ledger.accountType.${tp}`, tp)}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 border-t border-neutral-200 dark:border-neutral-700 px-5 py-3">
|
||||
<Button variant="outline" onClick={onClose}>{t('common.cancel', 'Cancel')}</Button>
|
||||
<Button onClick={() => save.mutate()} disabled={save.isPending || !number || !name}>{save.isPending ? t('common.saving', 'Saving…') : t('common.save', 'Save')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ChartOfAccountsManager: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const [accountModal, setAccountModal] = useState<{ account?: LedgerAccount } | null>(null);
|
||||
|
||||
const { data: accounts, isLoading: la } = useQuery({ queryKey: ['ledger-accounts'], queryFn: () => ledgerService.listAccounts() });
|
||||
const { data: mappings, isLoading: lm } = useQuery({ queryKey: ['ledger-mappings'], queryFn: () => ledgerService.getMappings() });
|
||||
|
||||
// Local editable copy of the settings (default/system accounts only).
|
||||
const [settings, setSettings] = useState<LedgerSettings>({});
|
||||
useEffect(() => { if (mappings?.settings) setSettings(mappings.settings); }, [mappings?.settings]);
|
||||
|
||||
const accountOptions = useMemo(() => (accounts ?? []).filter((a) => a.active), [accounts]);
|
||||
|
||||
const refetchAll = () => { qc.invalidateQueries({ queryKey: ['ledger-accounts'] }); qc.invalidateQueries({ queryKey: ['ledger-vat-codes'] }); qc.invalidateQueries({ queryKey: ['ledger-mappings'] }); };
|
||||
|
||||
const delAccount = useMutation({
|
||||
mutationFn: (id: number) => ledgerService.deleteAccount(id),
|
||||
onSuccess: () => { toast.success(t('common.deleted', 'Deleted.')); refetchAll(); },
|
||||
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'),
|
||||
});
|
||||
const setCat = useMutation({
|
||||
mutationFn: ({ id, accId }: { id: number; accId: number | null }) => ledgerService.setCategoryAccount(id, accId),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['ledger-mappings'] }); },
|
||||
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'),
|
||||
});
|
||||
// Save ONLY the account keys — the VAT maps are owned by VatCodesManager and
|
||||
// updateSettings is a partial merge, so scoping the patch here prevents a
|
||||
// stale full-settings save from reverting the maps.
|
||||
const saveSettings = useMutation({
|
||||
mutationFn: () => {
|
||||
const patch: Partial<LedgerSettings> = {};
|
||||
for (const k of SETTING_ACCOUNT_KEYS) patch[k] = settings[k];
|
||||
return ledgerService.updateSettings(patch);
|
||||
},
|
||||
onSuccess: () => { toast.success(t('ledger.settingsSaved', 'Mappings saved.')); qc.invalidateQueries({ queryKey: ['ledger-mappings'] }); },
|
||||
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'),
|
||||
});
|
||||
|
||||
const setAcctSetting = (key: keyof LedgerSettings, value: string) => setSettings((s) => ({ ...s, [key]: value }));
|
||||
|
||||
if (la || lm) return <Loading />;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<p className="flex items-start gap-2 text-xs text-neutral-500 dark:text-neutral-400">
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0 mt-0.5" />
|
||||
<span>{t('ledger.intro', 'Used only to produce the Treuhänder export — picpeak does not keep double-entry books. The seeded chart + VAT codes follow the Swiss/LI KMU-Kontenrahmen; adjust them to match your Treuhänder’s setup.')}</span>
|
||||
</p>
|
||||
|
||||
{/* Default + system accounts */}
|
||||
<Card>
|
||||
<CardContent className="p-5">
|
||||
<h2 className="text-base font-semibold text-neutral-900 dark:text-neutral-100 mb-3">{t('ledger.defaults.title', 'Default & system accounts')}</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{SETTING_ACCOUNT_KEYS.map((key) => (
|
||||
<div key={key}>
|
||||
<label className={labelCls}>{t(`ledger.defaults.${key}`, key)}</label>
|
||||
<select value={settings[key] as string ?? ''} onChange={(e) => setAcctSetting(key, e.target.value)} className={selectCls}>
|
||||
<option value="">{t('ledger.defaults.none', '— none —')}</option>
|
||||
{accountOptions.map((a) => <option key={a.id} value={a.number}>{a.number} · {a.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end">
|
||||
<Button onClick={() => saveSettings.mutate()} disabled={saveSettings.isPending}>{saveSettings.isPending ? t('common.saving', 'Saving…') : t('ledger.saveDefaults', 'Save mappings')}</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Category → account */}
|
||||
<Card>
|
||||
<CardContent className="p-5">
|
||||
<h2 className="text-base font-semibold text-neutral-900 dark:text-neutral-100 mb-3">{t('ledger.categoryMap.title', 'Expense category → account')}</h2>
|
||||
<div className="space-y-2">
|
||||
{(mappings?.categories ?? []).map((c) => (
|
||||
<div key={c.id} className="flex items-center gap-3">
|
||||
<span className="flex-1 text-sm text-neutral-800 dark:text-neutral-200">{categoryLabel(c as any, t)}</span>
|
||||
<select value={c.ledger_account_id ?? ''} onChange={(e) => setCat.mutate({ id: c.id, accId: e.target.value ? Number(e.target.value) : null })} className={selectCls} style={{ maxWidth: 320 }}>
|
||||
<option value="">{t('ledger.defaults.none', '— none —')}</option>
|
||||
{accountOptions.filter((a) => a.type === 'expense').map((a) => <option key={a.id} value={a.id}>{a.number} · {a.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Chart of accounts */}
|
||||
<Card>
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-base font-semibold text-neutral-900 dark:text-neutral-100">{t('ledger.accounts.title', 'Chart of accounts')}</h2>
|
||||
<Button size="sm" onClick={() => setAccountModal({})}><Plus className="w-4 h-4 mr-1" /> {t('ledger.account.addTitle', 'Add account')}</Button>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-left text-neutral-500 dark:text-neutral-400 border-b border-neutral-200 dark:border-neutral-700">
|
||||
<tr>
|
||||
<th className="py-1.5 pr-3 font-medium">{t('ledger.account.number', 'No.')}</th>
|
||||
<th className="py-1.5 pr-3 font-medium">{t('ledger.account.name', 'Name')}</th>
|
||||
<th className="py-1.5 pr-3 font-medium">{t('ledger.account.type', 'Type')}</th>
|
||||
<th className="py-1.5 pr-3 font-medium text-right">{t('common.actions', 'Actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-neutral-100 dark:divide-neutral-800">
|
||||
{(accounts ?? []).map((a) => (
|
||||
<tr key={a.id} className={a.active ? '' : 'opacity-50'}>
|
||||
<td className="py-1.5 pr-3 tabular-nums font-medium text-neutral-900 dark:text-neutral-100">{a.number}</td>
|
||||
<td className="py-1.5 pr-3 text-neutral-800 dark:text-neutral-200">{a.name}</td>
|
||||
<td className="py-1.5 pr-3 text-neutral-500 dark:text-neutral-400">{t(`ledger.accountType.${a.type}`, a.type)}</td>
|
||||
<td className="py-1.5 pr-3">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button onClick={() => setAccountModal({ account: a })} className="p-1 text-neutral-500 hover:text-neutral-800 dark:hover:text-neutral-200"><Pencil className="w-4 h-4" /></button>
|
||||
<button onClick={() => { if (window.confirm(t('ledger.account.confirmDelete', 'Delete this account?') as string)) delAccount.mutate(a.id); }} className="p-1 text-neutral-400 hover:text-red-600"><Trash2 className="w-4 h-4" /></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{accountModal && <AccountModal account={accountModal.account} onClose={() => setAccountModal(null)} onDone={() => { setAccountModal(null); refetchAll(); }} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChartOfAccountsManager;
|
||||
@@ -88,13 +88,8 @@ export const ClientsLayout: React.FC = () => {
|
||||
icon: Receipt,
|
||||
featureFlag: 'bills',
|
||||
},
|
||||
{
|
||||
key: 'tax-report',
|
||||
to: '/admin/clients/tax-report',
|
||||
label: t('clients.subnav.taxReport', 'Tax'),
|
||||
icon: Calculator,
|
||||
featureFlag: 'taxReport',
|
||||
},
|
||||
// Tax export moved permanently to the Accounting section (it is no
|
||||
// longer a CRM sub-feature). See AccountingLayout.
|
||||
// Future sub-features:
|
||||
// { key: 'messaging', ... featureFlag: 'messaging' }
|
||||
{
|
||||
|
||||
@@ -120,10 +120,10 @@ export const CustomerAccountPicker: React.FC<Props> = ({ value, onChange, disabl
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
<label className="block text-sm font-medium text-theme mb-1">
|
||||
<label className="block text-sm font-medium text-neutral-900 dark:text-neutral-100 mb-1">
|
||||
{t('events.customerPicker.label', 'Customer accounts')}
|
||||
</label>
|
||||
<p className="text-xs text-muted-theme mb-2">{helpText}</p>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-2">{helpText}</p>
|
||||
|
||||
{/* Selected chips */}
|
||||
{value.length > 0 && (
|
||||
@@ -131,16 +131,11 @@ export const CustomerAccountPicker: React.FC<Props> = ({ value, onChange, disabl
|
||||
{value.map((c) => (
|
||||
<span
|
||||
key={c.id}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-elevated, #f5f5f5)',
|
||||
color: 'var(--color-text)',
|
||||
border: '1px solid var(--color-surface-border, #e5e5e5)',
|
||||
}}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs bg-neutral-100 dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 border border-neutral-200 dark:border-neutral-700"
|
||||
>
|
||||
<span className="font-medium">{c.displayName?.trim() || c.email}</span>
|
||||
{c.displayName?.trim() && c.email !== c.displayName && (
|
||||
<span className="text-muted-theme">· {c.email}</span>
|
||||
<span className="text-neutral-500 dark:text-neutral-400">· {c.email}</span>
|
||||
)}
|
||||
{!disabled && (
|
||||
<button
|
||||
@@ -174,18 +169,14 @@ export const CustomerAccountPicker: React.FC<Props> = ({ value, onChange, disabl
|
||||
{/* Dropdown */}
|
||||
{isOpen && query.trim() !== '' && (
|
||||
<div
|
||||
className="absolute left-0 right-0 mt-1 z-20 rounded-lg shadow-lg border max-h-72 overflow-y-auto"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface, #ffffff)',
|
||||
borderColor: 'var(--color-surface-border, #e5e5e5)',
|
||||
}}
|
||||
className="absolute left-0 right-0 mt-1 z-20 rounded-lg shadow-lg border max-h-72 overflow-y-auto bg-white dark:bg-neutral-900 border-neutral-200 dark:border-neutral-700"
|
||||
>
|
||||
{isSearching ? (
|
||||
<div className="px-3 py-3 text-sm text-muted-theme">
|
||||
<div className="px-3 py-3 text-sm text-neutral-500 dark:text-neutral-400">
|
||||
{t('events.customerPicker.searching', 'Searching…')}
|
||||
</div>
|
||||
) : results.length === 0 ? (
|
||||
<div className="px-3 py-3 text-sm text-muted-theme">
|
||||
<div className="px-3 py-3 text-sm text-neutral-500 dark:text-neutral-400">
|
||||
{t('events.customerPicker.noResults', 'No matches. Invite this customer from Clients → Accounts first.')}
|
||||
</div>
|
||||
) : (
|
||||
@@ -197,7 +188,7 @@ export const CustomerAccountPicker: React.FC<Props> = ({ value, onChange, disabl
|
||||
onClick={() => select(r)}
|
||||
className="w-full text-left px-3 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-neutral-700 flex items-center gap-2"
|
||||
>
|
||||
<UserPlus className="w-4 h-4 text-muted-theme flex-shrink-0" />
|
||||
<UserPlus className="w-4 h-4 text-neutral-500 dark:text-neutral-400 flex-shrink-0" />
|
||||
<span className="flex-1 truncate">{labelFor(r)}</span>
|
||||
</button>
|
||||
</li>
|
||||
|
||||
@@ -57,7 +57,7 @@ const QuotesPanel: React.FC<Props> = ({ customerAccountId }) => {
|
||||
return (
|
||||
<Card padding="lg">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-lg font-semibold text-theme flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 flex items-center gap-2">
|
||||
<FileText className="w-5 h-5" /> {t('customers.detail.quotesSection', 'Quotes')}
|
||||
</h2>
|
||||
<div className="flex gap-2">
|
||||
@@ -78,18 +78,18 @@ const QuotesPanel: React.FC<Props> = ({ customerAccountId }) => {
|
||||
</div>
|
||||
|
||||
{isLoading ? <Loading /> : !data || data.quotes.length === 0 ? (
|
||||
<p className="text-sm text-muted-theme">
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
{t('customers.detail.noQuotes', 'No quotes for this customer yet.')}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y" style={{ borderColor: 'var(--color-surface-border)' }}>
|
||||
<ul className="divide-y divide-neutral-200 dark:divide-neutral-700">
|
||||
{data.quotes.map((q) => (
|
||||
<li key={q.id} className="py-2 flex items-center justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<Link to={`/admin/clients/quotes/${q.id}`} className="text-theme hover:underline font-mono text-sm">
|
||||
<Link to={`/admin/clients/quotes/${q.id}`} className="text-neutral-900 dark:text-neutral-100 hover:underline font-mono text-sm">
|
||||
{q.quoteNumber}
|
||||
</Link>
|
||||
<span className="text-xs text-muted-theme ml-2">{q.eventName || fmtDate(q.issueDate)}</span>
|
||||
<span className="text-xs text-neutral-500 dark:text-neutral-400 ml-2">{q.eventName || fmtDate(q.issueDate)}</span>
|
||||
</div>
|
||||
<span className="text-sm tabular-nums">{formatMoney(Number(q.totalAmountMinor) / 100, q.currency)}</span>
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${
|
||||
@@ -118,7 +118,7 @@ const ContractsPanel: React.FC<Props> = ({ customerAccountId }) => {
|
||||
return (
|
||||
<Card padding="lg">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-lg font-semibold text-theme flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 flex items-center gap-2">
|
||||
<ScrollText className="w-5 h-5" /> {t('customers.detail.contractsSection', 'Contracts')}
|
||||
</h2>
|
||||
<div className="flex gap-2">
|
||||
@@ -132,18 +132,18 @@ const ContractsPanel: React.FC<Props> = ({ customerAccountId }) => {
|
||||
</div>
|
||||
|
||||
{isLoading ? <Loading /> : !data || data.contracts.length === 0 ? (
|
||||
<p className="text-sm text-muted-theme">
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
{t('customers.detail.noContracts', 'No contracts for this customer yet.')}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y" style={{ borderColor: 'var(--color-surface-border)' }}>
|
||||
<ul className="divide-y divide-neutral-200 dark:divide-neutral-700">
|
||||
{data.contracts.map((c) => (
|
||||
<li key={c.id} className="py-2 flex items-center justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<Link to={`/admin/clients/contracts/${c.id}`} className="text-theme hover:underline font-mono text-sm">
|
||||
<Link to={`/admin/clients/contracts/${c.id}`} className="text-neutral-900 dark:text-neutral-100 hover:underline font-mono text-sm">
|
||||
{c.contractNumber}
|
||||
</Link>
|
||||
<span className="text-xs text-muted-theme ml-2 truncate">{c.title || fmtDate(c.issueDate)}</span>
|
||||
<span className="text-xs text-neutral-500 dark:text-neutral-400 ml-2 truncate">{c.title || fmtDate(c.issueDate)}</span>
|
||||
</div>
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${
|
||||
c.status === 'fully_signed' ? 'bg-green-100 text-green-800'
|
||||
@@ -172,7 +172,7 @@ const InvoicesPanel: React.FC<Props> = ({ customerAccountId }) => {
|
||||
return (
|
||||
<Card padding="lg">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-lg font-semibold text-theme flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 flex items-center gap-2">
|
||||
<Receipt className="w-5 h-5" /> {t('customers.detail.billsSection', 'Invoices')}
|
||||
</h2>
|
||||
<div className="flex gap-2">
|
||||
@@ -187,18 +187,18 @@ const InvoicesPanel: React.FC<Props> = ({ customerAccountId }) => {
|
||||
</div>
|
||||
|
||||
{isLoading ? <Loading /> : !data || data.invoices.length === 0 ? (
|
||||
<p className="text-sm text-muted-theme">
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
{t('customers.detail.noBills', 'No invoices for this customer yet.')}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y" style={{ borderColor: 'var(--color-surface-border)' }}>
|
||||
<ul className="divide-y divide-neutral-200 dark:divide-neutral-700">
|
||||
{data.invoices.map((inv) => (
|
||||
<li key={inv.id} className="py-2 flex items-center justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<Link to={`/admin/clients/bills/${inv.id}`} className="text-theme hover:underline font-mono text-sm">
|
||||
<Link to={`/admin/clients/bills/${inv.id}`} className="text-neutral-900 dark:text-neutral-100 hover:underline font-mono text-sm">
|
||||
{inv.invoiceNumber}
|
||||
</Link>
|
||||
<span className="text-xs text-muted-theme ml-2">
|
||||
<span className="text-xs text-neutral-500 dark:text-neutral-400 ml-2">
|
||||
{fmtDate(inv.dueDate)}
|
||||
{inv.installmentTotal > 1 ? ` · ${inv.installmentIndex + 1}/${inv.installmentTotal}` : ''}
|
||||
</span>
|
||||
|
||||
@@ -62,8 +62,8 @@ const Toggle: React.FC<ToggleProps> = ({ enabled, onChange, label, hint, icon: I
|
||||
role="switch"
|
||||
aria-checked={enabled}
|
||||
onClick={onChange}
|
||||
className="relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
style={{ backgroundColor: enabled ? 'var(--color-accent, #5C8762)' : '#cbd5e1' }}
|
||||
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 ${enabled ? '' : 'bg-neutral-300 dark:bg-neutral-600'}`}
|
||||
style={enabled ? { backgroundColor: 'var(--color-accent, #5C8762)' } : undefined}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${enabled ? 'translate-x-6' : 'translate-x-1'}`}
|
||||
|
||||
@@ -91,7 +91,7 @@ export const DocumentLineageCard: React.FC<DocumentLineageCardProps> = ({
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card padding="md" className={className}>
|
||||
<p className="text-sm text-muted-theme">
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
{t('dealLineage.loading', 'Loading related documents…')}
|
||||
</p>
|
||||
</Card>
|
||||
@@ -128,7 +128,7 @@ export const DocumentLineageCard: React.FC<DocumentLineageCardProps> = ({
|
||||
<h2 className="font-semibold mb-1 flex items-center gap-2">
|
||||
{t('dealLineage.title', 'Related documents')}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-theme">
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{t('dealLineage.empty', 'No other documents share this deal yet. New invoices, contracts, or installments will show up here once created.')}
|
||||
</p>
|
||||
</Card>
|
||||
@@ -256,7 +256,7 @@ const Group: React.FC<{
|
||||
action?: React.ReactNode;
|
||||
}> = ({ icon, label, count, children, action }) => (
|
||||
<div className="mb-3 last:mb-0">
|
||||
<div className="flex items-center gap-2 text-xs uppercase tracking-wider text-muted-theme mb-1">
|
||||
<div className="flex items-center gap-2 text-xs uppercase tracking-wider text-neutral-500 dark:text-neutral-400 mb-1">
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
<span>({count})</span>
|
||||
@@ -282,19 +282,19 @@ const Row: React.FC<{
|
||||
const inner = (
|
||||
<div className="flex items-center justify-between gap-3 py-1.5">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className={`font-mono text-sm ${isCurrent ? 'text-muted-theme' : ''}`}>{number}</span>
|
||||
<span className={`font-mono text-sm ${isCurrent ? 'text-neutral-500 dark:text-neutral-400' : ''}`}>{number}</span>
|
||||
{badge && (
|
||||
<span className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded font-semibold bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-300">
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
{meta && (
|
||||
<span className="text-xs text-muted-theme truncate">{meta}</span>
|
||||
<span className="text-xs text-neutral-500 dark:text-neutral-400 truncate">{meta}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0 text-xs">
|
||||
{right && <span className="tabular-nums">{right}</span>}
|
||||
<span className="text-muted-theme">{t(statusKey, statusFallback)}</span>
|
||||
<span className="text-neutral-500 dark:text-neutral-400">{t(statusKey, statusFallback)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -153,7 +153,7 @@ export const EditInstallmentPlanModal: React.FC<EditInstallmentPlanModalProps> =
|
||||
<h2 className="text-xl font-semibold">
|
||||
{t('dealLineage.editPlanModalTitle', 'Edit installment plan')}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-theme mt-1">
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||
{t('dealLineage.editPlanHelp',
|
||||
'Atomically reshape this plan: change percents, labels, triggers, add or remove rows. The plan total stays fixed; existing invoice numbers are kept where possible. Refused once any invoice has shipped.')}
|
||||
</p>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Booking-target dropdown for accounting (expenses + incoming invoices).
|
||||
* "Company" (value null) or a specific event. Projects remain a separate
|
||||
* aggregation of events and are intentionally NOT a booking target here.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { eventsService } from '../../services/events.service';
|
||||
|
||||
interface Props {
|
||||
value: number | null;
|
||||
onChange: (eventId: number | null) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const EventBookingSelect: React.FC<Props> = ({ value, onChange, className }) => {
|
||||
const { t } = useTranslation();
|
||||
const { data } = useQuery({
|
||||
queryKey: ['events-for-booking'],
|
||||
queryFn: () => eventsService.getEvents(1, 200),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
const events = data?.events ?? [];
|
||||
const cls = className
|
||||
|| 'w-full rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-3 py-2 text-sm';
|
||||
|
||||
return (
|
||||
<select className={cls} value={value == null ? '' : String(value)}
|
||||
onChange={(e) => onChange(e.target.value ? Number(e.target.value) : null)}>
|
||||
<option value="">{t('accounting.booking.company', 'Company')}</option>
|
||||
{events.map((ev) => (
|
||||
<option key={ev.id} value={ev.id}>{ev.event_name}</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventBookingSelect;
|
||||
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* Incoming mail (IMAP) configuration — a second block under the outgoing SMTP
|
||||
* settings, styled to match the SMTP card (icon inputs, password eye toggle,
|
||||
* full-width Save). Shown only when the `incomingMail` feature flag is on.
|
||||
*
|
||||
* The Folder field auto-detects: "Detect folders" lists the mailboxes on the
|
||||
* server and offers them as a dropdown (auto-selecting the inbox), instead of
|
||||
* making the admin type a path.
|
||||
*/
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Save, Server, User, Lock, Eye, EyeOff, FolderSearch, PlugZap, Mailbox, RefreshCw } from 'lucide-react';
|
||||
import { Button, Card, Input, Loading } from '../common';
|
||||
import { emailService, type IncomingMailConfig, type ImapFolder } from '../../services/email.service';
|
||||
|
||||
const labelCls = 'block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1';
|
||||
const selectCls = 'w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-accent-dark';
|
||||
|
||||
export const IncomingMailConfigCard: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const { data, isLoading } = useQuery({ queryKey: ['incoming-mail-config'], queryFn: () => emailService.getIncomingConfig() });
|
||||
const [cfg, setCfg] = useState<IncomingMailConfig>({ imap_host: '', imap_port: 993, imap_secure: true, imap_user: '', imap_pass: '', imap_folder: 'INBOX' });
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [folders, setFolders] = useState<ImapFolder[] | null>(null);
|
||||
|
||||
useEffect(() => { if (data) setCfg(data); }, [data]);
|
||||
|
||||
const set = (k: keyof IncomingMailConfig, v: any) => setCfg((c) => ({ ...c, [k]: v }));
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () => {
|
||||
// Mirror the SMTP card's client-side required guard. Host + port +
|
||||
// username are needed for the poller to authenticate (getImapConfig
|
||||
// returns null without host+user).
|
||||
if (!cfg.imap_host || !cfg.imap_port || !cfg.imap_user) {
|
||||
return Promise.reject(new Error(t('email.incoming.requiredFields', 'Host, port and username are required.')));
|
||||
}
|
||||
return emailService.updateIncomingConfig(cfg);
|
||||
},
|
||||
onSuccess: () => { toast.success(t('email.incoming.savedToast', 'Incoming mail settings saved.')); qc.invalidateQueries({ queryKey: ['incoming-mail-config'] }); },
|
||||
onError: (e: any) => toast.error(e?.response?.data?.error || e?.response?.data?.errors?.[0]?.msg || e.message || 'Failed'),
|
||||
});
|
||||
|
||||
const test = useMutation({
|
||||
mutationFn: () => emailService.testIncoming(cfg),
|
||||
onSuccess: (r) => toast.success(t('email.incoming.testOk', 'Connected to {{folder}} — {{messages}} messages, {{unseen}} unread.', { folder: r.folder, messages: r.messages, unseen: r.unseen })),
|
||||
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || t('email.incoming.testFailed', 'Connection failed.')),
|
||||
});
|
||||
|
||||
const roundTrip = useMutation({
|
||||
mutationFn: () => emailService.roundTripIncoming(),
|
||||
onSuccess: (r) => toast.success(t('email.incoming.roundTripOk', 'Round-trip OK — delivered to {{recipient}} in {{seconds}}s.', { recipient: r.recipient, seconds: r.seconds })),
|
||||
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || t('email.incoming.roundTripFailed', 'Round-trip test failed.')),
|
||||
});
|
||||
|
||||
const poll = useMutation({
|
||||
mutationFn: () => emailService.pollIncoming(),
|
||||
onSuccess: (r) => {
|
||||
if (r.skipped === 'disabled') {
|
||||
toast.info(t('email.incoming.pollDisabled', 'Incoming mail is turned off — enable it under Settings → Features.'));
|
||||
} else if (r.skipped === 'unconfigured') {
|
||||
toast.info(t('email.incoming.pollUnconfigured', 'Save the incoming mail settings first.'));
|
||||
} else if (r.skipped === 'busy') {
|
||||
toast.info(t('email.incoming.pollBusy', 'A poll is already running — try again in a moment.'));
|
||||
} else {
|
||||
toast.success(t('email.incoming.pollOk', 'Checked mailbox — {{count}} new email(s) ingested.', { count: r.processed || 0 }));
|
||||
qc.invalidateQueries({ queryKey: ['received-emails'] });
|
||||
}
|
||||
},
|
||||
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || t('email.incoming.pollFailed', 'Mailbox poll failed.')),
|
||||
});
|
||||
|
||||
const detect = useMutation({
|
||||
mutationFn: () => emailService.listIncomingFolders(cfg),
|
||||
onSuccess: (list) => {
|
||||
setFolders(list);
|
||||
if (list.length) {
|
||||
// Auto-select the inbox (special-use '\Inbox', else a path named INBOX)
|
||||
// when the current folder isn't one of the detected ones.
|
||||
const has = list.some((f) => f.path === cfg.imap_folder);
|
||||
if (!has) {
|
||||
const inbox = list.find((f) => (f.specialUse || '').toLowerCase().includes('inbox'))
|
||||
|| list.find((f) => f.path.toUpperCase() === 'INBOX') || list[0];
|
||||
if (inbox) set('imap_folder', inbox.path);
|
||||
}
|
||||
toast.success(t('email.incoming.foldersDetected', '{{count}} folders found.', { count: list.length }));
|
||||
} else {
|
||||
toast.info(t('email.incoming.noFolders', 'No folders returned by the server.'));
|
||||
}
|
||||
},
|
||||
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || t('email.incoming.detectFailed', 'Could not detect folders.')),
|
||||
});
|
||||
|
||||
if (isLoading) return <Loading />;
|
||||
|
||||
return (
|
||||
<Card padding="md" className="mt-6">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-1">{t('email.incoming.title', 'Incoming mail (IMAP)')}</h2>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">{t('email.incoming.subtitle', 'A dedicated mailbox polled every minute; attachments land in Accounting → Incoming invoices.')}</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className={labelCls}>{t('email.incoming.host', 'IMAP Host')} <span className="text-red-500">*</span></label>
|
||||
<Input
|
||||
type="text"
|
||||
value={cfg.imap_host}
|
||||
onChange={(e) => set('imap_host', e.target.value)}
|
||||
placeholder="imap.example.com"
|
||||
leftIcon={<Server className="w-5 h-5 text-neutral-400" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelCls}>{t('email.incoming.port', 'Port')} <span className="text-red-500">*</span></label>
|
||||
<Input type="number" value={cfg.imap_port} onChange={(e) => set('imap_port', parseInt(e.target.value, 10) || 0)} placeholder="993" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>{t('email.incoming.security', 'Security')}</label>
|
||||
<select className={selectCls} value={cfg.imap_secure ? 'ssl' : 'plain'} onChange={(e) => set('imap_secure', e.target.value === 'ssl')}>
|
||||
<option value="ssl">{t('email.incoming.ssl', 'SSL/TLS')}</option>
|
||||
<option value="plain">{t('email.incoming.plain', 'None / STARTTLS')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelCls}>{t('email.incoming.user', 'Username')} <span className="text-red-500">*</span></label>
|
||||
<Input
|
||||
type="text"
|
||||
value={cfg.imap_user}
|
||||
onChange={(e) => set('imap_user', e.target.value)}
|
||||
autoComplete="off"
|
||||
placeholder="[email protected]"
|
||||
leftIcon={<User className="w-5 h-5 text-neutral-400" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelCls}>{t('email.incoming.pass', 'Password')}</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={cfg.imap_pass}
|
||||
onChange={(e) => set('imap_pass', e.target.value)}
|
||||
autoComplete="new-password"
|
||||
placeholder={t('email.enterPassword', 'Enter password')}
|
||||
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
|
||||
/>
|
||||
<button type="button" onClick={() => setShowPassword(!showPassword)} className="absolute right-3 top-3 text-neutral-400 hover:text-neutral-600">
|
||||
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelCls}>{t('email.incoming.folder', 'Folder')}</label>
|
||||
<div className="flex gap-2">
|
||||
{folders && folders.length > 0 ? (
|
||||
<select className={selectCls} value={cfg.imap_folder} onChange={(e) => set('imap_folder', e.target.value)}>
|
||||
{folders.some((f) => f.path === cfg.imap_folder) ? null : <option value={cfg.imap_folder}>{cfg.imap_folder}</option>}
|
||||
{folders.map((f) => <option key={f.path} value={f.path}>{f.path}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
<Input type="text" value={cfg.imap_folder} onChange={(e) => set('imap_folder', e.target.value)} placeholder="INBOX" />
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => detect.mutate()}
|
||||
isLoading={detect.isPending}
|
||||
disabled={!cfg.imap_host || !cfg.imap_user}
|
||||
leftIcon={<FolderSearch className="w-4 h-4" />}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{t('email.incoming.detectFolders', 'Detect')}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">{t('email.incoming.folderHint', 'Enter host, username and password, then Detect to list the mailbox folders.')}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => test.mutate()}
|
||||
isLoading={test.isPending}
|
||||
disabled={!cfg.imap_host || !cfg.imap_user}
|
||||
leftIcon={<PlugZap className="w-5 h-5" />}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{t('email.incoming.test', 'Test connection')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => roundTrip.mutate()}
|
||||
isLoading={roundTrip.isPending}
|
||||
disabled={!cfg.imap_host || !cfg.imap_user}
|
||||
leftIcon={<Mailbox className="w-5 h-5" />}
|
||||
className="whitespace-nowrap"
|
||||
title={t('email.incoming.roundTripHint', 'Sends a test email via your SMTP settings to this mailbox and confirms it arrives. Save both first.') as string}
|
||||
>
|
||||
{t('email.incoming.roundTrip', 'Round-trip test')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => poll.mutate()}
|
||||
isLoading={poll.isPending}
|
||||
disabled={!cfg.imap_host || !cfg.imap_user}
|
||||
leftIcon={<RefreshCw className="w-5 h-5" />}
|
||||
className="whitespace-nowrap"
|
||||
title={t('email.incoming.pollHint', 'Check the mailbox now instead of waiting for the 60-second poll. Ingests unread attachments into Incoming invoices.') as string}
|
||||
>
|
||||
{t('email.incoming.poll', 'Check now')}
|
||||
</Button>
|
||||
<Button variant="primary" onClick={() => save.mutate()} isLoading={save.isPending} leftIcon={<Save className="w-5 h-5" />} className="flex-1 min-w-[12rem]">
|
||||
{t('email.incoming.save', 'Save Incoming Mail Settings')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default IncomingMailConfigCard;
|
||||
@@ -177,7 +177,7 @@ export const InstallmentsPanel: React.FC<InstallmentsPanelProps> = ({
|
||||
|
||||
{enabled && (
|
||||
<>
|
||||
<p className="text-xs text-muted-theme mb-3">
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-3">
|
||||
{advanced
|
||||
? t('installments.advancedHint',
|
||||
'Pick a trigger (quote accepted, before/after event, on delivery, fixed date) plus offset in days. Triggers re-resolve if the event date later shifts.')
|
||||
@@ -192,7 +192,7 @@ export const InstallmentsPanel: React.FC<InstallmentsPanelProps> = ({
|
||||
className="grid grid-cols-12 gap-2 items-end p-2 rounded-md bg-neutral-50 dark:bg-neutral-800/40"
|
||||
>
|
||||
<div className="col-span-2">
|
||||
<label className="block text-xs text-muted-theme mb-1">
|
||||
<label className="block text-xs text-neutral-500 dark:text-neutral-400 mb-1">
|
||||
{t('installments.percent', '%')}
|
||||
</label>
|
||||
<Input
|
||||
@@ -206,7 +206,7 @@ export const InstallmentsPanel: React.FC<InstallmentsPanelProps> = ({
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-4">
|
||||
<label className="block text-xs text-muted-theme mb-1">
|
||||
<label className="block text-xs text-neutral-500 dark:text-neutral-400 mb-1">
|
||||
{t('installments.label', 'Label')}
|
||||
</label>
|
||||
<Input
|
||||
@@ -219,11 +219,11 @@ export const InstallmentsPanel: React.FC<InstallmentsPanelProps> = ({
|
||||
|
||||
{!advanced ? (
|
||||
<div className="col-span-5">
|
||||
<label className="block text-xs text-muted-theme mb-1">
|
||||
<label className="block text-xs text-neutral-500 dark:text-neutral-400 mb-1">
|
||||
{t('installments.sendOn', 'Send on')}
|
||||
</label>
|
||||
{row.trigger === 'after_delivery' ? (
|
||||
<div className="text-xs text-muted-theme py-2">
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400 py-2">
|
||||
{t('installments.onDeliveryHint',
|
||||
'On delivery — admin releases manually. Switch to advanced to change.')}
|
||||
</div>
|
||||
@@ -242,7 +242,7 @@ export const InstallmentsPanel: React.FC<InstallmentsPanelProps> = ({
|
||||
) : (
|
||||
<>
|
||||
<div className="col-span-3">
|
||||
<label className="block text-xs text-muted-theme mb-1">
|
||||
<label className="block text-xs text-neutral-500 dark:text-neutral-400 mb-1">
|
||||
{t('installments.trigger', 'Trigger')}
|
||||
</label>
|
||||
<select
|
||||
@@ -262,7 +262,7 @@ export const InstallmentsPanel: React.FC<InstallmentsPanelProps> = ({
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="block text-xs text-muted-theme mb-1">
|
||||
<label className="block text-xs text-neutral-500 dark:text-neutral-400 mb-1">
|
||||
{t('installments.offsetDays', 'Offset (days)')}
|
||||
</label>
|
||||
<Input
|
||||
|
||||
@@ -87,12 +87,12 @@ export const PasswordResetModal: React.FC<PasswordResetModalProps> = ({
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<Card className="max-w-md w-full">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-semibold text-neutral-900">
|
||||
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{resultPassword ? t('events.passwordReset.newTitle') : t('events.passwordReset.title')}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-neutral-400 hover:text-neutral-600"
|
||||
className="text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
@@ -163,12 +163,12 @@ export const PasswordResetModal: React.FC<PasswordResetModalProps> = ({
|
||||
type="checkbox"
|
||||
checked={sendEmail}
|
||||
onChange={(e) => setSendEmail(e.target.checked)}
|
||||
className="w-4 h-4 text-accent bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500 focus:ring-2"
|
||||
className="w-4 h-4 text-accent bg-neutral-100 dark:bg-neutral-700 border-neutral-300 dark:border-neutral-600 rounded focus:ring-primary-500 focus:ring-2"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Mail className="w-4 h-4 text-neutral-500" />
|
||||
<span className="text-sm font-medium text-neutral-700">
|
||||
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||
{t('events.passwordReset.sendEmail')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Received-emails feed — read-only, paginated view of the received_emails log
|
||||
* (the IMAP poller's audit trail). Rendered as the "Received emails" tab in
|
||||
* EmailConfigPage, next to "Sent emails".
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Inbox, Paperclip } from 'lucide-react';
|
||||
import { Card, Loading, Button } from '../common';
|
||||
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||
import { emailService } from '../../services/email.service';
|
||||
|
||||
const statusClass = (s: string): string =>
|
||||
s === 'ingested' ? 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300'
|
||||
: s === 'error' ? 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300'
|
||||
: 'bg-neutral-200 text-neutral-700 dark:bg-neutral-700 dark:text-neutral-300';
|
||||
|
||||
export const ReceivedEmailsPanel: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { formatDateTime: fmtDateTime } = useLocalizedDate();
|
||||
const [page, setPage] = useState(1);
|
||||
const { data, isLoading } = useQuery({ queryKey: ['received-emails', page], queryFn: () => emailService.listReceived({ page, pageSize: 25 }), refetchInterval: 30000, refetchOnWindowFocus: true });
|
||||
|
||||
if (isLoading) return <Loading />;
|
||||
const items = data?.items ?? [];
|
||||
const pg = data?.pagination;
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<Card className="p-8 text-center">
|
||||
<Inbox className="w-10 h-10 mx-auto mb-3 text-neutral-400" />
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400">{t('email.received.empty', 'No received emails yet. Enable incoming mail and configure the mailbox.')}</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="p-0 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-neutral-50 dark:bg-neutral-800/50 text-left text-xs uppercase text-neutral-500 dark:text-neutral-400">
|
||||
<tr>
|
||||
<th className="px-4 py-2">{t('email.received.from', 'From')}</th>
|
||||
<th className="px-4 py-2">{t('email.received.subject', 'Subject')}</th>
|
||||
<th className="px-4 py-2">{t('email.received.received', 'Received')}</th>
|
||||
<th className="px-4 py-2">{t('email.received.status', 'Status')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-neutral-100 dark:divide-neutral-800">
|
||||
{items.map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td className="px-4 py-2 text-neutral-700 dark:text-neutral-300 truncate max-w-[14rem]">{r.from_address || '—'}</td>
|
||||
<td className="px-4 py-2 text-neutral-900 dark:text-neutral-100">
|
||||
<span className="truncate inline-block max-w-[18rem] align-middle">{r.subject || '—'}</span>
|
||||
{r.attachment_count > 0 && (
|
||||
<span className="ml-2 inline-flex items-center gap-0.5 text-xs text-neutral-500">
|
||||
<Paperclip className="w-3 h-3" />{r.attachment_count}
|
||||
{r.inbound_document_id && <Link to="/admin/accounting/inbox" className="ml-1 text-primary-600 hover:underline">{t('email.received.inbox', 'inbox')}</Link>}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-neutral-500 dark:text-neutral-400 whitespace-nowrap">{r.received_at ? fmtDateTime(r.received_at) : '—'}</td>
|
||||
<td className="px-4 py-2"><span className={`inline-block rounded px-2 py-0.5 text-xs font-medium ${statusClass(r.status)}`}>{t(`email.received.statusValue.${r.status}`, r.status)}</span></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{pg && pg.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-neutral-100 dark:border-neutral-800 text-sm">
|
||||
<Button size="sm" variant="outline" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page <= 1}>{t('common.previous', 'Previous')}</Button>
|
||||
<span className="text-neutral-500">{page} / {pg.totalPages}</span>
|
||||
<Button size="sm" variant="outline" onClick={() => setPage((p) => Math.min(pg.totalPages, p + 1))} disabled={page >= pg.totalPages}>{t('common.next', 'Next')}</Button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReceivedEmailsPanel;
|
||||
@@ -184,6 +184,15 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
slotBeforeCustomCss
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
// A force lock (instance-wide light/dark) overrides the per-theme color
|
||||
// mode. On the Branding page (where the Force control lives —
|
||||
// onForceColorModeChange is provided) we hide only the now-redundant
|
||||
// per-theme Color Mode picker. In per-event gallery editors (no Force
|
||||
// control) we ALSO hide the colour pickers, since a gallery can't override
|
||||
// the site-wide lock. Presets, fonts and style always stay.
|
||||
const forcedColorActive = (forceColorMode ?? null) !== null;
|
||||
const isBrandingContext = !!onForceColorModeChange;
|
||||
const hideGalleryColors = forcedColorActive && !isBrandingContext;
|
||||
const [localTheme, setLocalTheme] = useState<ThemeConfig>(value);
|
||||
const [selectedPreset, setSelectedPreset] = useState(presetName);
|
||||
const [customCss, setCustomCss] = useState(value.customCss || '');
|
||||
@@ -915,6 +924,14 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
|
||||
{/* Color Mode Selector */}
|
||||
<div className="mb-6">
|
||||
{forcedColorActive && (
|
||||
<div className="mb-3 rounded-lg border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20 px-3 py-2 text-xs text-amber-800 dark:text-amber-300">
|
||||
{isBrandingContext
|
||||
? t('branding.forcedModeBrandingHint', 'Light/dark is locked site-wide by the Force control below — the per-theme mode picker is hidden because it would have no effect.')
|
||||
: t('branding.forcedModeGalleryNote', 'A site-wide color lock is active, so this gallery follows the locked light/dark mode. Color and light/dark options are hidden here and can’t be overridden per gallery.')}
|
||||
</div>
|
||||
)}
|
||||
{!forcedColorActive && (<>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
|
||||
{t('branding.colorMode', 'Color Mode')}
|
||||
</label>
|
||||
@@ -969,6 +986,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
<p className="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
|
||||
{t('branding.colorModeHelp', 'Auto follows the visitor\'s system preference.')}
|
||||
</p>
|
||||
</>)}
|
||||
|
||||
{/*
|
||||
* Force color mode (instance-wide). Lives next to the per-theme
|
||||
@@ -1032,6 +1050,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
* same height so the four Surfaces and the two Accent rows align
|
||||
* cleanly side-by-side.
|
||||
*/}
|
||||
{!hideGalleryColors && (
|
||||
<div className="space-y-6">
|
||||
{/* Surfaces */}
|
||||
<div>
|
||||
@@ -1179,6 +1198,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
handleChange() — no dedicated picker. */}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Typography & Style */}
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* VAT-codes manager — the single home for VAT codes + the rate→code /
|
||||
* treatment→code maps (Settings → Accounting). Relocated from the
|
||||
* Chart-of-accounts page so all VAT config lives in one place.
|
||||
*
|
||||
* NOTE: ledgerService.updateSettings is a PARTIAL merge, so this component saves
|
||||
* ONLY the two map keys — the Chart-of-accounts page saves only its account
|
||||
* keys, and the two never overwrite each other.
|
||||
*/
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'react-toastify';
|
||||
import { X, Plus, Pencil, Trash2 } from 'lucide-react';
|
||||
import { Button, Card, CardContent, Input, Loading } from '../common';
|
||||
import {
|
||||
ledgerService, type LedgerAccount, type VatCode, type VatDirection, type LedgerSettings,
|
||||
} from '../../services/ledger.service';
|
||||
|
||||
const labelCls = 'block text-xs font-medium text-neutral-700 dark:text-neutral-300 mb-1';
|
||||
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';
|
||||
const TAX_TREATMENTS = ['domestic', 'reverse_charge_service', 'foreign_vat_non_reclaimable', 'import_goods'];
|
||||
|
||||
// Mirror backend ledgerService.rateKey so the map keys we write match the
|
||||
// lookup at export time (`outputVatMap[rateKey(inv.vat_rate)]`). 8.10 → '8.1',
|
||||
// 0 → '0', 19 → '19'. Keeping these in sync is what lets a user retype their
|
||||
// codes to local rates and have the revenue-rate rows follow automatically.
|
||||
const rateKey = (rate: number | string): string => {
|
||||
const n = Number(rate);
|
||||
if (!Number.isFinite(n)) return '0';
|
||||
return String(Number(n.toFixed(2)));
|
||||
};
|
||||
|
||||
const VatModal: React.FC<{ vat?: VatCode; accounts: LedgerAccount[]; onClose: () => void; onDone: () => void }> = ({ vat, accounts, onClose, onDone }) => {
|
||||
const { t } = useTranslation();
|
||||
const isEdit = !!vat;
|
||||
const [code, setCode] = useState(vat?.code ?? '');
|
||||
const [name, setName] = useState(vat?.name ?? '');
|
||||
const [rate, setRate] = useState<string>(vat ? String(vat.rate) : '8.1');
|
||||
const [direction, setDirection] = useState<VatDirection>(vat?.direction ?? 'input');
|
||||
const [accountId, setAccountId] = useState<number | ''>(vat?.account_id ?? '');
|
||||
const save = useMutation({
|
||||
mutationFn: () => {
|
||||
const payload = { code, name, rate: Number(rate) || 0, direction, accountId: accountId === '' ? null : Number(accountId) };
|
||||
return isEdit ? ledgerService.updateVatCode(vat!.id, payload) : ledgerService.createVatCode(payload);
|
||||
},
|
||||
onSuccess: () => { toast.success(t('common.saved', 'Saved.')); onDone(); },
|
||||
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'),
|
||||
});
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-start justify-center bg-black/50 p-4">
|
||||
<div className="mt-20 w-full max-w-sm rounded-xl bg-white dark:bg-neutral-900 shadow-xl">
|
||||
<div className="flex items-center justify-between border-b border-neutral-200 dark:border-neutral-700 px-5 py-3">
|
||||
<h2 className="text-base font-semibold text-neutral-900 dark:text-neutral-100">{isEdit ? t('ledger.vat.editTitle', 'Edit VAT code') : t('ledger.vat.addTitle', 'Add VAT code')}</h2>
|
||||
<button onClick={onClose} className="text-neutral-400 hover:text-neutral-600"><X className="w-5 h-5" /></button>
|
||||
</div>
|
||||
<div className="px-5 py-4 space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><label className={labelCls}>{t('ledger.vat.code', 'Code')}</label><Input value={code} onChange={(e) => setCode(e.target.value)} placeholder="VST81" /></div>
|
||||
<div><label className={labelCls}>{t('ledger.vat.rate', 'Rate %')}</label><Input value={rate} onChange={(e) => setRate(e.target.value)} inputMode="decimal" /></div>
|
||||
</div>
|
||||
<div><label className={labelCls}>{t('ledger.vat.name', 'Name')}</label><Input value={name} onChange={(e) => setName(e.target.value)} /></div>
|
||||
<div><label className={labelCls}>{t('ledger.vat.direction', 'Direction')}</label>
|
||||
<select value={direction} onChange={(e) => setDirection(e.target.value as VatDirection)} className={selectCls}>
|
||||
<option value="input">{t('ledger.vatDirection.input', 'Input (Vorsteuer)')}</option>
|
||||
<option value="output">{t('ledger.vatDirection.output', 'Output (Umsatzsteuer)')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div><label className={labelCls}>{t('ledger.vat.account', 'VAT account')}</label>
|
||||
<select value={accountId} onChange={(e) => setAccountId(e.target.value ? Number(e.target.value) : '')} className={selectCls}>
|
||||
<option value="">{t('ledger.vat.noAccount', '— none —')}</option>
|
||||
{accounts.map((a) => <option key={a.id} value={a.id}>{a.number} · {a.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 border-t border-neutral-200 dark:border-neutral-700 px-5 py-3">
|
||||
<Button variant="outline" onClick={onClose}>{t('common.cancel', 'Cancel')}</Button>
|
||||
<Button onClick={() => save.mutate()} disabled={save.isPending || !code || !name}>{save.isPending ? t('common.saving', 'Saving…') : t('common.save', 'Save')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const VatCodesManager: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const [vatModal, setVatModal] = useState<{ vat?: VatCode } | null>(null);
|
||||
|
||||
const { data: accounts } = useQuery({ queryKey: ['ledger-accounts'], queryFn: () => ledgerService.listAccounts() });
|
||||
const { data: vatCodes, isLoading: lv } = useQuery({ queryKey: ['ledger-vat-codes'], queryFn: () => ledgerService.listVatCodes() });
|
||||
const { data: mappings, isLoading: lm } = useQuery({ queryKey: ['ledger-mappings'], queryFn: () => ledgerService.getMappings() });
|
||||
|
||||
// Local copy of ONLY the VAT maps (the account keys stay on the CoA page).
|
||||
const [maps, setMaps] = useState<Pick<LedgerSettings, 'ledger_vat_map' | 'ledger_output_vat_map'>>({});
|
||||
useEffect(() => {
|
||||
if (mappings?.settings) {
|
||||
setMaps({
|
||||
ledger_vat_map: mappings.settings.ledger_vat_map,
|
||||
ledger_output_vat_map: mappings.settings.ledger_output_vat_map,
|
||||
});
|
||||
}
|
||||
}, [mappings?.settings]);
|
||||
|
||||
const inputVat = useMemo(() => (vatCodes ?? []).filter((v) => v.direction === 'input'), [vatCodes]);
|
||||
const outputVat = useMemo(() => (vatCodes ?? []).filter((v) => v.direction === 'output'), [vatCodes]);
|
||||
|
||||
// Revenue-rate rows are DATA-DRIVEN: the distinct rates of the output codes
|
||||
// (first-seen order), not a hardcoded Swiss list. Retype a code to a local
|
||||
// rate (e.g. 19) and its row appears here automatically; remove the last code
|
||||
// at a rate and that row drops. Seeds (8.1/2.6/3.8/0) just produce the same
|
||||
// four rows they did before.
|
||||
const outputRates = useMemo(() => {
|
||||
const seen = new Set<string>();
|
||||
const rates: string[] = [];
|
||||
for (const v of outputVat) {
|
||||
const k = rateKey(v.rate);
|
||||
if (!seen.has(k)) { seen.add(k); rates.push(k); }
|
||||
}
|
||||
return rates;
|
||||
}, [outputVat]);
|
||||
|
||||
const refetch = () => { qc.invalidateQueries({ queryKey: ['ledger-vat-codes'] }); qc.invalidateQueries({ queryKey: ['ledger-mappings'] }); };
|
||||
|
||||
const delVat = useMutation({
|
||||
mutationFn: (id: number) => ledgerService.deleteVatCode(id),
|
||||
onSuccess: () => { toast.success(t('common.deleted', 'Deleted.')); refetch(); },
|
||||
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'),
|
||||
});
|
||||
// PARTIAL save — only the two map keys, never the account keys.
|
||||
const saveMaps = useMutation({
|
||||
mutationFn: () => ledgerService.updateSettings({
|
||||
ledger_vat_map: maps.ledger_vat_map || {},
|
||||
ledger_output_vat_map: maps.ledger_output_vat_map || {},
|
||||
}),
|
||||
onSuccess: () => { toast.success(t('ledger.settingsSaved', 'Mappings saved.')); qc.invalidateQueries({ queryKey: ['ledger-mappings'] }); },
|
||||
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'),
|
||||
});
|
||||
|
||||
const setVatMap = (tt: string, code: string) => setMaps((s) => ({ ...s, ledger_vat_map: { ...(s.ledger_vat_map || {}), [tt]: code } }));
|
||||
const setOutputVatMap = (rate: string, code: string) => setMaps((s) => ({ ...s, ledger_output_vat_map: { ...(s.ledger_output_vat_map || {}), [rate]: code } }));
|
||||
|
||||
if (lv || lm) return <Loading />;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* VAT codes table */}
|
||||
<Card><CardContent className="p-5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-base font-semibold text-neutral-900 dark:text-neutral-100">{t('ledger.vatCodes.title', 'VAT codes')}</h2>
|
||||
<Button size="sm" onClick={() => setVatModal({})}><Plus className="w-4 h-4 mr-1" /> {t('ledger.vat.addTitle', 'Add VAT code')}</Button>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-left text-neutral-500 dark:text-neutral-400 border-b border-neutral-200 dark:border-neutral-700">
|
||||
<tr>
|
||||
<th className="py-1.5 pr-3 font-medium">{t('ledger.vat.code', 'Code')}</th>
|
||||
<th className="py-1.5 pr-3 font-medium">{t('ledger.vat.name', 'Name')}</th>
|
||||
<th className="py-1.5 pr-3 font-medium text-right">{t('ledger.vat.rate', 'Rate %')}</th>
|
||||
<th className="py-1.5 pr-3 font-medium">{t('ledger.vat.direction', 'Direction')}</th>
|
||||
<th className="py-1.5 pr-3 font-medium text-right">{t('common.actions', 'Actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-neutral-100 dark:divide-neutral-800">
|
||||
{(vatCodes ?? []).map((v) => (
|
||||
<tr key={v.id} className={v.active ? '' : 'opacity-50'}>
|
||||
<td className="py-1.5 pr-3 font-medium text-neutral-900 dark:text-neutral-100">{v.code}</td>
|
||||
<td className="py-1.5 pr-3 text-neutral-800 dark:text-neutral-200">{v.name}</td>
|
||||
<td className="py-1.5 pr-3 text-right tabular-nums text-neutral-700 dark:text-neutral-300">{Number(v.rate).toFixed(1)}</td>
|
||||
<td className="py-1.5 pr-3 text-neutral-500 dark:text-neutral-400">{t(`ledger.vatDirection.${v.direction}`, v.direction)}</td>
|
||||
<td className="py-1.5 pr-3">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button onClick={() => setVatModal({ vat: v })} className="p-1 text-neutral-500 hover:text-neutral-800 dark:hover:text-neutral-200"><Pencil className="w-4 h-4" /></button>
|
||||
<button onClick={() => { if (window.confirm(t('ledger.vat.confirmDelete', 'Delete this VAT code?') as string)) delVat.mutate(v.id); }} className="p-1 text-neutral-400 hover:text-red-600"><Trash2 className="w-4 h-4" /></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent></Card>
|
||||
|
||||
{/* Rate→code + treatment→code maps */}
|
||||
<Card><CardContent className="p-5">
|
||||
<h3 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100 mb-2">{t('ledger.outputVatMap.title', 'VAT code by revenue rate')}</h3>
|
||||
{outputRates.length === 0 ? (
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400">{t('ledger.outputVatMap.empty', 'Add output VAT codes above to configure a code per revenue rate.')}</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
{outputRates.map((rate) => (
|
||||
<div key={rate}>
|
||||
<label className={labelCls}>{rate}%</label>
|
||||
<select value={maps.ledger_output_vat_map?.[rate] ?? ''} onChange={(e) => setOutputVatMap(rate, e.target.value)} className={selectCls}>
|
||||
<option value="">{t('ledger.defaults.none', '— none —')}</option>
|
||||
{outputVat.filter((v) => rateKey(v.rate) === rate).map((v) => <option key={v.id} value={v.code}>{v.code}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h3 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100 mt-5 mb-2">{t('ledger.vatMap.title', 'VAT code by tax treatment (costs)')}</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{TAX_TREATMENTS.map((tt) => (
|
||||
<div key={tt}>
|
||||
<label className={labelCls}>{t(`accounting.taxTreatment.${tt}`, tt)}</label>
|
||||
<select value={maps.ledger_vat_map?.[tt] ?? ''} onChange={(e) => setVatMap(tt, e.target.value)} className={selectCls}>
|
||||
<option value="">{t('ledger.defaults.none', '— none —')}</option>
|
||||
{inputVat.map((v) => <option key={v.id} value={v.code}>{v.code} · {v.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end">
|
||||
<Button onClick={() => saveMaps.mutate()} disabled={saveMaps.isPending}>{saveMaps.isPending ? t('common.saving', 'Saving…') : t('ledger.saveDefaults', 'Save mappings')}</Button>
|
||||
</div>
|
||||
</CardContent></Card>
|
||||
|
||||
{vatModal && <VatModal vat={vatModal.vat} accounts={accounts ?? []} onClose={() => setVatModal(null)} onDone={() => { setVatModal(null); refetch(); }} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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 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';
|
||||
|
||||
interface Props {
|
||||
rate: number;
|
||||
code: string | null;
|
||||
onChange: (rate: number, code: string | null) => void;
|
||||
label?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const VatRateSelect: React.FC<Props> = ({ rate, code, onChange, label, disabled }) => {
|
||||
const { t } = useTranslation();
|
||||
const { data: codes = [] } = useQuery({
|
||||
queryKey: ['vat-codes', 'output'],
|
||||
queryFn: () => vatCodesService.listOutput(),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
// Selected option: prefer the snapshotted code; else a code whose rate matches
|
||||
// (legacy rows / no code stored); else "custom".
|
||||
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;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{label && (
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">{label}</label>
|
||||
)}
|
||||
<select
|
||||
className={selectCls}
|
||||
disabled={disabled}
|
||||
value={isCustom ? CUSTOM : String(matched!.id)}
|
||||
onChange={(e) => {
|
||||
if (e.target.value === CUSTOM) { onChange(rate, null); return; }
|
||||
const c = codes.find((x) => String(x.id) === e.target.value);
|
||||
if (c) onChange(Number(c.rate), c.code);
|
||||
}}
|
||||
>
|
||||
{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>
|
||||
);
|
||||
};
|
||||
@@ -21,6 +21,8 @@ export const DEFAULT_FLAGS: FeatureFlags = {
|
||||
quotes: false,
|
||||
bills: false,
|
||||
messaging: false,
|
||||
// Incoming mail (migration 128) — IMAP intake. Standalone, default off.
|
||||
incomingMail: false,
|
||||
analytics: true,
|
||||
userManagement: true,
|
||||
// Top-level Clients section (#354 follow-up). Migration 097 mirrors
|
||||
@@ -45,6 +47,15 @@ export const DEFAULT_FLAGS: FeatureFlags = {
|
||||
// Settings → Features once they've reviewed the seeded block
|
||||
// library with their lawyer.
|
||||
contracts: false,
|
||||
// Accounting (migration 122). Top-level MASTER for the Accounting
|
||||
// section (separate from CRM). Sub-features below require it.
|
||||
accounting: false,
|
||||
// Incoming invoices (migration 124) — external supplier-invoice capture +
|
||||
// re-bill. Accounting sub-feature; requires `accounting`.
|
||||
incomingInvoices: false,
|
||||
// Expenses (migration 127) — internal expenses (mileage / per-diem / cash).
|
||||
// Separate Accounting sub-feature; requires `accounting`.
|
||||
expenses: false,
|
||||
// Projects (migration 120). Admin-only grouping layer above events +
|
||||
// the Project Overview cockpit. Off by default — admin opts in under
|
||||
// Settings → Features once they want the CRM → Overview area.
|
||||
@@ -80,7 +91,13 @@ 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
|
||||
if (out.bills === false) out.taxReport = false; // tax report depends on bills
|
||||
// Accounting sub-features require the Accounting master. Tax export is
|
||||
// independent of Bills now — it relocated permanently into Accounting.
|
||||
if (out.accounting === false) {
|
||||
out.taxReport = false;
|
||||
out.incomingInvoices = false;
|
||||
out.expenses = false;
|
||||
}
|
||||
// Clients parent flag is DERIVED from its children. Admins don't
|
||||
// toggle it directly — enabling any CRM-area sub-feature
|
||||
// (Accounts today; future Calendar / Quotes / Bills / Messaging)
|
||||
@@ -91,11 +108,12 @@ function applyDependencyRules(flags: FeatureFlags): FeatureFlags {
|
||||
|| out.crmDevelopment
|
||||
|| out.quotes
|
||||
|| out.bills
|
||||
|| out.taxReport
|
||||
|| out.hoursLogging
|
||||
|| out.contracts
|
||||
// Migration 137 — admin calendar lights up the Clients section.
|
||||
|| out.calendar
|
||||
// NOTE: taxReport is intentionally NOT here anymore — the Tax export
|
||||
// moved permanently into the Accounting section (its own master).
|
||||
// future siblings: || out.messaging
|
||||
);
|
||||
return out;
|
||||
|
||||
@@ -18,3 +18,4 @@ export { SEOTab } from './tabs/SEOTab';
|
||||
export { ThumbnailsTab } from './tabs/ThumbnailsTab';
|
||||
export { ApiTokensTab } from './tabs/ApiTokensTab';
|
||||
export { WebhooksTab } from './tabs/WebhooksTab';
|
||||
export { AccountingTab } from './tabs/AccountingTab';
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Accounting settings tab — rates used by internal expenses + the proof
|
||||
* requirement. Rates are CHF; stored as integer minor units. Tax/legal
|
||||
* guidance only — verify with your Treuhaender.
|
||||
*/
|
||||
import React, { useEffect, useState } from 'react';
|
||||
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 { DecimalInput } from '../../../components/common/DecimalInput';
|
||||
import { accountingService } from '../../../services/accounting.service';
|
||||
import { sortedCountryOptions } from '../../../constants/countries';
|
||||
import { VatCodesManager } from '../../../components/admin/VatCodesManager';
|
||||
import { ChartOfAccountsManager } from '../../../components/admin/ChartOfAccountsManager';
|
||||
|
||||
const labelCls = 'block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1';
|
||||
const inputCls = 'w-full max-w-xs rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-3 py-2 text-sm';
|
||||
|
||||
export const AccountingTab: React.FC = () => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const { data, isLoading } = useQuery({ queryKey: ['accounting-settings'], queryFn: () => accountingService.getSettings() });
|
||||
|
||||
const [kmMajor, setKmMajor] = useState<number>(NaN);
|
||||
const [perDiemMajor, setPerDiemMajor] = useState<number>(NaN);
|
||||
const [requireProof, setRequireProof] = useState(false);
|
||||
const [vatRegistered, setVatRegistered] = useState(false);
|
||||
const [reclaimCountries, setReclaimCountries] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
setKmMajor(data.accounting_km_rate_minor / 100);
|
||||
setPerDiemMajor(data.accounting_per_diem_rate_minor / 100);
|
||||
setRequireProof(data.accounting_require_proof);
|
||||
setVatRegistered(data.accounting_vat_registered);
|
||||
setReclaimCountries(data.accounting_vat_reclaim_countries || []);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
const countries = sortedCountryOptions(i18n.language);
|
||||
|
||||
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'] }); },
|
||||
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'),
|
||||
});
|
||||
|
||||
if (isLoading) return <Loading />;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-neutral-900 dark:text-neutral-100">{t('settings.accounting.title', 'Accounting')}</h2>
|
||||
<p className="text-neutral-600 dark:text-neutral-400 mt-1">{t('settings.accounting.subtitle', 'Default rates for internal expenses and the proof requirement.')}</p>
|
||||
</div>
|
||||
|
||||
<Card><CardContent className="p-5 space-y-4">
|
||||
<div>
|
||||
<label className={labelCls}>{t('settings.accounting.kmRate', 'Mileage rate (CHF / km)')}</label>
|
||||
<DecimalInput value={kmMajor} onChange={setKmMajor} fractionDigits={2} className={inputCls} />
|
||||
<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>
|
||||
<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>
|
||||
</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" />
|
||||
{t('settings.accounting.requireProof', 'Require a proof file on every expense')}
|
||||
</label>
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400">{t('settings.accounting.disclaimer', 'Rates and VAT/tax treatment are guidance only — verify with your Treuhaender.')}</p>
|
||||
</CardContent></Card>
|
||||
|
||||
{/* VAT registration & reclaim — drives whether output/input VAT applies
|
||||
and which countries' input VAT is deductible (cost tax-treatment +
|
||||
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')}
|
||||
</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" />
|
||||
<span>
|
||||
{t('settings.accounting.vat.registered', 'VAT-registered (charge output VAT + reclaim input VAT)')}
|
||||
<span className="block text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{t('settings.accounting.vat.registeredHint', 'Off = small business / under threshold: no VAT charged, input VAT is a cost (not reclaimable).')}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<div className={vatRegistered ? '' : 'opacity-50 pointer-events-none'}>
|
||||
<label className={labelCls}>{t('settings.accounting.vat.reclaimCountries', 'Countries where input VAT is reclaimable')}</label>
|
||||
<select
|
||||
multiple
|
||||
size={6}
|
||||
value={reclaimCountries}
|
||||
onChange={(e) => setReclaimCountries(Array.from(e.target.selectedOptions, (o) => o.value))}
|
||||
className="w-full max-w-xs rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-3 py-2 text-sm"
|
||||
>
|
||||
{countries.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('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>
|
||||
</CardContent></Card>
|
||||
|
||||
<div>
|
||||
<Button onClick={() => save.mutate()} disabled={save.isPending}><Save className="w-4 h-4 mr-2" /> {save.isPending ? t('common.saving', 'Saving…') : t('common.save', 'Save')}</Button>
|
||||
</div>
|
||||
|
||||
{/* VAT codes + rate→code / treatment→code maps — relocated here from the
|
||||
Chart-of-accounts page so all VAT config lives in one place. */}
|
||||
<VatCodesManager />
|
||||
|
||||
{/* Chart of accounts (accounts + category/default-account mappings) —
|
||||
moved off the /admin/accounting section so all accounting config is
|
||||
here; the section keeps only the operational pages. */}
|
||||
<div className="pt-2">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wider text-neutral-500 dark:text-neutral-400 mb-3">
|
||||
{t('ledger.accounts.title', 'Chart of accounts')}
|
||||
</h3>
|
||||
<ChartOfAccountsManager />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AccountingTab;
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Images,
|
||||
BellRing,
|
||||
MessageSquare,
|
||||
Mailbox,
|
||||
CalendarDays,
|
||||
FileSignature,
|
||||
ScrollText,
|
||||
@@ -16,6 +17,9 @@ import {
|
||||
Briefcase,
|
||||
Wrench,
|
||||
Calculator,
|
||||
Landmark,
|
||||
ScanLine,
|
||||
Wallet,
|
||||
FolderKanban,
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -160,6 +164,21 @@ export const FeaturesTab: React.FC = () => {
|
||||
onToggle={(next) => setFlag('reminderEmails', next)}
|
||||
/>
|
||||
|
||||
<FeatureCard
|
||||
icon={Mailbox}
|
||||
title={t('settings.features.incomingMail.title', 'Incoming mail')}
|
||||
description={t(
|
||||
'settings.features.incomingMail.description',
|
||||
'Poll a dedicated mailbox (IMAP) every minute and drop invoice attachments into Accounting → Incoming invoices. Configure the mailbox under Settings → Email.',
|
||||
)}
|
||||
status="new"
|
||||
statusLabel={statusLabel('new')}
|
||||
sidebarHidden
|
||||
sidebarHiddenLabel={sidebarHiddenLabel}
|
||||
enabled={staged.incomingMail}
|
||||
onToggle={(next) => setFlag('incomingMail', next)}
|
||||
/>
|
||||
|
||||
<FeatureCard
|
||||
icon={MessageSquare}
|
||||
title={t('settings.features.messaging.title', 'Messaging')}
|
||||
@@ -260,25 +279,6 @@ export const FeaturesTab: React.FC = () => {
|
||||
onToggle={(next) => setFlag('bills', next)}
|
||||
/>
|
||||
|
||||
<FeatureCard
|
||||
icon={Calculator}
|
||||
title={t('settings.features.taxReport.title', 'Tax report')}
|
||||
description={t(
|
||||
'settings.features.taxReport.description',
|
||||
'Period-scoped revenue list with net + VAT breakdown grouped by VAT rate. Export as PDF (landscape, company letterhead) or CSV for your accountant. Cancelled invoices stay visible for a gap-free audit trail but are excluded from totals.',
|
||||
)}
|
||||
status="new"
|
||||
statusLabel={statusLabel('new')}
|
||||
sidebarLabel={t('settings.features.taxReport.sidebar', 'Tax')}
|
||||
enabled={staged.taxReport}
|
||||
onToggle={(next) => setFlag('taxReport', next)}
|
||||
disabled={!staged.bills}
|
||||
lockedReason={!staged.bills ? t(
|
||||
'settings.features.taxReport.requiresBills',
|
||||
'Enable Bills first — the tax report reads from your invoices.',
|
||||
) : undefined}
|
||||
/>
|
||||
|
||||
<FeatureCard
|
||||
icon={Briefcase}
|
||||
title={t('settings.features.hoursLogging.title', 'Hours logging')}
|
||||
@@ -308,6 +308,82 @@ export const FeaturesTab: React.FC = () => {
|
||||
/>
|
||||
</Section>
|
||||
|
||||
{/* Accounting — top-level master + sub-toggles. The Tax export
|
||||
relocated here permanently out of CRM. Sub-toggles are disabled
|
||||
until the Accounting master is on. */}
|
||||
<Section title={t('settings.features.sections.accounting', 'Accounting')}>
|
||||
<FeatureCard
|
||||
icon={Landmark}
|
||||
title={t('settings.features.accounting.title', 'Accounting')}
|
||||
description={t(
|
||||
'settings.features.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.',
|
||||
)}
|
||||
status="new"
|
||||
statusLabel={statusLabel('new')}
|
||||
sidebarLabel={t('settings.features.accounting.sidebar', 'Accounting')}
|
||||
enabled={staged.accounting}
|
||||
onToggle={(next) => setFlag('accounting', next)}
|
||||
/>
|
||||
|
||||
<FeatureCard
|
||||
icon={Calculator}
|
||||
title={t('settings.features.taxReport.title', 'Tax export')}
|
||||
description={t(
|
||||
'settings.features.taxReport.description',
|
||||
'Period-scoped revenue list with net + VAT breakdown grouped by VAT rate. Export as PDF (landscape, company letterhead) or CSV for your accountant. Cancelled invoices stay visible for a gap-free audit trail but are excluded from totals.',
|
||||
)}
|
||||
status="new"
|
||||
statusLabel={statusLabel('new')}
|
||||
sidebarLabel={t('settings.features.taxReport.sidebar', 'Tax')}
|
||||
enabled={staged.taxReport}
|
||||
onToggle={(next) => setFlag('taxReport', next)}
|
||||
disabled={!staged.accounting}
|
||||
lockedReason={!staged.accounting ? t(
|
||||
'settings.features.taxReport.requiresAccounting',
|
||||
'Enable Accounting first — Tax export lives in the Accounting section.',
|
||||
) : undefined}
|
||||
/>
|
||||
|
||||
<FeatureCard
|
||||
icon={ScanLine}
|
||||
title={t('settings.features.incomingInvoices.title', 'Incoming invoices')}
|
||||
description={t(
|
||||
'settings.features.incomingInvoices.description',
|
||||
'Capture received supplier invoices (upload or phone/tablet camera), categorize expenses, and re-bill costs to clients on the relevant event with a contract-driven markup.',
|
||||
)}
|
||||
status="new"
|
||||
statusLabel={statusLabel('new')}
|
||||
sidebarLabel={t('settings.features.incomingInvoices.sidebar', 'Incoming')}
|
||||
enabled={staged.incomingInvoices}
|
||||
onToggle={(next) => setFlag('incomingInvoices', next)}
|
||||
disabled={!staged.accounting}
|
||||
lockedReason={!staged.accounting ? t(
|
||||
'settings.features.incomingInvoices.requiresAccounting',
|
||||
'Enable Accounting first — Incoming invoices live in the Accounting section.',
|
||||
) : undefined}
|
||||
/>
|
||||
|
||||
<FeatureCard
|
||||
icon={Wallet}
|
||||
title={t('settings.features.expenses.title', 'Expenses')}
|
||||
description={t(
|
||||
'settings.features.expenses.description',
|
||||
'Internal expenses (mileage, per-diem, cash) booked to an event or the company, with optional proof. Separate from incoming supplier invoices. Configure km / per-diem rates and the proof requirement in the Accounting settings tab.',
|
||||
)}
|
||||
status="new"
|
||||
statusLabel={statusLabel('new')}
|
||||
sidebarLabel={t('settings.features.expenses.sidebar', 'Expenses')}
|
||||
enabled={staged.expenses}
|
||||
onToggle={(next) => setFlag('expenses', next)}
|
||||
disabled={!staged.accounting}
|
||||
lockedReason={!staged.accounting ? t(
|
||||
'settings.features.expenses.requiresAccounting',
|
||||
'Enable Accounting first — Expenses live in the Accounting section.',
|
||||
) : undefined}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
{/* Insights & Access */}
|
||||
<Section title={t('settings.features.sections.insights', 'Insights & Access')}>
|
||||
<FeatureCard
|
||||
|
||||
@@ -98,6 +98,7 @@
|
||||
"error": "Fehler",
|
||||
"configureInSettings": "Standards in den Einstellungen anpassen ↗",
|
||||
"save": "Speichern",
|
||||
"saving": "Speichern…",
|
||||
"cancel": "Abbrechen",
|
||||
"delete": "Löschen",
|
||||
"edit": "Bearbeiten",
|
||||
@@ -196,6 +197,7 @@
|
||||
"users": "Benutzer",
|
||||
"calendar": "Kalender",
|
||||
"clients": "CRM",
|
||||
"accounting": "Buchhaltung",
|
||||
"betaTag": "Beta"
|
||||
},
|
||||
"eventTypes": {
|
||||
@@ -1584,6 +1586,7 @@
|
||||
"communication": "Kommunikation",
|
||||
"scheduling": "Terminplanung",
|
||||
"sales": "Vertrieb",
|
||||
"accounting": "Buchhaltung",
|
||||
"insights": "Auswertungen & Zugriff",
|
||||
"customers": "Kunden",
|
||||
"clients": "CRM"
|
||||
@@ -1600,6 +1603,10 @@
|
||||
"description": "Der Kern von PicPeak. Immer verfügbar.",
|
||||
"locked": "Galerien sind die Grundlage von PicPeak und können nicht deaktiviert werden."
|
||||
},
|
||||
"incomingMail": {
|
||||
"title": "Eingehende E-Mails",
|
||||
"description": "Ruft ein dediziertes Postfach (IMAP) jede Minute ab und legt Rechnungsanhänge in Buchhaltung → Eingangsrechnungen ab. Postfach unter Einstellungen → E-Mail konfigurieren."
|
||||
},
|
||||
"reminderEmails": {
|
||||
"title": "Erinnerungs-E-Mails",
|
||||
"description": "Automatische Vor-Event-Erinnerung an Kunden N Tage vor dem Eventdatum. Vorlagen pro Kategorie (Konzert, Firma, Hochzeit, …) unter Einstellungen → Erinnerungsvorlagen; Übersteuerung pro Event auf der Event-Detailseite."
|
||||
@@ -1630,10 +1637,28 @@
|
||||
"sidebar": "Rechnungen"
|
||||
},
|
||||
"taxReport": {
|
||||
"title": "Steuerliste",
|
||||
"title": "Steuerexport",
|
||||
"description": "Periodenbezogene Umsatzliste mit Netto- und MwSt-Aufschlüsselung, gruppiert nach MwSt-Satz. Exportieren Sie als PDF (Querformat, Firmenbriefkopf) oder CSV für Ihre Buchhaltung. Stornierte Rechnungen bleiben für die lückenlose Nummernfolge sichtbar, sind aber nicht in den Summen enthalten.",
|
||||
"sidebar": "Steuer",
|
||||
"requiresBills": "Bitte zuerst Rechnungen aktivieren — die Steuerliste liest aus Ihren Rechnungen."
|
||||
"requiresBills": "Bitte zuerst Rechnungen aktivieren — die Steuerliste liest aus Ihren Rechnungen.",
|
||||
"requiresAccounting": "Bitte zuerst Buchhaltung aktivieren — der Steuerexport liegt im Buchhaltungsbereich."
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"incomingInvoices": {
|
||||
"title": "Eingangsrechnungen",
|
||||
"description": "Eingehende Lieferantenrechnungen erfassen (Upload oder Handy-/Tablet-Kamera), Aufwände kategorisieren und Kosten dem passenden Event des Kunden mit vertraglich hinterlegtem Zuschlag weiterverrechnen.",
|
||||
"sidebar": "Eingang",
|
||||
"requiresAccounting": "Bitte zuerst Buchhaltung aktivieren — Eingangsrechnungen liegen im Buchhaltungsbereich."
|
||||
},
|
||||
"expenses": {
|
||||
"title": "Aufwände",
|
||||
"description": "Interne Aufwände (Kilometer, Spesenpauschale, Barbelege), gebucht auf ein Event oder die Firma, mit optionalem Beleg. Getrennt von Eingangsrechnungen.",
|
||||
"sidebar": "Aufwände",
|
||||
"requiresAccounting": "Bitte zuerst Buchhaltung aktivieren — Aufwände liegen im Buchhaltungsbereich."
|
||||
},
|
||||
"analytics": {
|
||||
"title": "Statistiken",
|
||||
@@ -1689,6 +1714,24 @@
|
||||
},
|
||||
"crm": {
|
||||
"title": "CRM-Verhalten"
|
||||
},
|
||||
"accounting": {
|
||||
"title": "Buchhaltung",
|
||||
"subtitle": "Standardsätze für interne Aufwände 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.",
|
||||
"requireProof": "Beleg für jeden Aufwand verlangen",
|
||||
"vat": {
|
||||
"title": "MwSt-Registrierung & Vorsteuerabzug",
|
||||
"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."
|
||||
},
|
||||
"disclaimer": "Sätze und MwSt-/Steuerbehandlung dienen nur als Orientierung — mit Ihrem Treuhänder prüfen.",
|
||||
"savedToast": "Buchhaltungseinstellungen gespeichert."
|
||||
}
|
||||
},
|
||||
"branding": {
|
||||
@@ -1913,6 +1956,8 @@
|
||||
"forceColorModeNone": "Kein Zwang (Benutzerauswahl)",
|
||||
"forceColorModeDark": "Dunkelmodus erzwingen",
|
||||
"forceColorModeLight": "Hellmodus erzwingen",
|
||||
"forcedModeBrandingHint": "Hell/Dunkel wird site-weit über die Erzwingen-Steuerung unten festgelegt — die themenspezifische Modusauswahl ist ausgeblendet, da sie keine Wirkung hätte.",
|
||||
"forcedModeGalleryNote": "Eine site-weite Farbsperre ist aktiv, daher folgt diese Galerie dem gesperrten Hell-/Dunkel-Modus. Farb- und Hell/Dunkel-Optionen sind hier ausgeblendet und können nicht pro Galerie überschrieben werden.",
|
||||
"colorGroupSurfaces": "Oberflächen",
|
||||
"colorGroupSurfacesHelp": "Die neutralen Ebenen hinter Ihrem Inhalt. Hintergrund liegt am weitesten zurück; Oberfläche und Erhöht stapeln sich darüber.",
|
||||
"backgroundColorHelp": "Die Seite selbst — Hintergrundfarbe jeder Galerie, Admin-Seite und CMS-Seite.",
|
||||
@@ -2444,6 +2489,55 @@
|
||||
"success": "Warteschlange geleert – {{sent}} gesendet, {{failed}} fehlgeschlagen",
|
||||
"empty": "Keine ausstehenden E-Mails zum Senden"
|
||||
},
|
||||
"incoming": {
|
||||
"title": "Eingehende E-Mails (IMAP)",
|
||||
"subtitle": "Ein dediziertes Postfach, jede Minute abgerufen; Anhänge landen in Buchhaltung → Eingangsrechnungen.",
|
||||
"host": "IMAP-Host",
|
||||
"test": "Verbindung testen",
|
||||
"testOk": "Verbunden mit {{folder}} — {{messages}} Nachrichten, {{unseen}} ungelesen.",
|
||||
"testFailed": "Verbindung fehlgeschlagen.",
|
||||
"roundTrip": "Rundlauf-Test",
|
||||
"roundTripHint": "Sendet über die SMTP-Einstellungen eine Test-E-Mail an dieses Postfach und prüft, ob sie ankommt. Beide vorher speichern.",
|
||||
"roundTripOk": "Rundlauf OK — an {{recipient}} zugestellt in {{seconds}}s.",
|
||||
"roundTripFailed": "Rundlauf-Test fehlgeschlagen.",
|
||||
"poll": "Jetzt prüfen",
|
||||
"pollHint": "Postfach sofort prüfen, statt auf den 60-Sekunden-Abruf zu warten. Ungelesene Anhänge werden in Eingangsrechnungen übernommen.",
|
||||
"pollOk": "Postfach geprüft — {{count}} neue E-Mail(s) übernommen.",
|
||||
"pollDisabled": "Eingehende E-Mails sind deaktiviert — unter Einstellungen → Funktionen aktivieren.",
|
||||
"pollUnconfigured": "Zuerst die Einstellungen für eingehende E-Mails speichern.",
|
||||
"pollBusy": "Ein Abruf läuft bereits — bitte gleich erneut versuchen.",
|
||||
"pollFailed": "Postfach-Abruf fehlgeschlagen.",
|
||||
"port": "Port",
|
||||
"security": "Sicherheit",
|
||||
"ssl": "SSL/TLS",
|
||||
"plain": "Keine / STARTTLS",
|
||||
"user": "Benutzername",
|
||||
"pass": "Passwort",
|
||||
"folder": "Ordner",
|
||||
"save": "Eingangs-E-Mail-Einstellungen speichern",
|
||||
"detectFolders": "Erkennen",
|
||||
"folderHint": "Host, Benutzername und Passwort eingeben, dann „Erkennen“, um die Postfach-Ordner aufzulisten.",
|
||||
"foldersDetected": "{{count}} Ordner gefunden.",
|
||||
"noFolders": "Der Server hat keine Ordner zurückgegeben.",
|
||||
"detectFailed": "Ordner konnten nicht erkannt werden.",
|
||||
"requiredFields": "Host, Port und Benutzername sind erforderlich.",
|
||||
"savedToast": "Einstellungen für eingehende E-Mails gespeichert."
|
||||
},
|
||||
"received": {
|
||||
"tab": "Empfangene E-Mails",
|
||||
"from": "Von",
|
||||
"subject": "Betreff",
|
||||
"received": "Empfangen",
|
||||
"status": "Status",
|
||||
"empty": "Noch keine empfangenen E-Mails. Eingehende E-Mails aktivieren und Postfach konfigurieren.",
|
||||
"inbox": "Eingang",
|
||||
"statusValue": {
|
||||
"ingested": "Erfasst",
|
||||
"no_attachment": "Kein Anhang",
|
||||
"duplicate": "Duplikat",
|
||||
"error": "Fehler"
|
||||
}
|
||||
},
|
||||
"sentEmails": {
|
||||
"tab": "Gesendete E-Mails",
|
||||
"title": "Gesendete E-Mails",
|
||||
@@ -3382,6 +3476,180 @@
|
||||
"development": "Entwicklung"
|
||||
}
|
||||
},
|
||||
"accounting": {
|
||||
"title": "Buchhaltung",
|
||||
"subtitle": "Eingehende Lieferantenrechnungen, Aufwände und Auswertungen.",
|
||||
"navAriaLabel": "Buchhaltungs-Navigation",
|
||||
"empty": {
|
||||
"title": "Keine Buchhaltungsfunktionen aktiviert",
|
||||
"body": "Aktiviere die Steuerliste (oder eine andere Buchhaltungs-Unterfunktion) unter Einstellungen → Funktionen, um loszulegen."
|
||||
},
|
||||
"subnav": {
|
||||
"incomingInvoices": "Eingangsrechnungen",
|
||||
"expenses": "Aufwände",
|
||||
"taxReport": "Steuer",
|
||||
"export": "Treuhänder-Export",
|
||||
"chartOfAccounts": "Kontenplan"
|
||||
},
|
||||
"taxTreatment": {
|
||||
"domestic": "Inland (mit MWST)",
|
||||
"reverse_charge_service": "Bezugsteuer (Dienstleistungen)",
|
||||
"foreign_vat_non_reclaimable": "Ausländische MWST (nicht abziehbar)",
|
||||
"import_goods": "Wareneinfuhr"
|
||||
},
|
||||
"disposition": {
|
||||
"rebill": "An Kunde weiterverrechnen",
|
||||
"durchlaufend": "Durchlaufender Posten",
|
||||
"eigener_aufwand": "Eigener Aufwand",
|
||||
"duplikat": "Duplikat",
|
||||
"abgelehnt": "Abgelehnt"
|
||||
},
|
||||
"markup": {
|
||||
"none": "Keiner / aus Vertrag",
|
||||
"percent": "Prozent",
|
||||
"flat": "Pauschal"
|
||||
},
|
||||
"inbox": {
|
||||
"captureTitle": "Lieferantenrechnung erfassen",
|
||||
"captureBody": "Fotografiere eine Papierrechnung mit der Gerätekamera oder lade ein PDF / Bild hoch.",
|
||||
"scanCamera": "Mit Kamera scannen",
|
||||
"uploadFile": "Datei hochladen",
|
||||
"capturedToast": "Dokument erfasst.",
|
||||
"categorizedToast": "Dokument kategorisiert.",
|
||||
"triageTitle": "Dokument kategorisieren",
|
||||
"saveCategorize": "Speichern",
|
||||
"saveCategorizePay": "Speichern & als bezahlt markieren",
|
||||
"categorize": "Kategorisieren",
|
||||
"view": "Ansehen",
|
||||
"empty": "Noch keine Dokumente — oben eines erfassen.",
|
||||
"untitled": "Unbenanntes Dokument",
|
||||
"noAmount": "Betrag nicht erfasst",
|
||||
"rebillHint": "Erstellt eine bearbeitbare geplante Rechnung beim Kunden. MwSt-/Steuerbehandlung ist v1 — mit Treuhänder prüfen.",
|
||||
"previewLoading": "Vorschau wird geladen…",
|
||||
"previewError": "Vorschau nicht verfügbar — Felder manuell erfassen.",
|
||||
"qrHint": "Letzte Seite — der Schweizer QR-Einzahlschein sitzt meist unten.",
|
||||
"prevPage": "Zurück",
|
||||
"nextPage": "Weiter",
|
||||
"pageOf": "Seite {{n}} / {{total}}",
|
||||
"status": {
|
||||
"label": "Status",
|
||||
"unsorted": "Neu",
|
||||
"categorized": "Kategorisiert",
|
||||
"declined": "Abgelehnt",
|
||||
"duplicate": "Duplikat"
|
||||
},
|
||||
"field": {
|
||||
"supplier": "Lieferant",
|
||||
"total": "Total",
|
||||
"currency": "Währung",
|
||||
"invoiceDate": "Rechnungsdatum",
|
||||
"disposition": "Zuordnung",
|
||||
"category": "Kategorie",
|
||||
"categoryNone": "— keine —",
|
||||
"declineReason": "Grund",
|
||||
"customer": "Kunde",
|
||||
"eventId": "Event-ID (optional)",
|
||||
"markup": "Zuschlag",
|
||||
"reference": "Zahlungsreferenz",
|
||||
"referenceHint": "QR-/ESR-Referenz oder Mitteilung"
|
||||
}
|
||||
},
|
||||
"expenseStatus": {
|
||||
"open": "Offen",
|
||||
"parked": "Geparkt",
|
||||
"billed": "Verrechnet",
|
||||
"declined": "Abgelehnt"
|
||||
},
|
||||
"paymentMethod": {
|
||||
"bank_transfer": "Überweisung",
|
||||
"cash": "Bargeld",
|
||||
"twint": "TWINT",
|
||||
"paypal": "PayPal",
|
||||
"card": "Karte",
|
||||
"other": "Sonstiges"
|
||||
},
|
||||
"booking": {
|
||||
"label": "Buchen auf",
|
||||
"company": "Firma",
|
||||
"event": "Event",
|
||||
"eventId": "Event-ID"
|
||||
},
|
||||
"incoming": {
|
||||
"triageTitle": "Eingangsrechnung kategorisieren",
|
||||
"payTitle": "Lieferant als bezahlt markieren",
|
||||
"outstanding": "Offen",
|
||||
"markPaid": "Als bezahlt markieren",
|
||||
"markUnpaid": "Als unbezahlt markieren",
|
||||
"confirmPaid": "Als bezahlt markieren",
|
||||
"paid": "Bezahlt",
|
||||
"paidToast": "Als bezahlt markiert.",
|
||||
"categorizedToast": "Kategorisiert.",
|
||||
"categorizedPaidToast": "Kategorisiert und als bezahlt markiert."
|
||||
},
|
||||
"expense": {
|
||||
"kind": "Art",
|
||||
"km": "Kilometer",
|
||||
"days": "Tage",
|
||||
"rate": "Satz",
|
||||
"rateDefault": "Standard {{rate}} — leer lassen, um ihn zu verwenden",
|
||||
"computed": "Betrag",
|
||||
"who": "Bezahlt von / Lieferant",
|
||||
"whoHint": "z. B. Mitarbeitername oder Geschäft",
|
||||
"proof": "Beleg",
|
||||
"proofRequired": "Ein Beleg ist erforderlich.",
|
||||
"proofExisting": "Es ist bereits ein Beleg angehängt — neuen hochladen, um ihn zu ersetzen.",
|
||||
"viewProof": "Beleg"
|
||||
},
|
||||
"expenseKind": {
|
||||
"amount": "Betrag",
|
||||
"mileage": "Kilometer (km)",
|
||||
"per_diem": "Spesenpauschale"
|
||||
},
|
||||
"category": {
|
||||
"infrastructure": "Infrastruktur & Miete",
|
||||
"equipment": "Equipment & Hardware",
|
||||
"software": "Software & Lizenzen",
|
||||
"material": "Material & Verbrauch",
|
||||
"travel": "Reise & Spesen",
|
||||
"marketing": "Werbung & Marketing",
|
||||
"services": "Dienstleistungen/Fremdleistungen",
|
||||
"insurance": "Versicherungen & Gebühren",
|
||||
"training": "Weiterbildung",
|
||||
"other": "Sonstiges"
|
||||
},
|
||||
"ledger": {
|
||||
"allStatuses": "Alle Status",
|
||||
"allDispositions": "Alle Zuordnungen",
|
||||
"allKinds": "Alle Arten",
|
||||
"empty": "Noch keine Aufwände — oben einen hinzufügen.",
|
||||
"untitled": "Aufwand",
|
||||
"invoiceLink": "Rechnung",
|
||||
"paid": "Bezahlt",
|
||||
"markPaid": "Als bezahlt markieren",
|
||||
"markPaidTitle": "Lieferant als bezahlt markieren",
|
||||
"paidDate": "Zahldatum",
|
||||
"method": "Methode",
|
||||
"reference": "Referenz (optional)",
|
||||
"confirmPaid": "Als bezahlt markieren",
|
||||
"paidToast": "Als bezahlt markiert.",
|
||||
"unpaidToast": "Als nicht bezahlt markiert.",
|
||||
"addExpense": "Aufwand hinzufügen",
|
||||
"addTitle": "Aufwand hinzufügen",
|
||||
"editTitle": "Aufwand bearbeiten",
|
||||
"updatedToast": "Aufwand aktualisiert.",
|
||||
"payTitle": "Aufwand als bezahlt markieren",
|
||||
"invoiced": "Verrechnet",
|
||||
"invoicedToast": "Zu einer Kundenrechnung hinzugefügt.",
|
||||
"invoiceTitle": "Zur Kundenrechnung hinzufügen",
|
||||
"invoiceHint": "Erstellt eine verrechenbare Position auf der nächsten geplanten Rechnung des Kunden und sperrt den Aufwand für weitere Änderungen.",
|
||||
"addToInvoice": "Zur Rechnung hinzufügen",
|
||||
"locked": "Gesperrt",
|
||||
"lockedHint": "Gesperrt — dieser Aufwand ist auf einer Kundenrechnung.",
|
||||
"description": "Beschreibung",
|
||||
"descriptionHint": "z. B. Kilometer, Spesenpauschale, Barbeleg",
|
||||
"createdToast": "Aufwand hinzugefügt."
|
||||
}
|
||||
},
|
||||
"projects": {
|
||||
"title": "Projektübersicht",
|
||||
"subtitle": "Fasse Events zu Projekten zusammen und sieh jede E-Mail, jedes Dokument, jede Galerie und jede Stunde in einem Cockpit.",
|
||||
@@ -3613,6 +3881,57 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ledger": {
|
||||
"intro": "Dient nur dem Treuhänder-Export — picpeak führt keine doppelte Buchhaltung. Der vorbelegte Kontenplan + die MWST-Codes folgen dem Schweizer/LI KMU-Kontenrahmen; passe sie an das Setup deines Treuhänders an.",
|
||||
"settingsSaved": "Zuordnungen gespeichert.",
|
||||
"saveDefaults": "Zuordnungen speichern",
|
||||
"accountType": {
|
||||
"asset": "Aktiven", "liability": "Passiven", "equity": "Eigenkapital", "revenue": "Ertrag", "expense": "Aufwand"
|
||||
},
|
||||
"vatDirection": {
|
||||
"input": "Vorsteuer", "output": "Umsatzsteuer"
|
||||
},
|
||||
"defaults": {
|
||||
"title": "Standard- & Systemkonten",
|
||||
"none": "— keines —",
|
||||
"ledger_account_debitoren": "Forderungen (Debitoren)",
|
||||
"ledger_account_kreditoren": "Verbindlichkeiten (Kreditoren)",
|
||||
"ledger_account_bank": "Bank",
|
||||
"ledger_account_cash": "Kasse",
|
||||
"ledger_account_default_revenue": "Standard-Ertragskonto",
|
||||
"ledger_account_default_expense": "Standard-Aufwandkonto",
|
||||
"ledger_account_mileage": "Kilometer-Konto",
|
||||
"ledger_account_per_diem": "Spesenpauschalen-Konto",
|
||||
"ledger_account_rebilled_revenue": "Weiterverrechnete Spesen (Ertrag)"
|
||||
},
|
||||
"vatMap": { "title": "MWST-Code nach steuerlicher Behandlung (Kosten)" },
|
||||
"outputVatMap": { "title": "MWST-Code nach Umsatzsatz", "empty": "Fügen Sie oben Ausgangs-MWST-Codes hinzu, um pro Umsatzsatz einen Code festzulegen." },
|
||||
"categoryMap": { "title": "Aufwandkategorie → Konto" },
|
||||
"accounts": { "title": "Kontenplan" },
|
||||
"vatCodes": { "title": "MWST-Codes" },
|
||||
"account": {
|
||||
"addTitle": "Konto hinzufügen", "editTitle": "Konto bearbeiten",
|
||||
"number": "Kontonummer", "name": "Name", "type": "Art",
|
||||
"confirmDelete": "Dieses Konto löschen?"
|
||||
},
|
||||
"vat": {
|
||||
"addTitle": "MWST-Code hinzufügen", "editTitle": "MWST-Code bearbeiten",
|
||||
"code": "Code", "name": "Name", "rate": "Satz %", "direction": "Richtung",
|
||||
"account": "MWST-Konto", "noAccount": "— keines —", "confirmDelete": "Diesen MWST-Code löschen?"
|
||||
},
|
||||
"export": {
|
||||
"title": "Treuhänder-Export",
|
||||
"intro": "Lade das Sammeljournal (Erträge + Kosten als periodengerechte Buchungssätze mit Konto- und MWST-Codes) zum Import in die Buchhaltungssoftware deines Treuhänders herunter.",
|
||||
"format": "Zielsoftware",
|
||||
"format_generic": "Generisch (alle Spalten)",
|
||||
"format_banana": "Banana — doppelte Buchhaltung",
|
||||
"format_banana_ie": "Banana — Einnahmen/Ausgaben",
|
||||
"format_bexio": "bexio",
|
||||
"download": "CSV herunterladen",
|
||||
"failed": "Export fehlgeschlagen.",
|
||||
"disclaimer": "Nur periodengerecht (Belegdaten) — Zahlungen/Bankbewegungen sind nicht enthalten. Konto- + MWST-Codes folgen deiner Kontenplan-Zuordnung. Den Import vor der Einreichung immer mit dem Treuhänder prüfen."
|
||||
}
|
||||
},
|
||||
"taxReport": {
|
||||
"title": "Steuerliste",
|
||||
"intro": "Periodenbezogene Umsatzliste mit Netto- und MwSt-Aufschlüsselung, gruppiert nach MwSt-Satz. Stornierte Rechnungen bleiben für die lückenlose Nummernfolge sichtbar, sind aber nicht in den Summen enthalten.",
|
||||
@@ -3628,6 +3947,18 @@
|
||||
"currency": "Währung"
|
||||
},
|
||||
"exportPdf": "PDF exportieren",
|
||||
"ledgerExport": "Treuhänder-Export",
|
||||
"ledgerExportHint": "Buchungssätze für Ihren Treuhänder, abgebildet über Ihren Kontenplan.",
|
||||
"ledgerExportConfigure": "Einrichten →",
|
||||
"export": {
|
||||
"reportTitle": "Bericht",
|
||||
"reportHint": "Lesbare Liste — für Ihre Unterlagen.",
|
||||
"scopeLabel": "Umfang",
|
||||
"scopeAll": "Vollständig",
|
||||
"scopeIncome": "Nur Einnahmen",
|
||||
"scopeCost": "Nur Kosten",
|
||||
"journalTitle": "Buchungsjournal"
|
||||
},
|
||||
"exportCsv": "CSV exportieren",
|
||||
"exportFailed": "Export fehlgeschlagen. Bitte erneut versuchen.",
|
||||
"errorTitle": "Steuerliste konnte nicht geladen werden",
|
||||
@@ -3638,6 +3969,32 @@
|
||||
"grandTotalVat": "Gesamt MwSt.",
|
||||
"grandTotalGross": "Gesamt Brutto",
|
||||
"cancelledFootnote": "{{count}} stornierte Rechnung(en) — Beträge nicht in den Summen enthalten (für lückenlose Nummernfolge dargestellt).",
|
||||
"skontoTooltip": "Mit Skonto bezahlt",
|
||||
"costsTitle": "Kosten (Eingangsrechnungen + Spesen)",
|
||||
"costsDisclaimer": "Diese Einnahmen-Ausgaben-Übersicht ist eine Orientierungshilfe für Ihre Aufzeichnungen (Milchbüchleinrechnung). Vorsteuerabzug und Ergebnis hängen von der steuerlichen Behandlung jeder Kostenposition ab — vor der Einreichung mit Ihrem Treuhänder / der Steuerverwaltung prüfen.",
|
||||
"summary": {
|
||||
"outgoingTitle": "Ausgangsrechnungen",
|
||||
"title": "Einnahmen / Ausgaben",
|
||||
"income": "Einnahmen",
|
||||
"costs": "Ausgaben",
|
||||
"result": "Ergebnis",
|
||||
"vatPayable": "MWST-Zahllast (Umsatz- − Vorsteuer)",
|
||||
"vatUnconfigured": "Die MWST-Registrierung ist nicht konfiguriert, daher kann die MWST-Zahllast nicht berechnet werden. Lege sie unter Einstellungen → Buchhaltung fest."
|
||||
},
|
||||
"cost": {
|
||||
"source": "Art",
|
||||
"sourceIncoming": "Eingang",
|
||||
"sourceExpense": "Spese",
|
||||
"supplier": "Lieferant / Beschreibung",
|
||||
"taxTreatment": "Steuerliche Behandlung",
|
||||
"company": "Unternehmen",
|
||||
"total": "Summe Kosten"
|
||||
},
|
||||
"type": {
|
||||
"outgoing": "Ausgangsrechnung",
|
||||
"incoming": "Eingangsrechnung",
|
||||
"expense": "Aufwand"
|
||||
},
|
||||
"col": {
|
||||
"date": "Datum",
|
||||
"invoice": "Rechnung",
|
||||
@@ -3646,7 +4003,12 @@
|
||||
"vatRate": "MwSt %",
|
||||
"net": "Netto",
|
||||
"vat": "MwSt.",
|
||||
"total": "Brutto"
|
||||
"total": "Brutto",
|
||||
"skonto": "Skonto",
|
||||
"type": "Typ",
|
||||
"reference": "Referenz",
|
||||
"party": "Kunde / Lieferant",
|
||||
"tax": "Steuer"
|
||||
}
|
||||
},
|
||||
"quotes": {
|
||||
|
||||
@@ -98,6 +98,7 @@
|
||||
"error": "Error",
|
||||
"configureInSettings": "Configure defaults in Settings ↗",
|
||||
"save": "Save",
|
||||
"saving": "Saving…",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"edit": "Edit",
|
||||
@@ -196,6 +197,7 @@
|
||||
"users": "Users",
|
||||
"calendar": "Calendar",
|
||||
"clients": "CRM",
|
||||
"accounting": "Accounting",
|
||||
"betaTag": "Beta"
|
||||
},
|
||||
"archives": {
|
||||
@@ -1142,6 +1144,7 @@
|
||||
"communication": "Communication",
|
||||
"scheduling": "Scheduling",
|
||||
"sales": "Sales",
|
||||
"accounting": "Accounting",
|
||||
"insights": "Insights & Access",
|
||||
"customers": "Customers",
|
||||
"clients": "CRM"
|
||||
@@ -1158,6 +1161,10 @@
|
||||
"description": "The core PicPeak surface. Always available.",
|
||||
"locked": "Galleries are the foundation of PicPeak and can't be turned off."
|
||||
},
|
||||
"incomingMail": {
|
||||
"title": "Incoming mail",
|
||||
"description": "Poll a dedicated mailbox (IMAP) every minute and drop invoice attachments into Accounting → Incoming invoices. Configure the mailbox under Settings → Email."
|
||||
},
|
||||
"reminderEmails": {
|
||||
"title": "Reminder Emails",
|
||||
"description": "Automatic pre-event nudge to customers N days before their event date. Per-category templates (concert, corporate, wedding, …) editable in Settings → Reminder templates; per-event override on the event detail page."
|
||||
@@ -1188,10 +1195,28 @@
|
||||
"sidebar": "Invoices"
|
||||
},
|
||||
"taxReport": {
|
||||
"title": "Tax report",
|
||||
"title": "Tax export",
|
||||
"description": "Period-scoped revenue list with net + VAT breakdown grouped by VAT rate. Export as PDF (landscape, company letterhead) or CSV for your accountant. Cancelled invoices stay visible for a gap-free audit trail but are excluded from totals.",
|
||||
"sidebar": "Tax",
|
||||
"requiresBills": "Enable Bills first — the tax report reads from your invoices."
|
||||
"requiresBills": "Enable Bills first — the tax report reads from your invoices.",
|
||||
"requiresAccounting": "Enable Accounting first — Tax export lives in the Accounting section."
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"incomingInvoices": {
|
||||
"title": "Incoming invoices",
|
||||
"description": "Capture received supplier invoices (upload or phone/tablet camera), categorize expenses, and re-bill costs to clients on the relevant event with a contract-driven markup.",
|
||||
"sidebar": "Incoming",
|
||||
"requiresAccounting": "Enable Accounting first — Incoming invoices live in the Accounting section."
|
||||
},
|
||||
"expenses": {
|
||||
"title": "Expenses",
|
||||
"description": "Internal expenses (mileage, per-diem, cash) booked to an event or the company, with optional proof. Separate from incoming supplier invoices.",
|
||||
"sidebar": "Expenses",
|
||||
"requiresAccounting": "Enable Accounting first — Expenses live in the Accounting section."
|
||||
},
|
||||
"analytics": {
|
||||
"title": "Analytics",
|
||||
@@ -1247,6 +1272,24 @@
|
||||
},
|
||||
"crm": {
|
||||
"title": "CRM behaviour"
|
||||
},
|
||||
"accounting": {
|
||||
"title": "Accounting",
|
||||
"subtitle": "Default rates for internal expenses 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.",
|
||||
"requireProof": "Require a proof file on every expense",
|
||||
"vat": {
|
||||
"title": "VAT registration & reclaim",
|
||||
"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."
|
||||
},
|
||||
"disclaimer": "Rates and VAT/tax treatment are guidance only — verify with your Treuhänder.",
|
||||
"savedToast": "Accounting settings saved."
|
||||
}
|
||||
},
|
||||
"analytics": {
|
||||
@@ -1502,6 +1545,8 @@
|
||||
"forceColorModeNone": "No force (user choice)",
|
||||
"forceColorModeDark": "Force dark",
|
||||
"forceColorModeLight": "Force light",
|
||||
"forcedModeBrandingHint": "Light/dark is locked site-wide by the Force control below — the per-theme mode picker is hidden because it would have no effect.",
|
||||
"forcedModeGalleryNote": "A site-wide color lock is active, so this gallery follows the locked light/dark mode. Color and light/dark options are hidden here and can’t be overridden per gallery.",
|
||||
"colorGroupSurfaces": "Surfaces",
|
||||
"colorGroupSurfacesHelp": "The neutral layers behind your content. Background sits furthest back; Surface and Elevated stack on top.",
|
||||
"backgroundColorHelp": "The page itself — body background of every gallery, admin page and CMS page.",
|
||||
@@ -2017,6 +2062,55 @@
|
||||
"success": "Email queue flushed — {{sent}} sent, {{failed}} failed",
|
||||
"empty": "No pending emails to send"
|
||||
},
|
||||
"incoming": {
|
||||
"title": "Incoming mail (IMAP)",
|
||||
"subtitle": "A dedicated mailbox polled every minute; attachments land in Accounting → Incoming invoices.",
|
||||
"host": "IMAP Host",
|
||||
"test": "Test connection",
|
||||
"testOk": "Connected to {{folder}} — {{messages}} messages, {{unseen}} unread.",
|
||||
"testFailed": "Connection failed.",
|
||||
"roundTrip": "Round-trip test",
|
||||
"roundTripHint": "Sends a test email via your SMTP settings to this mailbox and confirms it arrives. Save both first.",
|
||||
"roundTripOk": "Round-trip OK — delivered to {{recipient}} in {{seconds}}s.",
|
||||
"roundTripFailed": "Round-trip test failed.",
|
||||
"poll": "Check now",
|
||||
"pollHint": "Check the mailbox now instead of waiting for the 60-second poll. Ingests unread attachments into Incoming invoices.",
|
||||
"pollOk": "Checked mailbox — {{count}} new email(s) ingested.",
|
||||
"pollDisabled": "Incoming mail is turned off — enable it under Settings → Features.",
|
||||
"pollUnconfigured": "Save the incoming mail settings first.",
|
||||
"pollBusy": "A poll is already running — try again in a moment.",
|
||||
"pollFailed": "Mailbox poll failed.",
|
||||
"port": "Port",
|
||||
"security": "Security",
|
||||
"ssl": "SSL/TLS",
|
||||
"plain": "None / STARTTLS",
|
||||
"user": "Username",
|
||||
"pass": "Password",
|
||||
"folder": "Folder",
|
||||
"save": "Save Incoming Mail Settings",
|
||||
"detectFolders": "Detect",
|
||||
"folderHint": "Enter host, username and password, then Detect to list the mailbox folders.",
|
||||
"foldersDetected": "{{count}} folders found.",
|
||||
"noFolders": "No folders returned by the server.",
|
||||
"detectFailed": "Could not detect folders.",
|
||||
"requiredFields": "Host, port and username are required.",
|
||||
"savedToast": "Incoming mail settings saved."
|
||||
},
|
||||
"received": {
|
||||
"tab": "Received emails",
|
||||
"from": "From",
|
||||
"subject": "Subject",
|
||||
"received": "Received",
|
||||
"status": "Status",
|
||||
"empty": "No received emails yet. Enable incoming mail and configure the mailbox.",
|
||||
"inbox": "inbox",
|
||||
"statusValue": {
|
||||
"ingested": "Ingested",
|
||||
"no_attachment": "No attachment",
|
||||
"duplicate": "Duplicate",
|
||||
"error": "Error"
|
||||
}
|
||||
},
|
||||
"sentEmails": {
|
||||
"tab": "Sent emails",
|
||||
"title": "Sent emails",
|
||||
@@ -3382,6 +3476,180 @@
|
||||
"development": "Development"
|
||||
}
|
||||
},
|
||||
"accounting": {
|
||||
"title": "Accounting",
|
||||
"subtitle": "Inbound supplier invoices, expenses and reporting.",
|
||||
"navAriaLabel": "Accounting navigation",
|
||||
"empty": {
|
||||
"title": "No accounting features enabled",
|
||||
"body": "Enable the Tax report (or another accounting sub-feature) under Settings → Features to get started."
|
||||
},
|
||||
"subnav": {
|
||||
"incomingInvoices": "Incoming invoices",
|
||||
"expenses": "Expenses",
|
||||
"taxReport": "Tax",
|
||||
"export": "Treuhänder export",
|
||||
"chartOfAccounts": "Chart of accounts"
|
||||
},
|
||||
"taxTreatment": {
|
||||
"domestic": "Domestic (with VAT)",
|
||||
"reverse_charge_service": "Reverse charge (services)",
|
||||
"foreign_vat_non_reclaimable": "Foreign VAT (non-reclaimable)",
|
||||
"import_goods": "Import of goods"
|
||||
},
|
||||
"disposition": {
|
||||
"rebill": "Re-bill to client",
|
||||
"durchlaufend": "Pass-through",
|
||||
"eigener_aufwand": "Company expense",
|
||||
"duplikat": "Duplicate",
|
||||
"abgelehnt": "Declined"
|
||||
},
|
||||
"markup": {
|
||||
"none": "None / from contract",
|
||||
"percent": "Percent",
|
||||
"flat": "Flat"
|
||||
},
|
||||
"inbox": {
|
||||
"captureTitle": "Capture a supplier invoice",
|
||||
"captureBody": "Photograph a paper invoice with your device camera, or upload a PDF / image.",
|
||||
"scanCamera": "Scan with camera",
|
||||
"uploadFile": "Upload file",
|
||||
"capturedToast": "Document captured.",
|
||||
"categorizedToast": "Document categorized.",
|
||||
"triageTitle": "Categorize document",
|
||||
"saveCategorize": "Save",
|
||||
"saveCategorizePay": "Save & mark paid",
|
||||
"categorize": "Categorize",
|
||||
"view": "View",
|
||||
"empty": "No documents yet — capture one above.",
|
||||
"untitled": "Untitled document",
|
||||
"noAmount": "amount not entered",
|
||||
"rebillHint": "Creates an editable scheduled invoice on the client. VAT/tax handling is v1 — verify with your Treuhänder.",
|
||||
"previewLoading": "Loading preview…",
|
||||
"previewError": "Preview unavailable — enter the fields manually.",
|
||||
"qrHint": "Showing the last page — the Swiss QR-bill usually sits at the bottom.",
|
||||
"prevPage": "Prev",
|
||||
"nextPage": "Next",
|
||||
"pageOf": "Page {{n}} / {{total}}",
|
||||
"status": {
|
||||
"label": "Status",
|
||||
"unsorted": "New",
|
||||
"categorized": "Categorized",
|
||||
"declined": "Declined",
|
||||
"duplicate": "Duplicate"
|
||||
},
|
||||
"field": {
|
||||
"supplier": "Supplier",
|
||||
"total": "Total",
|
||||
"currency": "Currency",
|
||||
"invoiceDate": "Invoice date",
|
||||
"disposition": "Disposition",
|
||||
"category": "Category",
|
||||
"categoryNone": "— none —",
|
||||
"declineReason": "Reason",
|
||||
"customer": "Client",
|
||||
"eventId": "Event ID (optional)",
|
||||
"markup": "Markup",
|
||||
"reference": "Payment reference",
|
||||
"referenceHint": "QR / ESR reference or message"
|
||||
}
|
||||
},
|
||||
"expenseStatus": {
|
||||
"open": "Open",
|
||||
"parked": "Parked",
|
||||
"billed": "Billed",
|
||||
"declined": "Declined"
|
||||
},
|
||||
"paymentMethod": {
|
||||
"bank_transfer": "Bank transfer",
|
||||
"cash": "Cash",
|
||||
"twint": "TWINT",
|
||||
"paypal": "PayPal",
|
||||
"card": "Card",
|
||||
"other": "Other"
|
||||
},
|
||||
"booking": {
|
||||
"label": "Book to",
|
||||
"company": "Company",
|
||||
"event": "Event",
|
||||
"eventId": "Event ID"
|
||||
},
|
||||
"incoming": {
|
||||
"triageTitle": "Categorize incoming invoice",
|
||||
"payTitle": "Mark supplier paid",
|
||||
"outstanding": "Outstanding",
|
||||
"markPaid": "Mark paid",
|
||||
"markUnpaid": "Mark unpaid",
|
||||
"confirmPaid": "Mark paid",
|
||||
"paid": "Paid",
|
||||
"paidToast": "Marked as paid.",
|
||||
"categorizedToast": "Categorized.",
|
||||
"categorizedPaidToast": "Categorized and marked paid."
|
||||
},
|
||||
"expense": {
|
||||
"kind": "Type",
|
||||
"km": "Kilometres",
|
||||
"days": "Days",
|
||||
"rate": "Rate",
|
||||
"rateDefault": "Default {{rate}} — leave blank to use it",
|
||||
"computed": "Amount",
|
||||
"who": "Paid by / vendor",
|
||||
"whoHint": "e.g. coworker name or shop",
|
||||
"proof": "Proof",
|
||||
"proofRequired": "A proof file is required.",
|
||||
"proofExisting": "A proof file is already attached — upload a new one to replace it.",
|
||||
"viewProof": "Proof"
|
||||
},
|
||||
"expenseKind": {
|
||||
"amount": "Amount",
|
||||
"mileage": "Mileage (km)",
|
||||
"per_diem": "Per-diem"
|
||||
},
|
||||
"category": {
|
||||
"infrastructure": "Infrastructure & rent",
|
||||
"equipment": "Equipment & hardware",
|
||||
"software": "Software & licenses",
|
||||
"material": "Materials & supplies",
|
||||
"travel": "Travel & expenses",
|
||||
"marketing": "Advertising & marketing",
|
||||
"services": "Services / subcontracting",
|
||||
"insurance": "Insurance & fees",
|
||||
"training": "Training",
|
||||
"other": "Other"
|
||||
},
|
||||
"ledger": {
|
||||
"allStatuses": "All statuses",
|
||||
"allDispositions": "All dispositions",
|
||||
"allKinds": "All types",
|
||||
"empty": "No expenses yet — add one above.",
|
||||
"untitled": "Expense",
|
||||
"invoiceLink": "Invoice",
|
||||
"paid": "Paid",
|
||||
"markPaid": "Mark paid",
|
||||
"markPaidTitle": "Mark supplier paid",
|
||||
"paidDate": "Payment date",
|
||||
"method": "Method",
|
||||
"reference": "Reference (optional)",
|
||||
"confirmPaid": "Mark paid",
|
||||
"paidToast": "Marked as paid.",
|
||||
"unpaidToast": "Marked as not paid.",
|
||||
"addExpense": "Add expense",
|
||||
"addTitle": "Add expense",
|
||||
"editTitle": "Edit expense",
|
||||
"updatedToast": "Expense updated.",
|
||||
"payTitle": "Mark expense paid",
|
||||
"invoiced": "Invoiced",
|
||||
"invoicedToast": "Added to a client invoice.",
|
||||
"invoiceTitle": "Add to client invoice",
|
||||
"invoiceHint": "This creates a billable line on the client’s next scheduled invoice and locks the expense from further edits.",
|
||||
"addToInvoice": "Add to invoice",
|
||||
"locked": "Locked",
|
||||
"lockedHint": "Locked — this expense is on a client invoice.",
|
||||
"description": "Description",
|
||||
"descriptionHint": "e.g. mileage, per-diem, cash receipt",
|
||||
"createdToast": "Expense added."
|
||||
}
|
||||
},
|
||||
"projects": {
|
||||
"title": "Project Overview",
|
||||
"subtitle": "Group events into projects and see every email, document, gallery and hour in one cockpit.",
|
||||
@@ -3613,6 +3881,57 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ledger": {
|
||||
"intro": "Used only to produce the Treuhänder export — picpeak does not keep double-entry books. The seeded chart + VAT codes follow the Swiss/LI KMU-Kontenrahmen; adjust them to match your Treuhänder’s setup.",
|
||||
"settingsSaved": "Mappings saved.",
|
||||
"saveDefaults": "Save mappings",
|
||||
"accountType": {
|
||||
"asset": "Asset", "liability": "Liability", "equity": "Equity", "revenue": "Revenue", "expense": "Expense"
|
||||
},
|
||||
"vatDirection": {
|
||||
"input": "Input (Vorsteuer)", "output": "Output (Umsatzsteuer)"
|
||||
},
|
||||
"defaults": {
|
||||
"title": "Default & system accounts",
|
||||
"none": "— none —",
|
||||
"ledger_account_debitoren": "Accounts receivable (Debitoren)",
|
||||
"ledger_account_kreditoren": "Accounts payable (Kreditoren)",
|
||||
"ledger_account_bank": "Bank",
|
||||
"ledger_account_cash": "Cash",
|
||||
"ledger_account_default_revenue": "Default revenue account",
|
||||
"ledger_account_default_expense": "Default expense account",
|
||||
"ledger_account_mileage": "Mileage account",
|
||||
"ledger_account_per_diem": "Per-diem account",
|
||||
"ledger_account_rebilled_revenue": "Re-billed expenses (revenue)"
|
||||
},
|
||||
"vatMap": { "title": "VAT code by tax treatment (costs)" },
|
||||
"outputVatMap": { "title": "VAT code by revenue rate", "empty": "Add output VAT codes above to configure a code per revenue rate." },
|
||||
"categoryMap": { "title": "Expense category → account" },
|
||||
"accounts": { "title": "Chart of accounts" },
|
||||
"vatCodes": { "title": "VAT codes" },
|
||||
"account": {
|
||||
"addTitle": "Add account", "editTitle": "Edit account",
|
||||
"number": "Account number", "name": "Name", "type": "Type",
|
||||
"confirmDelete": "Delete this account?"
|
||||
},
|
||||
"vat": {
|
||||
"addTitle": "Add VAT code", "editTitle": "Edit VAT code",
|
||||
"code": "Code", "name": "Name", "rate": "Rate %", "direction": "Direction",
|
||||
"account": "VAT account", "noAccount": "— none —", "confirmDelete": "Delete this VAT code?"
|
||||
},
|
||||
"export": {
|
||||
"title": "Treuhänder export",
|
||||
"intro": "Download the collective journal (revenue + costs as accrual postings with account and VAT codes) for import into your Treuhänder’s accounting software.",
|
||||
"format": "Target tool",
|
||||
"format_generic": "Generic (all columns)",
|
||||
"format_banana": "Banana — double-entry",
|
||||
"format_banana_ie": "Banana — income & expense",
|
||||
"format_bexio": "bexio",
|
||||
"download": "Download CSV",
|
||||
"failed": "Export failed.",
|
||||
"disclaimer": "Accrual basis only (document dates) — payments/bank movements are not included. Account + VAT codes follow your Chart-of-accounts mapping. Always review the import with your Treuhänder before filing."
|
||||
}
|
||||
},
|
||||
"taxReport": {
|
||||
"title": "Tax report",
|
||||
"intro": "Period-scoped revenue list with net + VAT breakdown grouped by VAT rate. Cancelled invoices stay visible for audit-trail continuity but are excluded from totals.",
|
||||
@@ -3628,6 +3947,18 @@
|
||||
"currency": "Currency"
|
||||
},
|
||||
"exportPdf": "Export PDF",
|
||||
"ledgerExport": "Accountant export",
|
||||
"ledgerExportHint": "Bookkeeping entries for your accountant, mapped via your Chart of accounts.",
|
||||
"ledgerExportConfigure": "Configure →",
|
||||
"export": {
|
||||
"reportTitle": "Report",
|
||||
"reportHint": "Readable list — for your own records.",
|
||||
"scopeLabel": "Scope",
|
||||
"scopeAll": "Complete",
|
||||
"scopeIncome": "Income only",
|
||||
"scopeCost": "Cost only",
|
||||
"journalTitle": "Accounting journal"
|
||||
},
|
||||
"exportCsv": "Export CSV",
|
||||
"exportFailed": "Export failed. Please try again.",
|
||||
"errorTitle": "Could not load tax report",
|
||||
@@ -3638,6 +3969,32 @@
|
||||
"grandTotalVat": "Total VAT",
|
||||
"grandTotalGross": "Total gross",
|
||||
"cancelledFootnote": "{{count}} cancelled invoice(s) — amounts excluded from totals (shown for audit-trail continuity).",
|
||||
"skontoTooltip": "Paid with Skonto",
|
||||
"costsTitle": "Costs (incoming invoices + expenses)",
|
||||
"costsDisclaimer": "This income/expense overview is a guideline for your records (Einnahmen-Ausgaben-Rechnung). VAT reclaimability and the result figure depend on each cost’s tax treatment — verify with your Treuhänder / tax authority before filing.",
|
||||
"summary": {
|
||||
"outgoingTitle": "Outgoing invoices",
|
||||
"title": "Income / costs",
|
||||
"income": "Income",
|
||||
"costs": "Costs",
|
||||
"result": "Result",
|
||||
"vatPayable": "VAT payable (output − input)",
|
||||
"vatUnconfigured": "VAT registration isn’t configured, so VAT payable can’t be computed. Set it under Settings → Accounting."
|
||||
},
|
||||
"cost": {
|
||||
"source": "Type",
|
||||
"sourceIncoming": "Incoming",
|
||||
"sourceExpense": "Expense",
|
||||
"supplier": "Supplier / description",
|
||||
"taxTreatment": "Tax treatment",
|
||||
"company": "Company",
|
||||
"total": "Total costs"
|
||||
},
|
||||
"type": {
|
||||
"outgoing": "Outgoing invoice",
|
||||
"incoming": "Incoming invoice",
|
||||
"expense": "Expense"
|
||||
},
|
||||
"col": {
|
||||
"date": "Date",
|
||||
"invoice": "Invoice",
|
||||
@@ -3646,7 +4003,12 @@
|
||||
"vatRate": "VAT %",
|
||||
"net": "Net",
|
||||
"vat": "VAT",
|
||||
"total": "Gross"
|
||||
"total": "Gross",
|
||||
"skonto": "Skonto",
|
||||
"type": "Type",
|
||||
"reference": "Reference",
|
||||
"party": "Customer / supplier",
|
||||
"tax": "Tax"
|
||||
}
|
||||
},
|
||||
"quotes": {
|
||||
|
||||
@@ -694,6 +694,7 @@ export const CreateEventPage: React.FC = () => {
|
||||
onChange={handleThemeChange}
|
||||
presetName={formData.theme_preset}
|
||||
onPresetChange={handlePresetChange}
|
||||
forceColorMode={publicSettings?.branding_force_color_mode ?? null}
|
||||
showGalleryLayouts={true}
|
||||
hideActions={true}
|
||||
onSyncFromBranding={() => {
|
||||
|
||||
@@ -39,7 +39,7 @@ type EditableFields =
|
||||
| 'phone' | 'companyName' | 'billingEmail' | 'vatId'
|
||||
| 'addressLine1' | 'addressLine2' | 'postalCode' | 'city' | 'state'
|
||||
| 'countryCode' | 'countryName' | 'preferredLanguage' | 'notes'
|
||||
| 'featureCalendar' | 'featureQuotes' | 'featureBills' | 'featureHoursLogging'
|
||||
| 'featureCalendar' | 'featureQuotes' | 'featureBills' | 'featureHoursLogging' | 'featureContracts'
|
||||
| 'hourlyRateMinor' | 'billingCadence' | 'billingCycleDay' | 'skontoDisabled';
|
||||
|
||||
// `fmtDate` (from useLocalizedDate, below) is the single canonical date
|
||||
@@ -137,6 +137,9 @@ export const CustomerDetailPage: React.FC = () => {
|
||||
featureQuotes: customer.featureQuotes ?? false,
|
||||
featureBills: customer.featureBills ?? false,
|
||||
featureHoursLogging: customer.featureHoursLogging ?? false,
|
||||
// Contracts is opt-OUT (default on) — preserve the tab for customers
|
||||
// saved before the per-customer override existed.
|
||||
featureContracts: customer.featureContracts ?? true,
|
||||
hourlyRateMinor: customer.hourlyRateMinor ?? null,
|
||||
billingCadence: customer.billingCadence ?? 'per_event',
|
||||
billingCycleDay: customer.billingCycleDay ?? 1,
|
||||
@@ -145,7 +148,7 @@ export const CustomerDetailPage: React.FC = () => {
|
||||
}
|
||||
}, [customer, form]);
|
||||
|
||||
const toggleFeature = (key: 'featureCalendar' | 'featureQuotes' | 'featureBills' | 'featureHoursLogging') => {
|
||||
const toggleFeature = (key: 'featureCalendar' | 'featureQuotes' | 'featureBills' | 'featureHoursLogging' | 'featureContracts') => {
|
||||
setForm((prev) => ({ ...prev, [key]: !prev[key] }) as any);
|
||||
};
|
||||
|
||||
@@ -298,13 +301,13 @@ export const CustomerDetailPage: React.FC = () => {
|
||||
className="p-2 -ml-2 rounded hover:bg-neutral-100 dark:hover:bg-neutral-700"
|
||||
aria-label={t('common.back', 'Back')}
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 text-muted-theme" />
|
||||
<ArrowLeft className="w-4 h-4 text-neutral-500 dark:text-neutral-400" />
|
||||
</Link>
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-bold text-theme truncate">
|
||||
<h1 className="text-2xl font-bold text-neutral-900 dark:text-neutral-100 truncate">
|
||||
{customer.displayName || customer.email}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-theme truncate">{customer.email}</p>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 truncate">{customer.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
@@ -330,7 +333,7 @@ export const CustomerDetailPage: React.FC = () => {
|
||||
{t('customers.passive.badge', 'Passive — admin only')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[11px] text-muted-theme">
|
||||
<span className="text-[11px] text-neutral-500 dark:text-neutral-400">
|
||||
{t('customers.passive.activeLabel', 'Has portal access')}
|
||||
</span>
|
||||
)}
|
||||
@@ -339,16 +342,16 @@ export const CustomerDetailPage: React.FC = () => {
|
||||
|
||||
{/* Account section */}
|
||||
<Card padding="lg">
|
||||
<h2 className="text-lg font-semibold text-theme mb-4 flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4 flex items-center gap-2">
|
||||
<Mail className="w-5 h-5" /> {t('customers.detail.accountSection', 'Account')}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-theme mb-1">{t('customers.detail.email', 'Email')}</label>
|
||||
<label className="block text-sm font-medium text-neutral-900 dark:text-neutral-100 mb-1">{t('customers.detail.email', 'Email')}</label>
|
||||
<Input type="email" value={form.email || ''} onChange={setField('email')} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-theme mb-1">{t('customers.detail.preferredLanguage', 'Preferred language')}</label>
|
||||
<label className="block text-sm font-medium text-neutral-900 dark:text-neutral-100 mb-1">{t('customers.detail.preferredLanguage', 'Preferred language')}</label>
|
||||
<select
|
||||
value={form.preferredLanguage || profileDefaultLocale}
|
||||
onChange={setField('preferredLanguage')}
|
||||
@@ -372,12 +375,12 @@ export const CustomerDetailPage: React.FC = () => {
|
||||
|
||||
{/* Personal section */}
|
||||
<Card padding="lg">
|
||||
<h2 className="text-lg font-semibold text-theme mb-4">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">
|
||||
{t('customers.detail.personalSection', 'Personal information')}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-theme mb-1">{t('customers.detail.salutation', 'Salutation')}</label>
|
||||
<label className="block text-sm font-medium text-neutral-900 dark:text-neutral-100 mb-1">{t('customers.detail.salutation', 'Salutation')}</label>
|
||||
{/* Salutation values are stored verbatim in the DB ("Herr",
|
||||
"Frau", "Mx", "Dr") — those are the canonical token values
|
||||
across locales. Display labels are translated; the value
|
||||
@@ -397,25 +400,25 @@ export const CustomerDetailPage: React.FC = () => {
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-theme mb-1">{t('customers.detail.firstName', 'First name')}</label>
|
||||
<label className="block text-sm font-medium text-neutral-900 dark:text-neutral-100 mb-1">{t('customers.detail.firstName', 'First name')}</label>
|
||||
<Input value={form.firstName || ''} onChange={setField('firstName')} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-theme mb-1">{t('customers.detail.lastName', 'Last name')}</label>
|
||||
<label className="block text-sm font-medium text-neutral-900 dark:text-neutral-100 mb-1">{t('customers.detail.lastName', 'Last name')}</label>
|
||||
<Input value={form.lastName || ''} onChange={setField('lastName')} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-theme mb-1">{t('customers.detail.displayName', 'Display name')}</label>
|
||||
<label className="block text-sm font-medium text-neutral-900 dark:text-neutral-100 mb-1">{t('customers.detail.displayName', 'Display name')}</label>
|
||||
<Input value={form.displayName || ''} onChange={setField('displayName')} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-theme mb-1 flex items-center gap-1">
|
||||
<label className="block text-sm font-medium text-neutral-900 dark:text-neutral-100 mb-1 flex items-center gap-1">
|
||||
<Phone className="w-4 h-4" /> {t('customers.detail.phone', 'Phone')}
|
||||
</label>
|
||||
<Input value={form.phone || ''} onChange={setField('phone')} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-theme mb-1 flex items-center gap-1">
|
||||
<label className="block text-sm font-medium text-neutral-900 dark:text-neutral-100 mb-1 flex items-center gap-1">
|
||||
<Building2 className="w-4 h-4" /> {t('customers.detail.company', 'Company')}
|
||||
</label>
|
||||
<Input value={form.companyName || ''} onChange={setField('companyName')} />
|
||||
@@ -434,10 +437,10 @@ export const CustomerDetailPage: React.FC = () => {
|
||||
|
||||
{/* Notes (admin-only) */}
|
||||
<Card padding="lg">
|
||||
<h2 className="text-lg font-semibold text-theme mb-4 flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4 flex items-center gap-2">
|
||||
<FileText className="w-5 h-5" /> {t('customers.detail.notesSection', 'Internal notes')}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-theme mb-3">
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-3">
|
||||
{t('customers.detail.notesHint', 'Visible only to admins. Never shown to the customer.')}
|
||||
</p>
|
||||
<textarea
|
||||
@@ -451,7 +454,7 @@ export const CustomerDetailPage: React.FC = () => {
|
||||
{/* Assigned events */}
|
||||
<Card padding="lg">
|
||||
<div className="flex items-center justify-between gap-4 mb-4 flex-wrap">
|
||||
<h2 className="text-lg font-semibold text-theme flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 flex items-center gap-2">
|
||||
<Calendar className="w-5 h-5" /> {t('customers.detail.eventsSection', 'Assigned events')}
|
||||
</h2>
|
||||
{/* Manage galleries: opens the multi-select dialog that
|
||||
@@ -470,17 +473,17 @@ export const CustomerDetailPage: React.FC = () => {
|
||||
</Button>
|
||||
</div>
|
||||
{customer.events.length === 0 ? (
|
||||
<p className="text-sm text-muted-theme">
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
{t('customers.detail.noEvents', 'Not assigned to any events yet. Use "Manage galleries" to add some.')}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y" style={{ borderColor: 'var(--color-surface-border)' }}>
|
||||
<ul className="divide-y divide-neutral-200 dark:divide-neutral-700">
|
||||
{customer.events.map((ev) => (
|
||||
<li key={ev.id} className="py-2 flex items-center justify-between">
|
||||
<Link to={`/admin/events/${ev.id}`} className="text-theme hover:underline">
|
||||
<Link to={`/admin/events/${ev.id}`} className="text-neutral-900 dark:text-neutral-100 hover:underline">
|
||||
{ev.eventName}
|
||||
</Link>
|
||||
<span className="text-xs text-muted-theme">
|
||||
<span className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{ev.eventDate ? fmtDate(ev.eventDate) : ''}
|
||||
{ev.expiresAt ? ` · ${t('customers.detail.expires', 'expires')} ${fmtDate(ev.expiresAt)}` : ''}
|
||||
</span>
|
||||
@@ -506,36 +509,36 @@ export const CustomerDetailPage: React.FC = () => {
|
||||
|
||||
{/* Address + billing */}
|
||||
<Card padding="lg">
|
||||
<h2 className="text-lg font-semibold text-theme mb-4 flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4 flex items-center gap-2">
|
||||
<MapPin className="w-5 h-5" /> {t('customers.detail.billingSection', 'Address & billing')}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-theme mb-1">{t('customers.detail.billingEmail', 'Billing email')}</label>
|
||||
<label className="block text-sm font-medium text-neutral-900 dark:text-neutral-100 mb-1">{t('customers.detail.billingEmail', 'Billing email')}</label>
|
||||
<Input type="email" value={form.billingEmail || ''} onChange={setField('billingEmail')} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-theme mb-1">{t('customers.detail.vatId', 'VAT / tax ID')}</label>
|
||||
<label className="block text-sm font-medium text-neutral-900 dark:text-neutral-100 mb-1">{t('customers.detail.vatId', 'VAT / tax ID')}</label>
|
||||
<Input value={form.vatId || ''} onChange={setField('vatId')} />
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-theme mb-1">{t('customers.detail.addressLine1', 'Address line 1')}</label>
|
||||
<label className="block text-sm font-medium text-neutral-900 dark:text-neutral-100 mb-1">{t('customers.detail.addressLine1', 'Address line 1')}</label>
|
||||
<Input value={form.addressLine1 || ''} onChange={setField('addressLine1')} />
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-theme mb-1">{t('customers.detail.addressLine2', 'Address line 2')}</label>
|
||||
<label className="block text-sm font-medium text-neutral-900 dark:text-neutral-100 mb-1">{t('customers.detail.addressLine2', 'Address line 2')}</label>
|
||||
<Input value={form.addressLine2 || ''} onChange={setField('addressLine2')} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-theme mb-1">{t('customers.detail.postalCode', 'Postal code')}</label>
|
||||
<label className="block text-sm font-medium text-neutral-900 dark:text-neutral-100 mb-1">{t('customers.detail.postalCode', 'Postal code')}</label>
|
||||
<Input value={form.postalCode || ''} onChange={setField('postalCode')} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-theme mb-1">{t('customers.detail.city', 'City')}</label>
|
||||
<label className="block text-sm font-medium text-neutral-900 dark:text-neutral-100 mb-1">{t('customers.detail.city', 'City')}</label>
|
||||
<Input value={form.city || ''} onChange={setField('city')} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-theme mb-1">{t('customers.detail.state', 'State / region')}</label>
|
||||
<label className="block text-sm font-medium text-neutral-900 dark:text-neutral-100 mb-1">{t('customers.detail.state', 'State / region')}</label>
|
||||
<Input value={form.state || ''} onChange={setField('state')} />
|
||||
</div>
|
||||
<div>
|
||||
@@ -570,13 +573,13 @@ export const CustomerDetailPage: React.FC = () => {
|
||||
toggle inside it is OFF — an empty "Customer features" card
|
||||
with just a title + hint reads as broken. The Card reappears
|
||||
the moment any master flag is re-enabled. */}
|
||||
{(flags.calendar || flags.quotes || flags.bills || flags.hoursLogging) && (
|
||||
{(flags.calendar || flags.quotes || flags.bills || flags.hoursLogging || flags.contracts) && (
|
||||
<Card padding="lg">
|
||||
<h2 className="text-lg font-semibold text-theme mb-1 flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-1 flex items-center gap-2">
|
||||
<ToggleLeft className="w-5 h-5" />
|
||||
{t('customers.detail.featuresSection', 'Customer features')}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-theme mb-4">
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-4">
|
||||
{t(
|
||||
'customers.detail.featuresHint',
|
||||
'Per-customer overrides for the customer-surface tabs. The global toggles in Settings → Features are the master switch — when global is OFF nobody sees the tab, regardless of what you set here. Defaults are ON, so flip a switch OFF to hide a tab for this specific customer.'
|
||||
@@ -609,11 +612,14 @@ export const CustomerDetailPage: React.FC = () => {
|
||||
...(flags.hoursLogging
|
||||
? [{ key: 'featureHoursLogging' as const, labelKey: 'customers.field.featureHoursLogging', fallback: 'Hours logging', badge: 'new' as const }]
|
||||
: []),
|
||||
...(flags.contracts
|
||||
? [{ key: 'featureContracts' as const, labelKey: 'customer.nav.contracts', fallback: 'Contracts', badge: 'new' as const }]
|
||||
: []),
|
||||
] as const).map(({ key, labelKey, fallback, badge }) => {
|
||||
const enabled = !!form[key];
|
||||
return (
|
||||
<label key={key} className="flex items-center justify-between gap-3 cursor-pointer">
|
||||
<span className="text-sm font-medium text-theme flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-neutral-900 dark:text-neutral-100 flex items-center gap-2">
|
||||
{t(labelKey, fallback)}
|
||||
{/* Status pill — 'soon' = amber, 'new' = green.
|
||||
Colors match Settings → Features StatusBadge so
|
||||
@@ -633,8 +639,8 @@ export const CustomerDetailPage: React.FC = () => {
|
||||
role="switch"
|
||||
aria-checked={enabled}
|
||||
onClick={() => toggleFeature(key)}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
style={{ backgroundColor: enabled ? 'var(--color-accent)' : 'var(--color-surface-border)' }}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 ${enabled ? '' : 'bg-neutral-300 dark:bg-neutral-600'}`}
|
||||
style={enabled ? { backgroundColor: 'var(--color-accent)' } : undefined}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${enabled ? 'translate-x-6' : 'translate-x-1'}`}
|
||||
@@ -653,7 +659,7 @@ export const CustomerDetailPage: React.FC = () => {
|
||||
per-entry basis from the standalone Hours logging page. */}
|
||||
{flags.hoursLogging && form.featureHoursLogging && (
|
||||
<div className="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-700">
|
||||
<label className="block text-sm font-medium text-theme mb-1">
|
||||
<label className="block text-sm font-medium text-neutral-900 dark:text-neutral-100 mb-1">
|
||||
{t('customers.field.hourlyRate', 'Default hourly rate')}
|
||||
</label>
|
||||
<DecimalInput
|
||||
@@ -668,7 +674,7 @@ export const CustomerDetailPage: React.FC = () => {
|
||||
placeholder="150.00"
|
||||
className="w-40 input"
|
||||
/>
|
||||
<p className="text-xs text-muted-theme mt-1">
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||
{t('customers.field.hourlyRateHint',
|
||||
'Major units (e.g. 150.00 for {{currency}} 150). Leave blank to require a per-entry override on every block.',
|
||||
{ currency: profileDefaultCurrency })}
|
||||
@@ -687,17 +693,17 @@ export const CustomerDetailPage: React.FC = () => {
|
||||
off — admin has nothing to bill, so cadence is moot. */}
|
||||
{flags.bills && (
|
||||
<Card padding="lg">
|
||||
<h2 className="text-lg font-semibold text-theme mb-1 flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-1 flex items-center gap-2">
|
||||
<Calendar className="w-5 h-5" />
|
||||
{t('customers.billing.section', 'Billing cadence')}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-theme mb-4">
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-4">
|
||||
{t('customers.billing.hint',
|
||||
'Per-event (default): every invoice is sent on its own schedule. Monthly: all invoices issued in the period accumulate into one bill that fires on the configured day.')}
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-theme mb-1">
|
||||
<label className="block text-sm font-medium text-neutral-900 dark:text-neutral-100 mb-1">
|
||||
{t('customers.billing.cadence', 'Billing cadence')}
|
||||
</label>
|
||||
<select
|
||||
@@ -713,7 +719,7 @@ export const CustomerDetailPage: React.FC = () => {
|
||||
</div>
|
||||
{(form.billingCadence === 'monthly' || form.billingCadence === 'quarterly') && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-theme mb-1">
|
||||
<label className="block text-sm font-medium text-neutral-900 dark:text-neutral-100 mb-1">
|
||||
{t('customers.billing.cycleDay', 'Cycle day')}
|
||||
</label>
|
||||
<input
|
||||
@@ -724,7 +730,7 @@ export const CustomerDetailPage: React.FC = () => {
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, billingCycleDay: Number(e.target.value) } as any))}
|
||||
className="input w-full"
|
||||
/>
|
||||
<p className="text-xs text-muted-theme mt-1">
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||
{t('customers.billing.cycleDayHint',
|
||||
'1..28 = day of month. Use negative -1..-15 for "N days before month end" (so -3 fires on the 28th of a 31-day month).')}
|
||||
</p>
|
||||
@@ -735,7 +741,7 @@ export const CustomerDetailPage: React.FC = () => {
|
||||
{/* Per-customer Skonto opt-out (migration 112). For B2B
|
||||
customers who negotiated "no early-payment discount" — set
|
||||
once instead of ticking the per-invoice toggle every time. */}
|
||||
<label className="mt-4 flex items-start gap-2 text-sm text-theme">
|
||||
<label className="mt-4 flex items-start gap-2 text-sm text-neutral-900 dark:text-neutral-100">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!form.skontoDisabled}
|
||||
@@ -744,7 +750,7 @@ export const CustomerDetailPage: React.FC = () => {
|
||||
/>
|
||||
<span>
|
||||
{t('customers.billing.skontoDisabled', 'No Skonto for this customer')}
|
||||
<span className="block text-xs text-muted-theme">
|
||||
<span className="block text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{t('customers.billing.skontoDisabledHint',
|
||||
'Disables the early-payment discount on all of this customer’s invoices, regardless of template or global defaults.')}
|
||||
</span>
|
||||
@@ -759,14 +765,14 @@ export const CustomerDetailPage: React.FC = () => {
|
||||
{(form.billingCadence === 'monthly' || form.billingCadence === 'manual') && monthlyDraft && monthlyDraft.lineItems.length > 0 && (
|
||||
<div className="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-700">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-sm font-semibold text-theme">
|
||||
<h3 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{form.billingCadence === 'manual'
|
||||
? t('customers.billing.draftPreview.titleManual',
|
||||
'Pending — ships on manual trigger')
|
||||
: t('customers.billing.draftPreview.title',
|
||||
'Pending in this month\'s bill')}
|
||||
</h3>
|
||||
<span className="text-xs text-muted-theme">
|
||||
<span className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{monthlyDraft.periodStart && monthlyDraft.periodEnd
|
||||
? t('customers.billing.draftPreview.periodRange',
|
||||
'{{number}} · {{from}} – {{to}}',
|
||||
@@ -800,10 +806,10 @@ export const CustomerDetailPage: React.FC = () => {
|
||||
<tbody>
|
||||
{monthlyDraft.lineItems.map((li) => (
|
||||
<tr key={li.id} className="border-t border-neutral-200 dark:border-neutral-700">
|
||||
<td className="px-3 py-1.5 tabular-nums text-muted-theme">{li.position}</td>
|
||||
<td className="px-3 py-1.5 tabular-nums text-neutral-500 dark:text-neutral-400">{li.position}</td>
|
||||
<td className="px-3 py-1.5">
|
||||
{li.parentPosition != null && (
|
||||
<span className="text-muted-theme mr-1">↳</span>
|
||||
<span className="text-neutral-500 dark:text-neutral-400 mr-1">↳</span>
|
||||
)}
|
||||
{li.description}
|
||||
</td>
|
||||
@@ -857,7 +863,7 @@ export const CustomerDetailPage: React.FC = () => {
|
||||
>
|
||||
{t('customers.billing.triggerNow', 'Trigger invoice now')}
|
||||
</Button>
|
||||
<p className="text-xs text-muted-theme mt-2">
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-2">
|
||||
{form.billingCadence === 'manual'
|
||||
? t('customers.billing.triggerHintManual',
|
||||
'Issues the running draft immediately. Manual-cadence drafts never ship automatically — this is the only way to send them. Refuses when nothing has been queued.')
|
||||
@@ -893,13 +899,13 @@ export const CustomerDetailPage: React.FC = () => {
|
||||
firing the standard portal-invitation email. We show ONE
|
||||
card with the right action based on the customer's state. */}
|
||||
<Card padding="lg">
|
||||
<h2 className="text-lg font-semibold text-theme mb-1 flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-1 flex items-center gap-2">
|
||||
<KeyRound className="w-5 h-5" />
|
||||
{t('customers.detail.passwordSection', 'Account actions')}
|
||||
</h2>
|
||||
{customer.isPassive ? (
|
||||
<>
|
||||
<p className="text-xs text-muted-theme mb-4">
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-4">
|
||||
{t(
|
||||
'customers.passive.detailHint',
|
||||
'This customer has no portal access (admin-only record). Click below to email them a portal sign-up link. The customer\'s existing invoices, quotes, and gallery assignments are preserved when they claim the invitation.',
|
||||
@@ -915,7 +921,7 @@ export const CustomerDetailPage: React.FC = () => {
|
||||
{t('customers.passive.sendInvite', 'Send portal invitation')}
|
||||
</Button>
|
||||
{!customer.isActive && (
|
||||
<p className="text-xs text-muted-theme mt-2">
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-2">
|
||||
{t('customers.passive.deactivatedHint',
|
||||
'Reactivate the customer before sending the invitation.')}
|
||||
</p>
|
||||
@@ -923,7 +929,7 @@ export const CustomerDetailPage: React.FC = () => {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-xs text-muted-theme mb-4">
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-4">
|
||||
{t(
|
||||
'customers.detail.passwordHint',
|
||||
'Sends a 7-day single-use reset link to the customer\'s email. The customer\'s current password keeps working until they click the link and set a new one.'
|
||||
@@ -939,7 +945,7 @@ export const CustomerDetailPage: React.FC = () => {
|
||||
{t('customers.detail.passwordReset.button', 'Send password reset email')}
|
||||
</Button>
|
||||
{!customer.isActive && (
|
||||
<p className="text-xs text-muted-theme mt-2">
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-2">
|
||||
{t('customers.detail.passwordReset.inactive', 'Reactivate the customer before sending a reset.')}
|
||||
</p>
|
||||
)}
|
||||
@@ -996,15 +1002,15 @@ export const CustomerDetailPage: React.FC = () => {
|
||||
|
||||
{confirmDeactivate && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4">
|
||||
<div className="w-full max-w-md rounded-xl shadow-lg" style={{ backgroundColor: 'var(--color-surface)' }}>
|
||||
<div className="w-full max-w-md rounded-xl shadow-lg bg-white dark:bg-neutral-900">
|
||||
<div className="p-6">
|
||||
<div className="flex items-start gap-3 mb-4">
|
||||
<AlertTriangle className="w-5 h-5 mt-0.5 text-amber-500" />
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-theme">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{t('customers.deactivate.title', 'Deactivate customer?')}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-muted-theme">
|
||||
<p className="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
|
||||
{t('customers.deactivate.body',
|
||||
'They will no longer be able to log in. You can re-activate or fully erase them later.')}
|
||||
</p>
|
||||
@@ -1033,15 +1039,15 @@ export const CustomerDetailPage: React.FC = () => {
|
||||
and audit-log references are preserved. */}
|
||||
{confirmErase && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4">
|
||||
<div className="w-full max-w-md rounded-xl shadow-lg" style={{ backgroundColor: 'var(--color-surface)' }}>
|
||||
<div className="w-full max-w-md rounded-xl shadow-lg bg-white dark:bg-neutral-900">
|
||||
<div className="p-6">
|
||||
<div className="flex items-start gap-3 mb-4">
|
||||
<AlertTriangle className="w-5 h-5 mt-0.5 text-red-600" />
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-theme">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{t('customers.erase.title', 'Erase customer data?')}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-muted-theme">
|
||||
<p className="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
|
||||
{t('customers.erase.body',
|
||||
'Removes the customer\'s name, email, phone, address, company and credentials. The account row stays so historical event-access records and audit logs still reference it. This is irreversible — you cannot restore the data afterwards.')}
|
||||
</p>
|
||||
|
||||
@@ -118,16 +118,16 @@ export const CustomerManagementPage: React.FC = () => {
|
||||
const display = c.displayName?.trim()
|
||||
|| [c.firstName, c.lastName].filter(Boolean).join(' ').trim()
|
||||
|| c.companyName?.trim();
|
||||
return display || <span className="text-muted-theme italic">{t('customers.unnamed', 'Unnamed')}</span>;
|
||||
return display || <span className="text-neutral-500 dark:text-neutral-400 italic">{t('customers.unnamed', 'Unnamed')}</span>;
|
||||
};
|
||||
|
||||
const renderTabs = () => (
|
||||
<div className="flex gap-6 border-b mb-6" style={{ borderColor: 'var(--color-surface-border)' }}>
|
||||
<div className="flex gap-6 border-b border-neutral-200 dark:border-neutral-700 mb-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('customers')}
|
||||
className={`pb-3 -mb-px border-b-2 text-sm font-medium ${
|
||||
activeTab === 'customers' ? 'border-accent text-accent' : 'border-transparent text-muted-theme hover:text-theme'
|
||||
activeTab === 'customers' ? 'border-accent text-accent' : 'border-transparent text-neutral-500 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-neutral-100'
|
||||
}`}
|
||||
>
|
||||
{t('customers.tabs.customers', 'Customers')}
|
||||
@@ -137,7 +137,7 @@ export const CustomerManagementPage: React.FC = () => {
|
||||
type="button"
|
||||
onClick={() => setActiveTab('invitations')}
|
||||
className={`pb-3 -mb-px border-b-2 text-sm font-medium ${
|
||||
activeTab === 'invitations' ? 'border-accent text-accent' : 'border-transparent text-muted-theme hover:text-theme'
|
||||
activeTab === 'invitations' ? 'border-accent text-accent' : 'border-transparent text-neutral-500 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-neutral-100'
|
||||
}`}
|
||||
>
|
||||
{t('customers.tabs.invitations', 'Invitations')}
|
||||
@@ -151,7 +151,7 @@ export const CustomerManagementPage: React.FC = () => {
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-2xl font-bold text-theme">{t('customers.pageTitle', 'Customers')}</h1>
|
||||
<h1 className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">{t('customers.pageTitle', 'Customers')}</h1>
|
||||
{/* Beta badge — Calendar/Quotes/Bills tabs in the customer
|
||||
surface are placeholders, so flag the whole feature as
|
||||
still evolving. Keeps expectations honest. */}
|
||||
@@ -162,7 +162,7 @@ export const CustomerManagementPage: React.FC = () => {
|
||||
{t('navigation.betaTag', 'Beta')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-theme mt-1">
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-1">
|
||||
{t('customers.pageSubtitle', 'Recurring customer accounts that can log in at /customer/login.')}
|
||||
</p>
|
||||
</div>
|
||||
@@ -197,14 +197,14 @@ export const CustomerManagementPage: React.FC = () => {
|
||||
{t('customers.loadError', 'Could not load customers')}
|
||||
</div>
|
||||
) : filteredCustomers.length === 0 ? (
|
||||
<div className="text-center text-muted-theme py-12">
|
||||
<div className="text-center text-neutral-500 dark:text-neutral-400 py-12">
|
||||
{t('customers.empty', 'No customers yet. Click "Invite customer" to add one.')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-muted-theme">
|
||||
<tr className="text-left text-neutral-500 dark:text-neutral-400">
|
||||
<th className="px-3 py-2 font-medium">{t('customers.table.name', 'Name')}</th>
|
||||
<th className="px-3 py-2 font-medium">{t('customers.table.email', 'Email')}</th>
|
||||
<th className="px-3 py-2 font-medium">{t('customers.table.company', 'Company')}</th>
|
||||
@@ -216,16 +216,16 @@ export const CustomerManagementPage: React.FC = () => {
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredCustomers.map((c) => (
|
||||
<tr key={c.id} className="border-t" style={{ borderColor: 'var(--color-surface-border)' }}>
|
||||
<tr key={c.id} className="border-t border-neutral-200 dark:border-neutral-700">
|
||||
<td className="px-3 py-3">
|
||||
<Link to={`/admin/clients/accounts/${c.id}`} className="text-theme hover:underline">
|
||||
<Link to={`/admin/clients/accounts/${c.id}`} className="text-neutral-900 dark:text-neutral-100 hover:underline">
|
||||
{renderCustomerName(c)}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-3 py-3 text-muted-theme">{c.email}</td>
|
||||
<td className="px-3 py-3 text-muted-theme">{c.companyName || '—'}</td>
|
||||
<td className="px-3 py-3 text-muted-theme">{c.eventCount ?? 0}</td>
|
||||
<td className="px-3 py-3 text-muted-theme">{formatDate(c.lastLogin)}</td>
|
||||
<td className="px-3 py-3 text-neutral-500 dark:text-neutral-400">{c.email}</td>
|
||||
<td className="px-3 py-3 text-neutral-500 dark:text-neutral-400">{c.companyName || '—'}</td>
|
||||
<td className="px-3 py-3 text-neutral-500 dark:text-neutral-400">{c.eventCount ?? 0}</td>
|
||||
<td className="px-3 py-3 text-neutral-500 dark:text-neutral-400">{formatDate(c.lastLogin)}</td>
|
||||
<td className="px-3 py-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
{c.isActive ? (
|
||||
@@ -278,14 +278,14 @@ export const CustomerManagementPage: React.FC = () => {
|
||||
{t('customers.loadInvitationsError', 'Could not load invitations')}
|
||||
</div>
|
||||
) : filteredInvitations.length === 0 ? (
|
||||
<div className="text-center text-muted-theme py-12">
|
||||
<div className="text-center text-neutral-500 dark:text-neutral-400 py-12">
|
||||
{t('customers.invitations.empty', 'No pending invitations.')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-muted-theme">
|
||||
<tr className="text-left text-neutral-500 dark:text-neutral-400">
|
||||
<th className="px-3 py-2 font-medium">{t('customers.invitations.email', 'Email')}</th>
|
||||
<th className="px-3 py-2 font-medium">{t('customers.invitations.invitedBy', 'Invited by')}</th>
|
||||
<th className="px-3 py-2 font-medium">{t('customers.invitations.expiresAt', 'Expires')}</th>
|
||||
@@ -295,16 +295,16 @@ export const CustomerManagementPage: React.FC = () => {
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredInvitations.map((inv: CustomerInvitationSummary) => (
|
||||
<tr key={inv.id} className="border-t" style={{ borderColor: 'var(--color-surface-border)' }}>
|
||||
<td className="px-3 py-3 text-theme">{inv.email}</td>
|
||||
<td className="px-3 py-3 text-muted-theme">{inv.invitedBy || '—'}</td>
|
||||
<td className="px-3 py-3 text-muted-theme">
|
||||
<tr key={inv.id} className="border-t border-neutral-200 dark:border-neutral-700">
|
||||
<td className="px-3 py-3 text-neutral-900 dark:text-neutral-100">{inv.email}</td>
|
||||
<td className="px-3 py-3 text-neutral-500 dark:text-neutral-400">{inv.invitedBy || '—'}</td>
|
||||
<td className="px-3 py-3 text-neutral-500 dark:text-neutral-400">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
{formatDate(inv.expiresAt)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-3 text-muted-theme">{formatDate(inv.createdAt)}</td>
|
||||
<td className="px-3 py-3 text-neutral-500 dark:text-neutral-400">{formatDate(inv.createdAt)}</td>
|
||||
<td className="px-3 py-3 text-right">
|
||||
<Button
|
||||
type="button"
|
||||
@@ -338,8 +338,7 @@ export const CustomerManagementPage: React.FC = () => {
|
||||
onClick={() => setCreateMode(null)}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-2xl rounded-xl shadow-lg max-h-[90vh] overflow-y-auto"
|
||||
style={{ backgroundColor: 'var(--color-surface)' }}
|
||||
className="w-full max-w-2xl rounded-xl shadow-lg max-h-[90vh] overflow-y-auto bg-white dark:bg-neutral-900"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="p-6">
|
||||
@@ -359,17 +358,17 @@ export const CustomerManagementPage: React.FC = () => {
|
||||
|
||||
{confirm && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4">
|
||||
<div className="w-full max-w-md rounded-xl shadow-lg" style={{ backgroundColor: 'var(--color-surface)' }}>
|
||||
<div className="w-full max-w-md rounded-xl shadow-lg bg-white dark:bg-neutral-900">
|
||||
<div className="p-6">
|
||||
<div className="flex items-start gap-3 mb-4">
|
||||
<AlertTriangle className="w-5 h-5 mt-0.5 text-amber-500" />
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-theme">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{confirm.kind === 'deactivate'
|
||||
? t('customers.deactivate.title', 'Deactivate customer?')
|
||||
: t('customers.cancelInvitation.title', 'Cancel invitation?')}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-muted-theme">
|
||||
<p className="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
|
||||
{confirm.kind === 'deactivate'
|
||||
? t('customers.deactivate.body',
|
||||
'They will no longer be able to log in. You can re-invite them later.')
|
||||
|
||||
@@ -19,6 +19,8 @@ import { Button, Input, Card, Loading } from '../../components/common';
|
||||
import { EmailPreviewModal } from '../../components/admin/EmailPreviewModal';
|
||||
import { EmailTemplateEditor } from '../../components/admin/EmailTemplateEditor';
|
||||
import { SentEmailsPanel } from '../../components/admin/SentEmailsPanel';
|
||||
import { ReceivedEmailsPanel } from '../../components/admin/ReceivedEmailsPanel';
|
||||
import { IncomingMailConfigCard } from '../../components/admin/IncomingMailConfigCard';
|
||||
import { Palette, RefreshCw, Info } from 'lucide-react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { emailService, type EmailConfig, type EmailTemplate, type EmailTemplateTranslation } from '../../services/email.service';
|
||||
@@ -130,7 +132,7 @@ The Photo Sharing Team`,
|
||||
|
||||
export const EmailConfigPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState<'smtp' | 'templates' | 'sent'>('smtp');
|
||||
const [activeTab, setActiveTab] = useState<'smtp' | 'templates' | 'sent' | 'received'>('smtp');
|
||||
const [selectedTemplateKey, setSelectedTemplateKey] = useState<string>('gallery_created');
|
||||
const [editedTemplate, setEditedTemplate] = useState<Partial<EmailTemplate>>({});
|
||||
const [editingLang, setEditingLang] = useState<string>('en');
|
||||
@@ -496,12 +498,27 @@ export const EmailConfigPage: React.FC = () => {
|
||||
>
|
||||
{t('email.sentEmails.tab', 'Sent emails')}
|
||||
</button>
|
||||
{featureFlags.incomingMail && (
|
||||
<button
|
||||
onClick={() => setActiveTab('received')}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-sm transition-colors ${
|
||||
activeTab === 'received'
|
||||
? 'border-accent text-accent'
|
||||
: 'border-transparent text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300'
|
||||
}`}
|
||||
>
|
||||
{t('email.received.tab', 'Received emails')}
|
||||
</button>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Sent emails Tab */}
|
||||
{activeTab === 'sent' && <SentEmailsPanel />}
|
||||
|
||||
{/* Received emails Tab */}
|
||||
{activeTab === 'received' && <ReceivedEmailsPanel />}
|
||||
|
||||
{/* SMTP Settings Tab */}
|
||||
{activeTab === 'smtp' && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
@@ -800,6 +817,9 @@ export const EmailConfigPage: React.FC = () => {
|
||||
)}
|
||||
|
||||
{/* Email Templates Tab */}
|
||||
{/* Incoming mail (IMAP) — a second block under SMTP, flag-gated. */}
|
||||
{activeTab === 'smtp' && featureFlags.incomingMail && <IncomingMailConfigCard />}
|
||||
|
||||
{activeTab === 'templates' && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<Card padding="sm">
|
||||
|
||||
@@ -2265,6 +2265,7 @@ export const EventDetailsPage: React.FC = () => {
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('branding.themeAndStyle')}</h2>
|
||||
<ThemeCustomizerEnhanced
|
||||
value={currentTheme || GALLERY_THEME_PRESETS.default.config}
|
||||
forceColorMode={publicSettings?.branding_force_color_mode ?? null}
|
||||
onChange={(theme) => {
|
||||
setCurrentTheme(theme);
|
||||
setEditForm(prev => ({ ...prev, color_theme: JSON.stringify(theme) }));
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
ThumbnailsTab,
|
||||
ApiTokensTab,
|
||||
WebhooksTab,
|
||||
AccountingTab,
|
||||
} from '../../features/settings';
|
||||
import { EmailConfigPage } from './EmailConfigPage';
|
||||
import { BrandingPage } from './BrandingPage';
|
||||
@@ -52,7 +53,7 @@ import { CrmSettingsPage } from './settings/CrmSettingsPage';
|
||||
import { ReminderTemplatesPage } from './settings/ReminderTemplatesPage';
|
||||
import { BlockLibraryPage } from './contracts/BlockLibraryPage';
|
||||
import { useFeatureFlags } from '../../contexts/FeatureFlagsContext';
|
||||
import { Briefcase, Receipt, ScrollText, Mail } from 'lucide-react';
|
||||
import { Briefcase, Receipt, ScrollText, Mail, Landmark } from 'lucide-react';
|
||||
|
||||
// Tab keys driving the inner-nav. Must include every key used in
|
||||
// `navGroups` below and in the switch at the bottom of the component.
|
||||
@@ -81,7 +82,8 @@ type TabType =
|
||||
| 'businessProfile'
|
||||
| 'crm'
|
||||
| 'contracts'
|
||||
| 'reminderTemplates';
|
||||
| 'reminderTemplates'
|
||||
| 'accounting';
|
||||
|
||||
interface NavItem {
|
||||
key: TabType;
|
||||
@@ -101,7 +103,7 @@ const ALL_TAB_KEYS: TabType[] = [
|
||||
'security', 'imageSecurity', 'seo',
|
||||
'apiTokens', 'webhooks',
|
||||
'status', 'analytics', 'backup',
|
||||
'businessProfile', 'crm', 'contracts', 'reminderTemplates',
|
||||
'businessProfile', 'crm', 'contracts', 'reminderTemplates', 'accounting',
|
||||
];
|
||||
|
||||
function isValidTab(value: string | null): value is TabType {
|
||||
@@ -251,6 +253,9 @@ export const SettingsPage: React.FC = () => {
|
||||
...(flags.reminderEmails
|
||||
? [{ key: 'reminderTemplates' as const, label: t('settings.reminderTemplates.title', 'Reminder emails'), icon: Mail }]
|
||||
: []),
|
||||
...(flags.accounting
|
||||
? [{ key: 'accounting' as const, label: t('settings.accounting.title', 'Accounting'), icon: Landmark }]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -276,7 +281,7 @@ export const SettingsPage: React.FC = () => {
|
||||
setActiveTab(visibleKeys[0]);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [flags.quotes, flags.bills, flags.contracts, flags.reminderEmails, activeTab]);
|
||||
}, [flags.quotes, flags.bills, flags.contracts, flags.reminderEmails, flags.accounting, activeTab]);
|
||||
|
||||
// For tabs that mount existing top-level pages OR bring their own
|
||||
// header (FeaturesTab has its own icon+title+description block), skip
|
||||
@@ -414,6 +419,7 @@ export const SettingsPage: React.FC = () => {
|
||||
{activeTab === 'crm' && <CrmSettingsPage />}
|
||||
{activeTab === 'contracts' && <BlockLibraryPage />}
|
||||
{activeTab === 'reminderTemplates' && <ReminderTemplatesPage />}
|
||||
{activeTab === 'accounting' && <AccountingTab />}
|
||||
|
||||
{activeTab === 'status' && (
|
||||
<StatusTab
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
/**
|
||||
* Accounting → Incoming invoices (external supplier invoices).
|
||||
*
|
||||
* Capture (camera/upload) → triage (confirm fields + disposition + booking) →
|
||||
* the supplier invoice is the payable: mark it PAID here, or re-bill it to a
|
||||
* client. PDFs are previewed as server-rasterised page images (never raw).
|
||||
*/
|
||||
import React, { useRef, useState, useEffect } from 'react';
|
||||
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 { 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 { useLocalizedDate } from '../../../hooks/useLocalizedDate';
|
||||
import {
|
||||
accountingService, categoryLabel,
|
||||
type InboundDocument, type Disposition, type MarkupType, type PaymentMethod, type ExpenseCategory,
|
||||
} from '../../../services/accounting.service';
|
||||
|
||||
const DISPOSITIONS: Disposition[] = ['rebill', 'durchlaufend', 'eigener_aufwand', 'duplikat', 'abgelehnt'];
|
||||
const PAYMENT_METHODS: PaymentMethod[] = ['bank_transfer', 'cash', 'twint', 'paypal', 'card', 'other'];
|
||||
// Only client-facing dispositions book to an event. A "Company expense"
|
||||
// (eigener_aufwand) always books to the company — no event picker.
|
||||
const BOOKING_DISPOSITIONS: Disposition[] = ['rebill', 'durchlaufend'];
|
||||
|
||||
const statusClasses: Record<string, string> = {
|
||||
unsorted: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300',
|
||||
categorized: 'bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-300',
|
||||
declined: 'bg-neutral-200 text-neutral-700 dark:bg-neutral-700 dark:text-neutral-300',
|
||||
duplicate: 'bg-neutral-200 text-neutral-700 dark:bg-neutral-700 dark:text-neutral-300',
|
||||
};
|
||||
|
||||
const labelCls = 'block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1';
|
||||
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';
|
||||
|
||||
/**
|
||||
* Reusable rasterised-document preview. PDFs render as server-side page
|
||||
* images (never raw); images stream inline. Defaults to the LAST page
|
||||
* (Swiss QR-bill usually sits at the bottom of the final page). Used by
|
||||
* the triage, view, and mark-paid modals so the admin can always re-read
|
||||
* the slip — #1.
|
||||
*/
|
||||
const DocumentPreview: React.FC<{ doc: InboundDocument; maxHeight?: string; initialPage?: 'first' | 'last' }> = ({ doc, maxHeight = '60vh', initialPage = 'first' }) => {
|
||||
const { t } = useTranslation();
|
||||
const isPdf = (doc.mimeType || '').includes('pdf');
|
||||
const pageCount = doc.pageCount || 1;
|
||||
// Categorisation reads the invoice header (supplier/amount/date) → first page;
|
||||
// payment needs the Swiss QR-bill → last page.
|
||||
const [page, setPage] = useState(initialPage === 'last' ? pageCount : 1);
|
||||
const [imgUrl, setImgUrl] = useState<string | null>(null);
|
||||
const [previewError, setPreviewError] = useState(false);
|
||||
useEffect(() => {
|
||||
let url: string | null = null; let cancelled = false;
|
||||
setImgUrl(null); setPreviewError(false);
|
||||
(isPdf ? accountingService.getInboundPageBlob(doc.id, page) : accountingService.getInboundFileBlob(doc.id))
|
||||
.then((b) => { if (!cancelled) { url = URL.createObjectURL(b); setImgUrl(url); } })
|
||||
.catch(() => { if (!cancelled) setPreviewError(true); });
|
||||
return () => { cancelled = true; if (url) URL.revokeObjectURL(url); };
|
||||
}, [doc.id, isPdf, page]);
|
||||
return (
|
||||
<div>
|
||||
<div className="overflow-auto rounded-md border border-neutral-200 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-800" style={{ maxHeight }}>
|
||||
{previewError ? <div className="flex items-center justify-center px-3 py-16 text-center text-sm text-neutral-500">{t('accounting.inbox.previewError', 'Preview unavailable — enter the fields manually.')}</div>
|
||||
: imgUrl ? <img src={imgUrl} alt="document page" className="w-full h-auto" />
|
||||
: <div className="flex items-center justify-center px-3 py-16 text-sm text-neutral-500">{t('accounting.inbox.previewLoading', 'Loading preview…')}</div>}
|
||||
</div>
|
||||
{/* Always show the pager for PDFs (disabled at the ends) so the control
|
||||
is consistent even on single-page invoices. */}
|
||||
{isPdf && (
|
||||
<div className="mt-2 flex items-center justify-center gap-3 text-sm">
|
||||
<Button size="sm" variant="outline" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page <= 1}>{t('accounting.inbox.prevPage', 'Prev')}</Button>
|
||||
<span className="text-neutral-600 dark:text-neutral-400">{t('accounting.inbox.pageOf', 'Page {{n}} / {{total}}', { n: page, total: pageCount })}</span>
|
||||
<Button size="sm" variant="outline" onClick={() => setPage((p) => Math.min(pageCount, p + 1))} disabled={page >= pageCount}>{t('accounting.inbox.nextPage', 'Next')}</Button>
|
||||
</div>
|
||||
)}
|
||||
{isPdf && initialPage === 'last' && <p className="mt-1 text-center text-xs text-neutral-500 dark:text-neutral-400">{t('accounting.inbox.qrHint', 'Showing the last page — the Swiss QR-bill usually sits at the bottom.')}</p>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PayModal: React.FC<{ doc: InboundDocument; onClose: () => void; onDone: () => void }> = ({ doc, onClose, onDone }) => {
|
||||
const { t } = useTranslation();
|
||||
const [paidAt, setPaidAt] = useState('');
|
||||
const [method, setMethod] = useState<PaymentMethod>('bank_transfer');
|
||||
const [reference, setReference] = useState(doc.paymentReference || '');
|
||||
const save = useMutation({
|
||||
mutationFn: () => accountingService.markInboundPaid(doc.id, { paid: true, paidAt: paidAt || undefined, paymentMethod: method, paymentReference: reference || undefined }),
|
||||
onSuccess: () => { toast.success(t('accounting.incoming.paidToast', 'Marked as paid.')); onDone(); },
|
||||
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'),
|
||||
});
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/50 p-4">
|
||||
{/* Wider, two-column: preview on the left so the admin can read the
|
||||
QR-bill while confirming payment — #1. */}
|
||||
<div className="mt-12 w-full max-w-3xl rounded-xl bg-white dark:bg-neutral-900 shadow-xl">
|
||||
<div className="flex items-center justify-between border-b border-neutral-200 dark:border-neutral-700 px-5 py-3">
|
||||
<h2 className="text-base font-semibold text-neutral-900 dark:text-neutral-100">{t('accounting.incoming.payTitle', 'Mark supplier paid')}</h2>
|
||||
<button onClick={onClose} className="text-neutral-400 hover:text-neutral-600"><X className="w-5 h-5" /></button>
|
||||
</div>
|
||||
<div className="px-5 py-4 grid grid-cols-1 lg:grid-cols-2 gap-5">
|
||||
<div className="order-2 lg:order-1"><DocumentPreview doc={doc} maxHeight="50vh" initialPage="last" /></div>
|
||||
<div className="order-1 lg:order-2 space-y-3">
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
{t('accounting.incoming.outstanding', 'Outstanding')}: <span className="font-semibold text-neutral-900 dark:text-neutral-100">{doc.totalAmountMinor != null ? formatMoneyMinor(doc.totalAmountMinor, doc.currency || 'CHF') : '—'}</span>
|
||||
</p>
|
||||
<div><label className={labelCls}>{t('accounting.ledger.paidDate', 'Payment date')}</label><LocalizedDateInput value={paidAt} onChange={setPaidAt} /></div>
|
||||
<div><label className={labelCls}>{t('accounting.ledger.method', 'Method')}</label>
|
||||
<select value={method} onChange={(e) => setMethod(e.target.value as PaymentMethod)} className={selectCls}>
|
||||
{PAYMENT_METHODS.map((m) => <option key={m} value={m}>{t(`accounting.paymentMethod.${m}`, m)}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div><label className={labelCls}>{t('accounting.ledger.reference', 'Reference (optional)')}</label><Input value={reference} onChange={(e) => setReference(e.target.value)} /></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 border-t border-neutral-200 dark:border-neutral-700 px-5 py-3">
|
||||
<Button variant="outline" onClick={onClose}>{t('common.cancel', 'Cancel')}</Button>
|
||||
<Button onClick={() => save.mutate()} disabled={save.isPending}>{save.isPending ? t('common.saving', 'Saving…') : t('accounting.incoming.confirmPaid', 'Mark paid')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** Read-only re-view of a categorized/declined incoming invoice (#1):
|
||||
* preview + the confirmed fields, without the triage form. */
|
||||
const ViewModal: React.FC<{ doc: InboundDocument; onClose: () => void }> = ({ doc, onClose }) => {
|
||||
const { t } = useTranslation();
|
||||
const { format } = useLocalizedDate();
|
||||
const field = (label: string, value: React.ReactNode) => (
|
||||
<div className="flex justify-between gap-3 py-1 text-sm border-b border-neutral-100 dark:border-neutral-800">
|
||||
<span className="text-neutral-500 dark:text-neutral-400">{label}</span>
|
||||
<span className="text-neutral-900 dark:text-neutral-100 text-right">{value ?? '—'}</span>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/50 p-4">
|
||||
<div className="mt-10 w-full max-w-4xl rounded-xl bg-white dark:bg-neutral-900 shadow-xl">
|
||||
<div className="flex items-center justify-between border-b border-neutral-200 dark:border-neutral-700 px-5 py-3">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">{doc.supplierName || doc.originalFilename || t('accounting.inbox.untitled', 'Untitled document')}</h2>
|
||||
<button onClick={onClose} className="text-neutral-400 hover:text-neutral-600"><X className="w-5 h-5" /></button>
|
||||
</div>
|
||||
<div className="px-5 py-4 grid grid-cols-1 lg:grid-cols-2 gap-5">
|
||||
<div className="order-2 lg:order-1"><DocumentPreview doc={doc} /></div>
|
||||
<div className="order-1 lg:order-2">
|
||||
{field(t('accounting.inbox.field.supplier', 'Supplier'), doc.supplierName)}
|
||||
{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))}
|
||||
{field(t('accounting.incoming.paid', 'Paid'), doc.supplierPaid
|
||||
? (doc.supplierPaidAt ? format(doc.supplierPaidAt) : t('common.yes', 'Yes'))
|
||||
: t('common.no', 'No'))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 border-t border-neutral-200 dark:border-neutral-700 px-5 py-3">
|
||||
<Button variant="outline" onClick={onClose}>{t('common.close', 'Close')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TriageModal: React.FC<{ doc: InboundDocument; categories: ExpenseCategory[]; onClose: () => void; onDone: () => void }> = ({ doc, categories, onClose, onDone }) => {
|
||||
const { t } = 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 totalMinor = Number.isFinite(amountMajor) ? Math.round(amountMajor * 100) : null;
|
||||
|
||||
const markupPayload = () => ({
|
||||
markupType,
|
||||
markupPercent: markupType === 'percent' && Number.isFinite(markupValue) ? markupValue : null,
|
||||
markupFlatMinor: markupType === 'flat' && Number.isFinite(markupValue) ? Math.round(markupValue * 100) : null,
|
||||
});
|
||||
|
||||
// `pay` decides whether to also mark the supplier invoice paid in the same
|
||||
// step (#5/#1). When true we mark it paid directly (using the reference
|
||||
// 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.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(),
|
||||
});
|
||||
if (pay) {
|
||||
await accountingService.markInboundPaid(doc.id, { paid: true, paymentReference: reference || undefined });
|
||||
}
|
||||
},
|
||||
onSuccess: (_data, pay) => {
|
||||
toast.success(pay ? t('accounting.incoming.categorizedPaidToast', 'Categorized and marked paid.') : t('accounting.incoming.categorizedToast', 'Categorized.'));
|
||||
onDone();
|
||||
},
|
||||
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'),
|
||||
});
|
||||
|
||||
const rebillNeedsCustomer = disposition === 'rebill' && !customer[0];
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/50 p-4">
|
||||
<div className="mt-10 w-full max-w-4xl rounded-xl bg-white dark:bg-neutral-900 shadow-xl">
|
||||
<div className="flex items-center justify-between border-b border-neutral-200 dark:border-neutral-700 px-5 py-3">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">{t('accounting.incoming.triageTitle', 'Categorize incoming invoice')}</h2>
|
||||
<button onClick={onClose} className="text-neutral-400 hover:text-neutral-600"><X className="w-5 h-5" /></button>
|
||||
</div>
|
||||
<div className="px-5 py-4 grid grid-cols-1 lg:grid-cols-2 gap-5">
|
||||
<div className="order-2 lg:order-1"><DocumentPreview doc={doc} /></div>
|
||||
|
||||
<div className="order-1 lg:order-2 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<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.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>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{BOOKING_DISPOSITIONS.includes(disposition) && (
|
||||
<div>
|
||||
<label className={labelCls}>{t('accounting.booking.label', 'Book to')}</label>
|
||||
<EventBookingSelect value={eventId} onChange={setEventId} className={selectCls} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{disposition === 'eigener_aufwand' && (
|
||||
<div><label className={labelCls}>{t('accounting.inbox.field.category', 'Category')}</label>
|
||||
<select value={categoryId ?? ''} onChange={(e) => setCategoryId(e.target.value ? Number(e.target.value) : undefined)} className={selectCls}>
|
||||
<option value="">{t('accounting.inbox.field.categoryNone', '— none —')}</option>
|
||||
{categories.map((c) => <option key={c.id} value={c.id}>{categoryLabel(c, t)}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{disposition === 'rebill' && (
|
||||
<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>
|
||||
{markupType !== 'none' && <DecimalInput value={markupValue} onChange={setMarkupValue} fractionDigits={2} className={selectCls} placeholder={markupType === 'percent' ? '%' : currency} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-end gap-2 border-t border-neutral-200 dark:border-neutral-700 px-5 py-3">
|
||||
<Button variant="outline" onClick={onClose}>{t('common.cancel', 'Cancel')}</Button>
|
||||
{/* #5: categorize-only OR categorize then continue to mark paid. */}
|
||||
<Button variant="outline" onClick={() => save.mutate(true)} disabled={save.isPending || rebillNeedsCustomer}>{t('accounting.inbox.saveCategorizePay', 'Save & mark paid')}</Button>
|
||||
<Button onClick={() => save.mutate(false)} disabled={save.isPending || rebillNeedsCustomer}>{save.isPending ? t('common.saving', 'Saving…') : t('accounting.inbox.saveCategorize', 'Save')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const AccountingInboxPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const { format } = useLocalizedDate();
|
||||
const cameraRef = useRef<HTMLInputElement>(null);
|
||||
const uploadRef = useRef<HTMLInputElement>(null);
|
||||
const [triageDoc, setTriageDoc] = useState<InboundDocument | null>(null);
|
||||
const [payDoc, setPayDoc] = useState<InboundDocument | null>(null);
|
||||
const [viewDoc, setViewDoc] = useState<InboundDocument | null>(null);
|
||||
|
||||
// Auto-refresh so emails the IMAP poller ingests in the background appear
|
||||
// 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() });
|
||||
|
||||
const upload = useMutation({
|
||||
mutationFn: ({ file, source }: { file: File; source: 'upload' | 'camera' }) => accountingService.uploadInbound(file, source),
|
||||
onSuccess: (doc) => { toast.success(t('accounting.inbox.capturedToast', 'Document captured.')); qc.invalidateQueries({ queryKey: ['accounting-inbound'] }); if (doc.status === 'unsorted') setTriageDoc(doc); },
|
||||
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Upload failed'),
|
||||
});
|
||||
const unpay = useMutation({
|
||||
mutationFn: (id: number) => accountingService.markInboundPaid(id, { paid: false }),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['accounting-inbound'] }); },
|
||||
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 handleTriageDone = () => { setTriageDoc(null); refresh(); };
|
||||
|
||||
const items = data?.items ?? [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input ref={cameraRef} type="file" accept="image/*" capture="environment" className="hidden" onChange={onFile('camera')} />
|
||||
<input ref={uploadRef} type="file" accept="image/*,application/pdf" className="hidden" onChange={onFile('upload')} />
|
||||
|
||||
<Card className="mb-6"><CardContent className="flex flex-col sm:flex-row items-center gap-3 p-5">
|
||||
<div className="flex-1">
|
||||
<h2 className="text-base font-semibold text-neutral-900 dark:text-neutral-100">{t('accounting.inbox.captureTitle', 'Capture a supplier invoice')}</h2>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400">{t('accounting.inbox.captureBody', 'Photograph a paper invoice with your device camera, or upload a PDF / image.')}</p>
|
||||
</div>
|
||||
<Button onClick={() => cameraRef.current?.click()} disabled={upload.isPending}><Camera className="w-4 h-4 mr-2" /> {t('accounting.inbox.scanCamera', 'Scan with camera')}</Button>
|
||||
<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>
|
||||
|
||||
{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" />
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400">{t('accounting.inbox.empty', 'No documents yet — capture one above.')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{items.map((doc) => (
|
||||
<div key={doc.id} className="flex flex-wrap items-center gap-3 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-4 py-3">
|
||||
{/* Click routes by state: new → categorize, categorized & unpaid
|
||||
→ mark paid, everything else → view. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (doc.status === 'unsorted') setTriageDoc(doc);
|
||||
else if (doc.status === 'categorized' && !doc.supplierPaid) setPayDoc(doc);
|
||||
else setViewDoc(doc);
|
||||
}}
|
||||
className="flex-1 min-w-[12rem] text-left"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* #1: once paid, the front status reads "Paid" — not the
|
||||
stale "categorized". */}
|
||||
{doc.supplierPaid
|
||||
? <span className="inline-block rounded px-2 py-0.5 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300">{t('accounting.incoming.paid', 'Paid')}</span>
|
||||
: <span className={`inline-block rounded px-2 py-0.5 text-xs font-medium ${statusClasses[doc.status] || ''}`}>{t(`accounting.inbox.status.${doc.status}`, doc.status)}</span>}
|
||||
<span className="text-sm font-medium text-neutral-900 dark:text-neutral-100 truncate hover:underline">{doc.supplierName || doc.originalFilename || t('accounting.inbox.untitled', 'Untitled document')}</span>
|
||||
{doc.source === 'camera' && <Camera className="w-3.5 h-3.5 text-neutral-400" />}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{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)}</>}
|
||||
</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>
|
||||
{doc.status !== 'declined' && doc.status !== 'duplicate' && (
|
||||
doc.supplierPaid
|
||||
// Paid is shown by the front badge — here we only offer the
|
||||
// revert action, so there aren't two "Paid" chips.
|
||||
? <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>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{triageDoc && <TriageModal doc={triageDoc} categories={categories ?? []} onClose={() => setTriageDoc(null)} onDone={handleTriageDone} />}
|
||||
{payDoc && <PayModal doc={payDoc} onClose={() => setPayDoc(null)} onDone={() => { setPayDoc(null); refresh(); }} />}
|
||||
{viewDoc && <ViewModal doc={viewDoc} onClose={() => setViewDoc(null)} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AccountingInboxPage;
|
||||
@@ -0,0 +1,329 @@
|
||||
/**
|
||||
* Accounting → Expenses (internal own costs).
|
||||
*
|
||||
* Mileage (km) / per-diem (days) / plain amount, booked to an event or the
|
||||
* company, with an optional proof file (required when the accounting setting
|
||||
* says so). Rates default from the Accounting settings tab, overridable per
|
||||
* entry. No supplier payment here — that lives on incoming invoices.
|
||||
*/
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'react-toastify';
|
||||
import { X, Plus, Paperclip, Car, CalendarDays, Coins, Pencil, FileText, CheckCircle2, Circle, Lock } from 'lucide-react';
|
||||
import { Button, Card, CardContent, Input, LocalizedDateInput, Loading } from '../../../components/common';
|
||||
import { DecimalInput } from '../../../components/common/DecimalInput';
|
||||
import { EventBookingSelect } from '../../../components/admin/EventBookingSelect';
|
||||
import { CustomerAccountPicker, type SelectedCustomer } from '../../../components/admin/CustomerAccountPicker';
|
||||
import { formatMoneyMinor } from '../../../utils/money';
|
||||
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
|
||||
import {
|
||||
accountingService, categoryLabel,
|
||||
type Expense, type ExpenseKind, type ExpenseCategory, type MarkupType, type PaymentMethod,
|
||||
} from '../../../services/accounting.service';
|
||||
|
||||
const PAYMENT_METHODS: PaymentMethod[] = ['bank_transfer', 'cash', 'twint', 'paypal', 'card', 'other'];
|
||||
|
||||
const KINDS: ExpenseKind[] = ['amount', 'mileage', 'per_diem'];
|
||||
const labelCls = 'block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1';
|
||||
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';
|
||||
const kindIcon: Record<ExpenseKind, React.ReactNode> = {
|
||||
amount: <Coins className="w-3.5 h-3.5" />, mileage: <Car className="w-3.5 h-3.5" />, per_diem: <CalendarDays className="w-3.5 h-3.5" />,
|
||||
};
|
||||
|
||||
const ExpenseFormModal: React.FC<{ categories: ExpenseCategory[]; expense?: Expense; onClose: () => void; onDone: () => void }> = ({ categories, expense, onClose, onDone }) => {
|
||||
const { t } = useTranslation();
|
||||
const isEdit = !!expense;
|
||||
const { data: settings } = useQuery({ queryKey: ['accounting-settings'], queryFn: () => accountingService.getSettings() });
|
||||
const [kind, setKind] = useState<ExpenseKind>(expense?.kind ?? 'amount');
|
||||
const [supplier, setSupplier] = useState(expense?.supplierName ?? '');
|
||||
const [description, setDescription] = useState(expense?.description ?? '');
|
||||
const [amountMajor, setAmountMajor] = useState<number>(expense && expense.kind === 'amount' && expense.chfAmountMinor != null ? expense.chfAmountMinor / 100 : NaN);
|
||||
const [quantity, setQuantity] = useState<number>(expense?.quantity != null ? expense.quantity : NaN);
|
||||
const [rateMajor, setRateMajor] = useState<number>(expense?.rateMinor != null ? expense.rateMinor / 100 : NaN); // per-entry override (major units)
|
||||
const [categoryId, setCategoryId] = useState<number | undefined>(expense?.categoryId ?? undefined);
|
||||
const [eventId, setEventId] = useState<number | null>(expense?.eventId ?? null);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
|
||||
const defaultRateMinor = kind === 'mileage' ? (settings?.accounting_km_rate_minor ?? 0)
|
||||
: kind === 'per_diem' ? (settings?.accounting_per_diem_rate_minor ?? 0) : 0;
|
||||
const effRateMinor = Number.isFinite(rateMajor) ? Math.round(rateMajor * 100) : defaultRateMinor;
|
||||
const computedMinor = useMemo(() => {
|
||||
if (kind === 'amount') return Number.isFinite(amountMajor) ? Math.round(amountMajor * 100) : null;
|
||||
return Number.isFinite(quantity) ? Math.round(quantity * effRateMinor) : null;
|
||||
}, [kind, amountMajor, quantity, effRateMinor]);
|
||||
// Proof is only mandatory on CREATE (or when editing a row that has no
|
||||
// proof yet) — an edit on a row that already has a proof keeps it.
|
||||
const requireProof = !!settings?.accounting_require_proof && !(isEdit && expense!.hasProof);
|
||||
|
||||
const payload = () => ({
|
||||
kind,
|
||||
quantity: kind === 'amount' ? undefined : (Number.isFinite(quantity) ? quantity : undefined),
|
||||
rateMinor: kind === 'amount' ? undefined : (Number.isFinite(rateMajor) ? Math.round(rateMajor * 100) : undefined),
|
||||
chfAmountMinor: kind === 'amount' && Number.isFinite(amountMajor) ? Math.round(amountMajor * 100) : undefined,
|
||||
eventId,
|
||||
categoryId: categoryId ?? null,
|
||||
supplierName: supplier || null,
|
||||
description: description || null,
|
||||
});
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () => isEdit
|
||||
? accountingService.updateExpense(expense!.id, payload(), file)
|
||||
: accountingService.createExpense(payload(), file),
|
||||
onSuccess: () => { toast.success(isEdit ? t('accounting.ledger.updatedToast', 'Expense updated.') : t('accounting.ledger.createdToast', 'Expense added.')); onDone(); },
|
||||
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'),
|
||||
});
|
||||
|
||||
const qtyLabel = kind === 'mileage' ? t('accounting.expense.km', 'Kilometres') : t('accounting.expense.days', 'Days');
|
||||
const incomplete = (kind === 'amount' && !Number.isFinite(amountMajor)) || (kind !== 'amount' && !Number.isFinite(quantity)) || (requireProof && !file);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/50 p-4">
|
||||
<div className="mt-12 w-full max-w-md rounded-xl bg-white dark:bg-neutral-900 shadow-xl">
|
||||
<div className="flex items-center justify-between border-b border-neutral-200 dark:border-neutral-700 px-5 py-3">
|
||||
<h2 className="text-base font-semibold text-neutral-900 dark:text-neutral-100">{isEdit ? t('accounting.ledger.editTitle', 'Edit expense') : t('accounting.ledger.addTitle', 'Add expense')}</h2>
|
||||
<button onClick={onClose} className="text-neutral-400 hover:text-neutral-600"><X className="w-5 h-5" /></button>
|
||||
</div>
|
||||
<div className="px-5 py-4 space-y-3">
|
||||
<div><label className={labelCls}>{t('accounting.expense.kind', 'Type')}</label>
|
||||
<select value={kind} onChange={(e) => setKind(e.target.value as ExpenseKind)} className={selectCls}>
|
||||
{KINDS.map((k) => <option key={k} value={k}>{t(`accounting.expenseKind.${k}`, k)}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{kind === 'amount' ? (
|
||||
<div><label className={labelCls}>{t('accounting.inbox.field.total', 'Total')}</label><DecimalInput value={amountMajor} onChange={setAmountMajor} fractionDigits={2} className={selectCls} /></div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><label className={labelCls}>{qtyLabel}</label><DecimalInput value={quantity} onChange={setQuantity} fractionDigits={2} className={selectCls} /></div>
|
||||
<div><label className={labelCls}>{t('accounting.expense.rate', 'Rate')}</label>
|
||||
<DecimalInput value={rateMajor} onChange={setRateMajor} fractionDigits={2} className={selectCls} placeholder={(defaultRateMinor / 100).toFixed(2)} />
|
||||
<p className="mt-1 text-xs text-neutral-500">{t('accounting.expense.rateDefault', 'Default {{rate}} — leave blank to use it', { rate: (defaultRateMinor / 100).toFixed(2) })}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{computedMinor != null && <p className="text-sm text-neutral-700 dark:text-neutral-300">{t('accounting.expense.computed', 'Amount')}: <span className="font-semibold">{formatMoneyMinor(computedMinor, 'CHF')}</span></p>}
|
||||
|
||||
<div><label className={labelCls}>{t('accounting.expense.who', 'Paid by / vendor')}</label><Input value={supplier} onChange={(e) => setSupplier(e.target.value)} placeholder={t('accounting.expense.whoHint', 'e.g. coworker name or shop')} /></div>
|
||||
<div><label className={labelCls}>{t('accounting.ledger.description', 'Description')}</label><Input value={description} onChange={(e) => setDescription(e.target.value)} /></div>
|
||||
|
||||
<div><label className={labelCls}>{t('accounting.inbox.field.category', 'Category')}</label>
|
||||
<select value={categoryId ?? ''} onChange={(e) => setCategoryId(e.target.value ? Number(e.target.value) : undefined)} className={selectCls}>
|
||||
<option value="">{t('accounting.inbox.field.categoryNone', '— none —')}</option>
|
||||
{categories.map((c) => <option key={c.id} value={c.id}>{categoryLabel(c, t)}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div><label className={labelCls}>{t('accounting.booking.label', 'Book to')}</label>
|
||||
<EventBookingSelect value={eventId} onChange={setEventId} className={selectCls} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelCls}>{t('accounting.expense.proof', 'Proof')}{requireProof ? ' *' : ''}</label>
|
||||
{isEdit && expense!.hasProof && !file && <p className="mb-1 text-xs text-neutral-500 dark:text-neutral-400">{t('accounting.expense.proofExisting', 'A proof file is already attached — upload a new one to replace it.')}</p>}
|
||||
<input type="file" accept="image/*,application/pdf" onChange={(e) => setFile(e.target.files?.[0] || null)} className="text-sm" />
|
||||
{requireProof && !file && <p className="mt-1 text-xs text-amber-600 dark:text-amber-400">{t('accounting.expense.proofRequired', 'A proof file is required.')}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 border-t border-neutral-200 dark:border-neutral-700 px-5 py-3">
|
||||
<Button variant="outline" onClick={onClose}>{t('common.cancel', 'Cancel')}</Button>
|
||||
<Button onClick={() => save.mutate()} disabled={save.isPending || incomplete}>{save.isPending ? t('common.saving', 'Saving…') : (isEdit ? t('common.save', 'Save') : t('accounting.ledger.addExpense', 'Add expense'))}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** Mark an expense supplier-paid / settled (manual). #2. */
|
||||
const ExpensePaidModal: React.FC<{ expense: Expense; onClose: () => void; onDone: () => void }> = ({ expense, onClose, onDone }) => {
|
||||
const { t } = useTranslation();
|
||||
const [paidAt, setPaidAt] = useState('');
|
||||
const [method, setMethod] = useState<PaymentMethod>('bank_transfer');
|
||||
const [reference, setReference] = useState('');
|
||||
const save = useMutation({
|
||||
mutationFn: () => accountingService.markExpensePaid(expense.id, { paid: true, paidAt: paidAt || undefined, paymentMethod: method, paymentReference: reference || undefined }),
|
||||
onSuccess: () => { toast.success(t('accounting.ledger.paidToast', 'Marked as paid.')); onDone(); },
|
||||
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'),
|
||||
});
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-start justify-center bg-black/50 p-4">
|
||||
<div className="mt-16 w-full max-w-sm rounded-xl bg-white dark:bg-neutral-900 shadow-xl">
|
||||
<div className="flex items-center justify-between border-b border-neutral-200 dark:border-neutral-700 px-5 py-3">
|
||||
<h2 className="text-base font-semibold text-neutral-900 dark:text-neutral-100">{t('accounting.ledger.payTitle', 'Mark expense paid')}</h2>
|
||||
<button onClick={onClose} className="text-neutral-400 hover:text-neutral-600"><X className="w-5 h-5" /></button>
|
||||
</div>
|
||||
<div className="px-5 py-4 space-y-3">
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
{t('accounting.expense.computed', 'Amount')}: <span className="font-semibold text-neutral-900 dark:text-neutral-100">{expense.chfAmountMinor != null ? formatMoneyMinor(expense.chfAmountMinor, 'CHF') : '—'}</span>
|
||||
</p>
|
||||
<div><label className={labelCls}>{t('accounting.ledger.paidDate', 'Payment date')}</label><LocalizedDateInput value={paidAt} onChange={setPaidAt} /></div>
|
||||
<div><label className={labelCls}>{t('accounting.ledger.method', 'Method')}</label>
|
||||
<select value={method} onChange={(e) => setMethod(e.target.value as PaymentMethod)} className={selectCls}>
|
||||
{PAYMENT_METHODS.map((m) => <option key={m} value={m}>{t(`accounting.paymentMethod.${m}`, m)}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div><label className={labelCls}>{t('accounting.ledger.reference', 'Reference (optional)')}</label><Input value={reference} onChange={(e) => setReference(e.target.value)} /></div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 border-t border-neutral-200 dark:border-neutral-700 px-5 py-3">
|
||||
<Button variant="outline" onClick={onClose}>{t('common.cancel', 'Cancel')}</Button>
|
||||
<Button onClick={() => save.mutate()} disabled={save.isPending}>{save.isPending ? t('common.saving', 'Saving…') : t('accounting.ledger.confirmPaid', 'Mark paid')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** Add an expense onto a client invoice (re-bill). Locks editing. #3. */
|
||||
const InvoiceExpenseModal: React.FC<{ expense: Expense; onClose: () => void; onDone: () => void }> = ({ expense, onClose, onDone }) => {
|
||||
const { t } = useTranslation();
|
||||
const [customer, setCustomer] = useState<SelectedCustomer[]>([]);
|
||||
const [markupType, setMarkupType] = useState<MarkupType>('none');
|
||||
const [markupValue, setMarkupValue] = useState<number>(NaN);
|
||||
const save = useMutation({
|
||||
mutationFn: () => accountingService.invoiceExpense(expense.id, {
|
||||
customerAccountId: customer[0]!.id,
|
||||
markupType,
|
||||
markupPercent: markupType === 'percent' && Number.isFinite(markupValue) ? markupValue : null,
|
||||
markupFlatMinor: markupType === 'flat' && Number.isFinite(markupValue) ? Math.round(markupValue * 100) : null,
|
||||
}),
|
||||
onSuccess: () => { toast.success(t('accounting.ledger.invoicedToast', 'Added to a client invoice.')); onDone(); },
|
||||
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'),
|
||||
});
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/50 p-4">
|
||||
<div className="mt-16 w-full max-w-md rounded-xl bg-white dark:bg-neutral-900 shadow-xl">
|
||||
<div className="flex items-center justify-between border-b border-neutral-200 dark:border-neutral-700 px-5 py-3">
|
||||
<h2 className="text-base font-semibold text-neutral-900 dark:text-neutral-100">{t('accounting.ledger.invoiceTitle', 'Add to client invoice')}</h2>
|
||||
<button onClick={onClose} className="text-neutral-400 hover:text-neutral-600"><X className="w-5 h-5" /></button>
|
||||
</div>
|
||||
<div className="px-5 py-4 space-y-3">
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
{t('accounting.expense.computed', 'Amount')}: <span className="font-semibold text-neutral-900 dark:text-neutral-100">{expense.chfAmountMinor != null ? formatMoneyMinor(expense.chfAmountMinor, 'CHF') : '—'}</span>
|
||||
</p>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400">{t('accounting.ledger.invoiceHint', 'This creates a billable line on the client’s next scheduled invoice and locks the expense from further edits.')}</p>
|
||||
<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>
|
||||
{markupType !== 'none' && <DecimalInput value={markupValue} onChange={setMarkupValue} fractionDigits={2} className={selectCls} placeholder={markupType === 'percent' ? '%' : 'CHF'} />}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 border-t border-neutral-200 dark:border-neutral-700 px-5 py-3">
|
||||
<Button variant="outline" onClick={onClose}>{t('common.cancel', 'Cancel')}</Button>
|
||||
<Button onClick={() => save.mutate()} disabled={save.isPending || !customer[0]}>{save.isPending ? t('common.saving', 'Saving…') : t('accounting.ledger.addToInvoice', 'Add to invoice')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ExpensesLedgerPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const { format } = useLocalizedDate();
|
||||
const [kind, setKind] = useState('');
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [editExpense, setEditExpense] = useState<Expense | null>(null);
|
||||
const [paidExpense, setPaidExpense] = useState<Expense | null>(null);
|
||||
const [invoiceExpense, setInvoiceExpense] = useState<Expense | null>(null);
|
||||
|
||||
const unpay = useMutation({
|
||||
mutationFn: (id: number) => accountingService.markExpensePaid(id, { paid: false }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['accounting-expenses'] }),
|
||||
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'),
|
||||
});
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['accounting-expenses', kind],
|
||||
queryFn: () => accountingService.listExpenses({ kind: (kind || undefined) as ExpenseKind | undefined, pageSize: 100 }),
|
||||
});
|
||||
const { data: categories } = useQuery({ queryKey: ['expense-categories'], queryFn: () => accountingService.listCategories() });
|
||||
const catById = useMemo(() => new Map((categories ?? []).map((c) => [c.id, c])), [categories]);
|
||||
|
||||
const openProof = async (id: number) => {
|
||||
try { const blob = await accountingService.getExpenseProofBlob(id); window.open(URL.createObjectURL(blob), '_blank'); }
|
||||
catch (e: any) { toast.error(e?.response?.data?.error || e.message || 'Failed'); }
|
||||
};
|
||||
|
||||
const items = data?.items ?? [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 flex flex-wrap gap-3">
|
||||
<select value={kind} onChange={(e) => setKind(e.target.value)} className={selectCls} style={{ maxWidth: 200 }}>
|
||||
<option value="">{t('accounting.ledger.allKinds', 'All types')}</option>
|
||||
{KINDS.map((k) => <option key={k} value={k}>{t(`accounting.expenseKind.${k}`, k)}</option>)}
|
||||
</select>
|
||||
<Button className="ml-auto" onClick={() => setShowAdd(true)}><Plus className="w-4 h-4 mr-1" /> {t('accounting.ledger.addExpense', 'Add expense')}</Button>
|
||||
</div>
|
||||
|
||||
{isLoading ? <Loading /> : items.length === 0 ? (
|
||||
<Card><CardContent className="p-8 text-center text-sm text-neutral-600 dark:text-neutral-400">{t('accounting.ledger.empty', 'No expenses yet — add one above.')}</CardContent></Card>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{items.map((ex: Expense) => {
|
||||
const cat = ex.categoryId ? catById.get(ex.categoryId) : null;
|
||||
return (
|
||||
<div key={ex.id} className="flex flex-wrap items-center gap-3 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-4 py-3">
|
||||
<span className="inline-flex items-center gap-1 rounded bg-neutral-100 dark:bg-neutral-800 px-2 py-0.5 text-xs font-medium text-neutral-700 dark:text-neutral-300">{kindIcon[ex.kind]} {t(`accounting.expenseKind.${ex.kind}`, ex.kind)}</span>
|
||||
<div className="flex-1 min-w-[10rem]">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-neutral-900 dark:text-neutral-100 truncate">{ex.description || ex.supplierName || t('accounting.ledger.untitled', 'Expense')}</span>
|
||||
{/* invoiced = on a real client invoice → locked (#2/#3). */}
|
||||
{ex.invoiced && (
|
||||
ex.billedInvoiceId ? (
|
||||
<Link to={`/admin/bills/${ex.billedInvoiceId}`} className="inline-flex items-center gap-1 rounded bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider hover:underline">
|
||||
<FileText className="w-3 h-3" /> {t('accounting.ledger.invoiced', 'Invoiced')}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 rounded bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider"><FileText className="w-3 h-3" /> {t('accounting.ledger.invoiced', 'Invoiced')}</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{ex.eventId != null ? `${t('accounting.booking.event', 'Event')} #${ex.eventId}` : t('accounting.booking.company', 'Company')}
|
||||
{cat && <>{' · '}{categoryLabel(cat, t)}</>}
|
||||
{' · '}{format(ex.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
{ex.hasProof && <button onClick={() => openProof(ex.id)} className="inline-flex items-center gap-1 text-xs text-primary-600 hover:underline"><Paperclip className="w-3.5 h-3.5" /> {t('accounting.expense.viewProof', 'Proof')}</button>}
|
||||
|
||||
{/* Paid toggle (#2): manual, independent of invoiced. */}
|
||||
{ex.paid
|
||||
? <button onClick={() => unpay.mutate(ex.id)} disabled={unpay.isPending} title={ex.paidAt ? format(ex.paidAt) : undefined} className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-green-700 dark:text-green-300 hover:bg-green-50 dark:hover:bg-green-900/30"><CheckCircle2 className="w-4 h-4" /> {t('accounting.ledger.paid', 'Paid')}</button>
|
||||
: <Button size="sm" variant="outline" onClick={() => setPaidExpense(ex)}><Circle className="w-3.5 h-3.5 mr-1" /> {t('accounting.ledger.markPaid', 'Mark paid')}</Button>}
|
||||
|
||||
{/* Edit + add-to-invoice only until invoiced (#3). */}
|
||||
{ex.invoiced ? (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-neutral-400 dark:text-neutral-500" title={t('accounting.ledger.lockedHint', 'Locked — this expense is on a client invoice.') as string}><Lock className="w-3.5 h-3.5" /> {t('accounting.ledger.locked', 'Locked')}</span>
|
||||
) : (
|
||||
<>
|
||||
<Button size="sm" variant="ghost" onClick={() => setEditExpense(ex)}><Pencil className="w-3.5 h-3.5 mr-1" /> {t('common.edit', 'Edit')}</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setInvoiceExpense(ex)}><FileText className="w-3.5 h-3.5 mr-1" /> {t('accounting.ledger.addToInvoice', 'Add to invoice')}</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="text-sm font-medium tabular-nums text-neutral-900 dark:text-neutral-100">{ex.chfAmountMinor != null ? formatMoneyMinor(ex.chfAmountMinor, 'CHF') : '—'}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAdd && <ExpenseFormModal categories={categories ?? []} onClose={() => setShowAdd(false)} onDone={() => { setShowAdd(false); qc.invalidateQueries({ queryKey: ['accounting-expenses'] }); }} />}
|
||||
{editExpense && <ExpenseFormModal categories={categories ?? []} expense={editExpense} onClose={() => setEditExpense(null)} onDone={() => { setEditExpense(null); qc.invalidateQueries({ queryKey: ['accounting-expenses'] }); }} />}
|
||||
{paidExpense && <ExpensePaidModal expense={paidExpense} onClose={() => setPaidExpense(null)} onDone={() => { setPaidExpense(null); qc.invalidateQueries({ queryKey: ['accounting-expenses'] }); }} />}
|
||||
{invoiceExpense && <InvoiceExpenseModal expense={invoiceExpense} onClose={() => setInvoiceExpense(null)} onDone={() => { setInvoiceExpense(null); qc.invalidateQueries({ queryKey: ['accounting-expenses'] }); }} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExpensesLedgerPage;
|
||||
@@ -394,7 +394,7 @@ export const BillDetailPage: React.FC = () => {
|
||||
<div><div className="text-neutral-600 dark:text-neutral-300">{t('bills.field.eventName', 'Event')}</div>
|
||||
<div>
|
||||
{inv.eventId ? (
|
||||
<Link to={`/admin/events/${inv.eventId}`} className="text-theme hover:underline">{inv.eventName}</Link>
|
||||
<Link to={`/admin/events/${inv.eventId}`} className="text-neutral-900 dark:text-neutral-100 hover:underline">{inv.eventName}</Link>
|
||||
) : inv.eventName}
|
||||
{inv.eventDate ? ` · ${fmtDate(inv.eventDate)}` : ''}
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,7 @@ import { quotesService } from '../../../services/quotes.service';
|
||||
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 { LineItemsTable, type EditableLineItem } from '../../../components/admin/LineItemsTable';
|
||||
import { InstallmentsPanel } from '../../../components/admin/InstallmentsPanel';
|
||||
import { customerAdminService } from '../../../services/customerAdmin.service';
|
||||
@@ -58,6 +59,8 @@ export const BillEditorPage: React.FC = () => {
|
||||
// effect; new invoices start as null so they pick up profile.)
|
||||
const [qrFormat, setQrFormat] = useState<InvoiceQrFormat | null>(null);
|
||||
const [vatRate, setVatRate] = useState(0);
|
||||
// Migration 130 — snapshot of the chosen output VAT code (null = custom rate).
|
||||
const [vatCode, setVatCode] = useState<string | null>(null);
|
||||
const [shipping, setShipping] = useState(0);
|
||||
const [ccPdfEmail, setCcPdfEmail] = useState('');
|
||||
const [lineItems, setLineItems] = useState<EditableLineItem[]>([]);
|
||||
@@ -137,6 +140,8 @@ export const BillEditorPage: React.FC = () => {
|
||||
// it inherits the profile default at render time.
|
||||
setQrFormat((inv.qrFormat as InvoiceQrFormat | null) || null);
|
||||
setVatRate(Number(inv.vatRate || 0));
|
||||
setVatCode(((inv as { vatCode?: string | null }).vatCode
|
||||
?? (inv as { totals?: { vatCode?: string | null } }).totals?.vatCode) ?? null);
|
||||
setShipping(Number(inv.shippingAmountMinor || 0) / 100);
|
||||
setCcPdfEmail(inv.ccPdfEmail || '');
|
||||
setPaymentTermTemplateId(inv.paymentTermTemplateId ?? null);
|
||||
@@ -280,6 +285,7 @@ export const BillEditorPage: React.FC = () => {
|
||||
const { quote, lineItems: quoteLineItems } = await quotesService.get(contract.sourceQuoteId);
|
||||
if (quote.currency) setCurrency(quote.currency);
|
||||
if (quote.vatRate != null) setVatRate(Number(quote.vatRate));
|
||||
setVatCode((quote as { vatCode?: string | null }).vatCode ?? null);
|
||||
if (quote.shippingAmountMinor != null) {
|
||||
setShipping(Number(quote.shippingAmountMinor) / 100);
|
||||
}
|
||||
@@ -373,6 +379,7 @@ export const BillEditorPage: React.FC = () => {
|
||||
// which is the same outcome but pollutes the column.
|
||||
qrFormat: qrFormat || undefined,
|
||||
vatRate,
|
||||
vatCode,
|
||||
shippingAmountMinor: toMinor(shipping),
|
||||
ccPdfEmail: ccPdfEmail || undefined,
|
||||
// Payment-term template id (migration 113). null = no template
|
||||
@@ -651,8 +658,11 @@ export const BillEditorPage: React.FC = () => {
|
||||
<LineItemsTable items={lineItems} currency={currency} showDiscount={false}
|
||||
vatRate={vatRate / 100} shippingAmount={shipping} onChange={setLineItems} />
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 mt-4">
|
||||
<Input type="number" step="0.1" label={t('bills.field.vatRate', 'VAT rate %') as string}
|
||||
value={vatRate} onChange={(e) => setVatRate(Number(e.target.value))} />
|
||||
<VatRateSelect
|
||||
label={t('bills.field.vatRate', 'VAT rate %') as string}
|
||||
rate={vatRate}
|
||||
code={vatCode}
|
||||
onChange={(rate, code) => { setVatRate(rate); setVatCode(code); }} />
|
||||
<Input type="number" step="0.01" label={t('bills.field.shipping', 'Shipping') as string}
|
||||
value={shipping} onChange={(e) => setShipping(Number(e.target.value))} />
|
||||
<div>
|
||||
|
||||
@@ -61,7 +61,7 @@ export const BillsListPage: React.FC = () => {
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-2xl font-bold text-theme">{t('bills.title', 'Invoices')}</h1>
|
||||
<h1 className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">{t('bills.title', 'Invoices')}</h1>
|
||||
{/* Beta badge — matches the Customers + Quotes pages. */}
|
||||
<span
|
||||
className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded font-semibold bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300"
|
||||
@@ -70,7 +70,7 @@ export const BillsListPage: React.FC = () => {
|
||||
{t('navigation.betaTag', 'Beta')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-theme mt-1">
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-1">
|
||||
{t('bills.subtitle', 'Schedule, send, track payments and chase late invoices.')}
|
||||
</p>
|
||||
</div>
|
||||
@@ -122,7 +122,7 @@ export const BillsListPage: React.FC = () => {
|
||||
{/* Body inside the same card (matches Customers + Quotes). */}
|
||||
<div className="mt-4">
|
||||
{isLoading ? <Loading /> : !data || data.invoices.length === 0 ? (
|
||||
<p className="text-center text-muted-theme py-8">{t('bills.empty', 'No invoices yet.')}</p>
|
||||
<p className="text-center text-neutral-500 dark:text-neutral-400 py-8">{t('bills.empty', 'No invoices yet.')}</p>
|
||||
) : (
|
||||
<div className="rounded-lg border border-neutral-200 dark:border-neutral-700 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
@@ -173,11 +173,11 @@ export const BillsListPage: React.FC = () => {
|
||||
<td className="px-3 py-2 truncate max-w-xs">
|
||||
{inv.eventName
|
||||
? (inv.eventId
|
||||
? <Link to={`/admin/events/${inv.eventId}`} className="text-theme hover:underline" onClick={(e) => e.stopPropagation()}>{inv.eventName}</Link>
|
||||
? <Link to={`/admin/events/${inv.eventId}`} className="text-neutral-900 dark:text-neutral-100 hover:underline" onClick={(e) => e.stopPropagation()}>{inv.eventName}</Link>
|
||||
: inv.eventName)
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs text-muted-theme">
|
||||
<td className="px-3 py-2 text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{inv.installmentTotal > 1 ? `${inv.installmentIndex + 1}/${inv.installmentTotal} · ${inv.installmentLabel || ''}` : '—'}
|
||||
</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap">{inv.issueDate ? fmtDate(inv.issueDate) : '—'}</td>
|
||||
|
||||
@@ -436,10 +436,10 @@ export const CalendarPage: React.FC = () => {
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-theme">
|
||||
<h1 className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">
|
||||
{t('calendar.pageTitle', 'Calendar')}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-theme">
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
{t('calendar.subtitle',
|
||||
'Events, logged hours, and pending quotes/contracts in one view.')}
|
||||
</p>
|
||||
@@ -466,7 +466,7 @@ export const CalendarPage: React.FC = () => {
|
||||
|
||||
<Card padding="md">
|
||||
{itemsLoading && !itemsResp && (
|
||||
<div className="mb-3 flex items-center gap-2 text-sm text-muted-theme">
|
||||
<div className="mb-3 flex items-center gap-2 text-sm text-neutral-500 dark:text-neutral-400">
|
||||
<Loading />
|
||||
<span>{t('calendar.loading', 'Loading items…')}</span>
|
||||
</div>
|
||||
@@ -609,6 +609,11 @@ export const CalendarPage: React.FC = () => {
|
||||
and the active state (selected view) all read from these
|
||||
vars so the calendar respects custom branding palettes. */}
|
||||
<style>{`
|
||||
/* Admin-toggle-aware chrome vars — gallery theme writes inline
|
||||
--color-* on :root which would otherwise override .dark; scope
|
||||
local vars so the calendar follows the admin light/dark toggle. */
|
||||
.fc { --cal-border: #e5e5e5; --cal-muted: #737373; --cal-text: #171717; }
|
||||
.dark .fc { --cal-border: #262626; --cal-muted: #a3a3a3; --cal-text: #f5f5f5; }
|
||||
.cal-dashed {
|
||||
border-style: dashed !important;
|
||||
background-color: transparent !important;
|
||||
@@ -624,8 +629,8 @@ export const CalendarPage: React.FC = () => {
|
||||
/* FC button restyle — match picpeak admin .btn-outline shape. */
|
||||
.fc .fc-button-primary {
|
||||
background-color: transparent;
|
||||
border: 1px solid var(--color-surface-border);
|
||||
color: var(--color-muted-text);
|
||||
border: 1px solid var(--cal-border);
|
||||
color: var(--cal-muted);
|
||||
text-transform: none;
|
||||
font-weight: 500;
|
||||
box-shadow: none;
|
||||
@@ -634,8 +639,8 @@ export const CalendarPage: React.FC = () => {
|
||||
.fc .fc-button-primary:hover:not(:disabled) {
|
||||
opacity: 0.8;
|
||||
background-color: transparent;
|
||||
border-color: var(--color-surface-border);
|
||||
color: var(--color-muted-text);
|
||||
border-color: var(--cal-border);
|
||||
color: var(--cal-muted);
|
||||
}
|
||||
.fc .fc-button-primary:focus,
|
||||
.fc .fc-button-primary:focus-visible {
|
||||
@@ -652,14 +657,14 @@ export const CalendarPage: React.FC = () => {
|
||||
.fc .fc-button-primary:disabled {
|
||||
opacity: 0.5;
|
||||
background-color: transparent;
|
||||
border-color: var(--color-surface-border);
|
||||
color: var(--color-muted-text);
|
||||
border-color: var(--cal-border);
|
||||
color: var(--cal-muted);
|
||||
}
|
||||
/* Toolbar title (month / week range) reads the regular text
|
||||
colour rather than FC's hardcoded #333 so dark mode looks
|
||||
right. */
|
||||
.fc .fc-toolbar-title {
|
||||
color: var(--color-text);
|
||||
color: var(--cal-text);
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -677,12 +682,12 @@ export const CalendarPage: React.FC = () => {
|
||||
selector that maps to a day column so the rule works
|
||||
regardless of which DOM shape FC ends up rendering. */
|
||||
.fc {
|
||||
--fc-border-color: var(--color-surface-border);
|
||||
--fc-border-color: var(--cal-border);
|
||||
}
|
||||
.fc .fc-timegrid-col:not(:first-of-type),
|
||||
.fc .fc-day:not(:first-child),
|
||||
.fc .fc-timegrid-cols > table > tbody > tr > td:not(:first-child) {
|
||||
box-shadow: inset 1px 0 0 var(--color-surface-border);
|
||||
box-shadow: inset 1px 0 0 var(--cal-border);
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
@@ -698,7 +703,7 @@ const Legend: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Card padding="sm">
|
||||
<div className="flex flex-wrap gap-4 text-xs text-muted-theme">
|
||||
<div className="flex flex-wrap gap-4 text-xs text-neutral-500 dark:text-neutral-400">
|
||||
<LegendSwatch color={COLOR_EVENT} label={t('calendar.legend.events', 'Events')} />
|
||||
<LegendSwatch color={COLOR_HOURS} label={t('calendar.legend.hours', 'Hours')} />
|
||||
<LegendSwatch
|
||||
|
||||
@@ -239,14 +239,14 @@ export const CrmDevelopmentPage: React.FC = () => {
|
||||
<MailCheck className="w-4 h-4" />
|
||||
{t('crmDev.paymentCheck.title', 'Test payment-check email (real invoice)')}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-theme mb-4">
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 mb-4">
|
||||
{t('crmDev.paymentCheck.help',
|
||||
'Fires the admin payment-check email for a real sent/overdue invoice, bypassing the 24h throttle. The three buttons in the email are real signed tokens — clicking them will affect the invoice status.')}
|
||||
</p>
|
||||
|
||||
{invoiceListLoading ? <Loading /> : (
|
||||
<>
|
||||
<label className="block text-xs uppercase tracking-wider text-muted-theme mb-1">
|
||||
<label className="block text-xs uppercase tracking-wider text-neutral-500 dark:text-neutral-400 mb-1">
|
||||
{t('crmDev.paymentCheck.selectInvoice', 'Sent or overdue invoice')}
|
||||
</label>
|
||||
<select
|
||||
@@ -262,7 +262,7 @@ export const CrmDevelopmentPage: React.FC = () => {
|
||||
))}
|
||||
</select>
|
||||
{invoiceList && invoiceList.invoices.length === 0 && (
|
||||
<p className="text-sm text-muted-theme mb-3">
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 mb-3">
|
||||
{t('crmDev.paymentCheck.noneAvailable',
|
||||
'No sent or overdue invoices in the database.')}
|
||||
</p>
|
||||
@@ -285,7 +285,7 @@ export const CrmDevelopmentPage: React.FC = () => {
|
||||
<Mail className="w-4 h-4" />
|
||||
{t('crmDev.templates.title', 'Send any CRM email to me (mock data)')}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-theme mb-4">
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 mb-4">
|
||||
{t('crmDev.templates.help',
|
||||
'Queues the chosen template to your own admin email with placeholder values. When the install has a real quote / invoice on file, the appropriate PDF is attached so you can verify the full output.')}
|
||||
</p>
|
||||
@@ -294,12 +294,12 @@ export const CrmDevelopmentPage: React.FC = () => {
|
||||
// Env gate banner above already explains the situation —
|
||||
// keep this card empty rather than rendering a misleading
|
||||
// "0 templates" list.
|
||||
<p className="text-sm text-muted-theme">
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
{t('crmDev.envDisabled.cardHint',
|
||||
'Email templates can\'t be listed until the dev-tools env gate is opened.')}
|
||||
</p>
|
||||
) : (templates && templates.length === 0) ? (
|
||||
<p className="text-sm text-muted-theme">
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
{t('crmDev.templates.empty',
|
||||
'No CRM email templates found — run migrations to seed them.')}
|
||||
</p>
|
||||
@@ -323,7 +323,7 @@ export const CrmDevelopmentPage: React.FC = () => {
|
||||
</div>
|
||||
<div className="text-sm font-medium mt-0.5">{title}</div>
|
||||
{description && (
|
||||
<div className="text-xs text-muted-theme mt-0.5">{description}</div>
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400 mt-0.5">{description}</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
|
||||
@@ -238,7 +238,7 @@ export const HourEntryDragCreateModal: React.FC<HourEntryDragCreateModalProps> =
|
||||
<h2 className="font-semibold text-lg mb-1">
|
||||
{t('calendar.hourEntry.createTitle', 'Log hours')}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-theme mb-4">
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-4">
|
||||
{/* The pre-filled range is part of the page state, not editable
|
||||
from this modal. Admin can edit start/end after creating
|
||||
via the inline-edit popover (also in this commit). */}
|
||||
|
||||
@@ -144,7 +144,7 @@ export const HourEntryInlinePopover: React.FC<HourEntryInlinePopoverProps> = ({
|
||||
<h2 className="font-semibold text-lg">
|
||||
{item.customerName || t('calendar.hourEntry.untitledCustomer', 'Hours')}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-theme">
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{item.entryDate} · {item.startTime}–{item.endTime}
|
||||
</p>
|
||||
</div>
|
||||
@@ -166,7 +166,7 @@ export const HourEntryInlinePopover: React.FC<HourEntryInlinePopoverProps> = ({
|
||||
// here so the admin can't accidentally type into a locked
|
||||
// entry. The invoice link is omitted for now — clicking
|
||||
// through to the bill belongs on a follow-up commit.
|
||||
<p className="text-sm text-muted-theme">
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
{item.description || t('calendar.hourEntry.noDescription', 'No description.')}
|
||||
</p>
|
||||
) : (
|
||||
|
||||
@@ -91,7 +91,7 @@ export const HoursLoggingPage: React.FC = () => {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-2xl font-bold text-theme">
|
||||
<h1 className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">
|
||||
{t('hoursLogging.title', 'Hours logging')}
|
||||
</h1>
|
||||
{/* Beta badge — matches Customers + Quotes + Contracts +
|
||||
@@ -104,7 +104,7 @@ export const HoursLoggingPage: React.FC = () => {
|
||||
{t('navigation.betaTag', 'Beta')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-theme mt-1">
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-1">
|
||||
{t('hoursLogging.subtitle',
|
||||
'Pick a customer and log billable time blocks. Entries flow into the next monthly bill or are billed on demand for per-event customers.')}
|
||||
</p>
|
||||
@@ -112,7 +112,7 @@ export const HoursLoggingPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<Card padding="lg">
|
||||
<label className="block text-sm font-medium text-theme mb-2">
|
||||
<label className="block text-sm font-medium text-neutral-900 dark:text-neutral-100 mb-2">
|
||||
{t('hoursLogging.pickCustomer', 'Customer')}
|
||||
</label>
|
||||
<CustomerPicker
|
||||
@@ -158,22 +158,22 @@ export const HoursLoggingPage: React.FC = () => {
|
||||
{!selectedId && (
|
||||
<Card padding="lg">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Clock className="w-4 h-4 text-muted-theme" />
|
||||
<h2 className="text-base font-semibold text-theme">
|
||||
<Clock className="w-4 h-4 text-neutral-500 dark:text-neutral-400" />
|
||||
<h2 className="text-base font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{t('hoursLogging.openHours.title', 'Open hours across all customers')}
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-theme mb-4">
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 mb-4">
|
||||
{t('hoursLogging.openHours.subtitle',
|
||||
'Unbilled time blocks waiting to be billed. Pick a customer above, or click a row to drill in.')}
|
||||
</p>
|
||||
|
||||
{summaryLoading ? (
|
||||
<p className="text-sm text-muted-theme py-6 text-center">
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 py-6 text-center">
|
||||
{t('common.loading', 'Loading…')}
|
||||
</p>
|
||||
) : summary.length === 0 ? (
|
||||
<p className="text-sm text-muted-theme py-6 text-center">
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 py-6 text-center">
|
||||
{t('hoursLogging.openHours.empty',
|
||||
'No unbilled hours right now — everything is billed or no time has been logged yet.')}
|
||||
</p>
|
||||
@@ -188,14 +188,14 @@ export const HoursLoggingPage: React.FC = () => {
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-theme truncate">{summaryLabel(r)}</span>
|
||||
<span className="font-medium text-neutral-900 dark:text-neutral-100 truncate">{summaryLabel(r)}</span>
|
||||
{r.isPassive && (
|
||||
<span className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-neutral-100 text-neutral-600 dark:bg-neutral-700 dark:text-neutral-300">
|
||||
{t('hoursLogging.openHours.passive', 'Passive')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-theme mt-0.5">
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400 mt-0.5">
|
||||
{t('hoursLogging.openHours.entryLine', {
|
||||
count: r.entryCount,
|
||||
hours: (r.totalMinutes / 60).toFixed(2),
|
||||
@@ -206,7 +206,7 @@ export const HoursLoggingPage: React.FC = () => {
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<div className="text-right">
|
||||
{r.rateResolvable ? (
|
||||
<div className="font-semibold text-theme tabular-nums">
|
||||
<div className="font-semibold text-neutral-900 dark:text-neutral-100 tabular-nums">
|
||||
{formatMoneyMinor(r.openAmountMinor, currency)}
|
||||
</div>
|
||||
) : (
|
||||
@@ -216,7 +216,7 @@ export const HoursLoggingPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ChevronRight className="w-4 h-4 text-muted-theme" />
|
||||
<ChevronRight className="w-4 h-4 text-neutral-500 dark:text-neutral-400" />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Calculator, Download, FileDown, AlertCircle } from 'lucide-react';
|
||||
import { Calculator, Download, FileDown, FileSpreadsheet, AlertCircle } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Button, Card, Loading, LocalizedDateInput } from '../../../components/common';
|
||||
|
||||
// Lightweight native select styled to match Input — the common barrel
|
||||
@@ -26,9 +27,13 @@ import { Button, Card, Loading, LocalizedDateInput } from '../../../components/c
|
||||
const selectClassName =
|
||||
'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';
|
||||
import { taxReportService, type TaxReportParams } from '../../../services/taxReport.service';
|
||||
import { ledgerService, type ExportFormat } from '../../../services/ledger.service';
|
||||
import { useFeatureFlags } from '../../../contexts/FeatureFlagsContext';
|
||||
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
const LEDGER_FORMATS: ExportFormat[] = ['generic', 'banana', 'banana_ie', 'bexio'];
|
||||
|
||||
type PeriodPreset = 'thisYear' | 'lastYear' | 'thisQuarter' | 'lastQuarter' | 'custom';
|
||||
|
||||
function isoDate(d: Date): string {
|
||||
@@ -89,13 +94,22 @@ function triggerBrowserDownload(url: string, filename: string) {
|
||||
|
||||
export const TaxReportPage: React.FC = () => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { flags } = useFeatureFlags();
|
||||
const { format: fmtDate } = useLocalizedDate();
|
||||
const [preset, setPreset] = useState<PeriodPreset>('thisYear');
|
||||
const initialPeriod = useMemo(() => periodForPreset('thisYear'), []);
|
||||
const [from, setFrom] = useState(initialPeriod.from);
|
||||
const [to, setTo] = useState(initialPeriod.to);
|
||||
const [currency, setCurrency] = useState<string>('CHF');
|
||||
const [isExporting, setIsExporting] = useState<'pdf' | 'csv' | null>(null);
|
||||
const [isExporting, setIsExporting] = useState<'pdf' | 'csv' | 'ledger' | null>(null);
|
||||
// Export-only scope (income/cost split). The on-screen report stays complete.
|
||||
const [exportScope, setExportScope] = useState<'all' | 'income' | 'cost'>('all');
|
||||
// Treuhänder (collective-journal) export — same period/currency as the
|
||||
// report; target tool picks the import format (generic / Banana / bexio).
|
||||
const [ledgerFormat, setLedgerFormat] = useState<ExportFormat>('generic');
|
||||
// Unified-ledger sort (#5). Defaults to date ascending — matches the
|
||||
// server-side order so the first paint is stable.
|
||||
const [sort, setSort] = useState<{ key: string; dir: 'asc' | 'desc' }>({ key: 'date', dir: 'asc' });
|
||||
|
||||
const onPresetChange = (next: PeriodPreset) => {
|
||||
setPreset(next);
|
||||
@@ -120,9 +134,10 @@ export const TaxReportPage: React.FC = () => {
|
||||
const handleExport = async (format: 'pdf' | 'csv') => {
|
||||
setIsExporting(format);
|
||||
try {
|
||||
const exportParams = { ...params, scope: exportScope };
|
||||
const { url, filename } = format === 'pdf'
|
||||
? await taxReportService.downloadPdfUrl(params)
|
||||
: await taxReportService.downloadCsvUrl(params);
|
||||
? await taxReportService.downloadPdfUrl(exportParams)
|
||||
: await taxReportService.downloadCsvUrl(exportParams);
|
||||
triggerBrowserDownload(url, filename);
|
||||
} catch (err) {
|
||||
toast.error(t('taxReport.exportFailed', 'Export failed. Please try again.'));
|
||||
@@ -133,6 +148,22 @@ export const TaxReportPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Treuhänder collective-journal export (double-entry postings with account +
|
||||
// VAT codes) — reuses the page's period/currency, adds the target-tool format.
|
||||
const handleLedgerExport = async () => {
|
||||
setIsExporting('ledger');
|
||||
try {
|
||||
const { url, filename } = await ledgerService.downloadExportUrl({ from, to, currency, format: ledgerFormat });
|
||||
triggerBrowserDownload(url, filename);
|
||||
} catch (err: any) {
|
||||
toast.error(err?.response?.data?.error || t('taxReport.exportFailed', 'Export failed. Please try again.'));
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(err);
|
||||
} finally {
|
||||
setIsExporting(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Per maintainer: every CH/LI/DE/AT-based business writes 1'000.00
|
||||
// regardless of document language, so we default to de-CH (the only
|
||||
// Intl locale producing apostrophe thousands). Non-DACH operators
|
||||
@@ -143,7 +174,43 @@ export const TaxReportPage: React.FC = () => {
|
||||
// rates in the period. With a single rate the breakdown is just a
|
||||
// restatement of the grand totals — pure noise.
|
||||
const showPerRateBreakdown = (report?.totalsByVatRate.length || 0) > 1;
|
||||
const exportsDisabled = isLoading || isExporting !== null || !report || report.rows.length === 0;
|
||||
const hasCosts = (report?.costs?.rows.length || 0) > 0;
|
||||
const hasAnyData = !!report && (report.rows.length > 0 || hasCosts);
|
||||
const exportsDisabled = isLoading || isExporting !== null || !hasAnyData;
|
||||
|
||||
// Whether the disclaimer + the "tax treatment" semantics apply: any
|
||||
// cost row in the ledger (type !== 'outgoing').
|
||||
const ledgerHasCosts = !!report && report.ledger.some((r) => r.type !== 'outgoing');
|
||||
|
||||
// Sorted COPY of the ledger. Numeric sort for the signed amount
|
||||
// columns (so income sits above costs ascending), localeCompare for
|
||||
// date / party / type. Toggling a header flips the direction.
|
||||
const sortedLedger = useMemo(() => {
|
||||
if (!report) return [];
|
||||
const copy = [...report.ledger];
|
||||
const { key, dir } = sort;
|
||||
const factor = dir === 'asc' ? 1 : -1;
|
||||
const numericKeys: Record<string, 'netMinor' | 'vatMinor' | 'totalMinor'> = {
|
||||
net: 'netMinor', vat: 'vatMinor', gross: 'totalMinor',
|
||||
};
|
||||
copy.sort((a, b) => {
|
||||
if (key in numericKeys) {
|
||||
const f = numericKeys[key];
|
||||
return (a[f] - b[f]) * factor;
|
||||
}
|
||||
const av = key === 'party' ? a.party : key === 'type' ? a.type : a.date;
|
||||
const bv = key === 'party' ? b.party : key === 'type' ? b.type : b.date;
|
||||
return String(av || '').localeCompare(String(bv || '')) * factor;
|
||||
});
|
||||
return copy;
|
||||
}, [report, sort]);
|
||||
|
||||
const toggleSort = (key: string) => {
|
||||
setSort((prev) => prev.key === key
|
||||
? { key, dir: prev.dir === 'asc' ? 'desc' : 'asc' }
|
||||
: { key, dir: 'asc' });
|
||||
};
|
||||
const sortIndicator = (key: string) => (sort.key === key ? (sort.dir === 'asc' ? ' ▲' : ' ▼') : '');
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -232,8 +299,38 @@ export const TaxReportPage: React.FC = () => {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-end gap-2 pt-1">
|
||||
{/* Export area — two clearly-separated groups so it's obvious
|
||||
what each file is and who it's for: the human-readable Report
|
||||
(PDF/CSV) and the accounting Journal (for the Treuhänder). The
|
||||
Journal group only shows when the accounting layer is on, since
|
||||
it needs the Chart-of-accounts mapping. */}
|
||||
<div className="pt-3 mt-1 border-t border-neutral-200 dark:border-neutral-700 space-y-3">
|
||||
{/* Group 1 — Report (for you) */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1">
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-semibold uppercase tracking-wider text-neutral-500 dark:text-neutral-400">
|
||||
{t('taxReport.export.reportTitle', 'Report')}
|
||||
</div>
|
||||
<div className="text-[11px] text-neutral-400 dark:text-neutral-500">
|
||||
{t('taxReport.export.reportHint', 'Readable list — for your own records.')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<label className="flex items-center gap-1.5 text-xs font-medium text-neutral-500 dark:text-neutral-400">
|
||||
{t('taxReport.export.scopeLabel', 'Scope')}
|
||||
<select
|
||||
value={exportScope}
|
||||
onChange={(e) => setExportScope(e.target.value as 'all' | 'income' | 'cost')}
|
||||
disabled={exportsDisabled}
|
||||
className={`${selectClassName} w-auto min-w-[140px]`}
|
||||
>
|
||||
<option value="all">{t('taxReport.export.scopeAll', 'Complete')}</option>
|
||||
<option value="income">{t('taxReport.export.scopeIncome', 'Income only')}</option>
|
||||
<option value="cost">{t('taxReport.export.scopeCost', 'Cost only')}</option>
|
||||
</select>
|
||||
</label>
|
||||
<Button
|
||||
className="min-w-[150px]"
|
||||
variant="outline"
|
||||
onClick={() => handleExport('csv')}
|
||||
disabled={exportsDisabled}
|
||||
@@ -243,6 +340,7 @@ export const TaxReportPage: React.FC = () => {
|
||||
{t('taxReport.exportCsv', 'Export CSV')}
|
||||
</Button>
|
||||
<Button
|
||||
className="min-w-[150px]"
|
||||
variant="primary"
|
||||
onClick={() => handleExport('pdf')}
|
||||
disabled={exportsDisabled}
|
||||
@@ -253,6 +351,49 @@ export const TaxReportPage: React.FC = () => {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Group 2 — Accounting journal (for your accountant). Solid
|
||||
divider above to separate it from the Report group. */}
|
||||
{flags.accounting && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1 pt-3 border-t border-neutral-200 dark:border-neutral-700">
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-semibold uppercase tracking-wider text-neutral-500 dark:text-neutral-400">
|
||||
{t('taxReport.export.journalTitle', 'Accounting journal')}
|
||||
</div>
|
||||
<div className="text-[11px] text-neutral-400 dark:text-neutral-500">
|
||||
{t('taxReport.ledgerExportHint', 'Double-entry postings for your accountant, mapped via your Chart of accounts.')}{' '}
|
||||
<Link to="/admin/settings?tab=accounting" className="underline hover:text-neutral-600 dark:hover:text-neutral-300">
|
||||
{t('taxReport.ledgerExportConfigure', 'Configure →')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
value={ledgerFormat}
|
||||
onChange={(e) => setLedgerFormat(e.target.value as ExportFormat)}
|
||||
disabled={exportsDisabled}
|
||||
aria-label={t('ledger.export.format', 'Target tool') as string}
|
||||
className={`${selectClassName} min-w-[150px] w-auto`}
|
||||
>
|
||||
{LEDGER_FORMATS.map((f) => (
|
||||
<option key={f} value={f}>{t(`ledger.export.format_${f}`, f)}</option>
|
||||
))}
|
||||
</select>
|
||||
<Button
|
||||
className="min-w-[150px]"
|
||||
variant="outline"
|
||||
onClick={handleLedgerExport}
|
||||
disabled={exportsDisabled}
|
||||
isLoading={isExporting === 'ledger'}
|
||||
leftIcon={<FileSpreadsheet className="w-4 h-4" />}
|
||||
>
|
||||
{t('taxReport.ledgerExport', 'Accountant export')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Totals card (right) — compact summary. Headline is the
|
||||
@@ -260,8 +401,11 @@ export const TaxReportPage: React.FC = () => {
|
||||
there are 2+ rates in the period (otherwise it duplicates
|
||||
the grand totals). Cancelled footnote at the bottom when
|
||||
applicable. */}
|
||||
{report && report.rows.length > 0 && (
|
||||
{hasAnyData && report && (
|
||||
<Card padding="md">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-neutral-500 dark:text-neutral-400 mb-2">
|
||||
{t('taxReport.summary.outgoingTitle', 'Outgoing invoices')}
|
||||
</h2>
|
||||
<div className="space-y-1.5 text-sm">
|
||||
<div className="flex justify-between gap-3">
|
||||
<span className="text-neutral-700 dark:text-neutral-300">{t('taxReport.grandTotalNet', 'Total net')}</span>
|
||||
@@ -283,6 +427,52 @@ export const TaxReportPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Einnahmen-Ausgaben summary (#4): income vs costs vs result.
|
||||
Always shown when the cost side loaded (even with zero costs)
|
||||
so the result/income is visible, not just revenue. Hidden only
|
||||
if the cost side errored (a banner explains that separately). */}
|
||||
{report.summary && !report.costsError && (
|
||||
<div className="mt-4 pt-3 border-t border-neutral-200 dark:border-neutral-700">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-neutral-500 dark:text-neutral-400 mb-2">
|
||||
{t('taxReport.summary.title', 'Income / costs')}
|
||||
</h2>
|
||||
<div className="space-y-1.5 text-sm">
|
||||
<div className="flex justify-between gap-3">
|
||||
<span className="text-neutral-700 dark:text-neutral-300">{t('taxReport.summary.income', 'Income')}</span>
|
||||
<span className="tabular-nums text-emerald-700 dark:text-emerald-400">
|
||||
{formatMinor(report.summary.incomeGrossMinor, report.currency, intlLocale)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between gap-3">
|
||||
<span className="text-neutral-700 dark:text-neutral-300">{t('taxReport.summary.costs', 'Costs')}</span>
|
||||
<span className="tabular-nums text-rose-700 dark:text-rose-400">
|
||||
−{formatMinor(report.summary.costGrossMinor, report.currency, intlLocale)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between gap-3 pt-1.5 border-t border-neutral-200 dark:border-neutral-700">
|
||||
<span className="font-semibold text-neutral-900 dark:text-neutral-100">{t('taxReport.summary.result', 'Result')}</span>
|
||||
<span className="tabular-nums font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{formatMinor(report.summary.resultGrossMinor, report.currency, intlLocale)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between gap-3 text-xs text-neutral-500 dark:text-neutral-400">
|
||||
<span>{t('taxReport.summary.vatPayable', 'VAT payable (output − input)')}</span>
|
||||
<span className="tabular-nums">
|
||||
{report.summary.vatRegistrationConfigured === false || report.summary.vatPayableMinor == null
|
||||
? '—'
|
||||
: formatMinor(report.summary.vatPayableMinor, report.currency, intlLocale)}
|
||||
</span>
|
||||
</div>
|
||||
{report.summary.vatRegistrationConfigured === false && (
|
||||
<p className="flex items-start gap-1.5 text-xs text-amber-600 dark:text-amber-400 pt-1">
|
||||
<AlertCircle className="w-3.5 h-3.5 flex-shrink-0 mt-0.5" />
|
||||
<span>{t('taxReport.summary.vatUnconfigured', 'VAT registration isn’t configured, so VAT payable can’t be computed. Set it under Settings → Accounting.')}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showPerRateBreakdown && (
|
||||
<div className="mt-4 pt-3 border-t border-neutral-200 dark:border-neutral-700">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-neutral-500 dark:text-neutral-400 mb-2">
|
||||
@@ -317,6 +507,19 @@ export const TaxReportPage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Non-fatal: the revenue report loaded but the cost side errored. */}
|
||||
{report?.costsError && (
|
||||
<Card padding="md">
|
||||
<div className="flex items-start gap-3 text-amber-700 dark:text-amber-400">
|
||||
<AlertCircle className="w-5 h-5 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium">{t('taxReport.costsErrorTitle', 'Costs could not be loaded')}</p>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400 mt-1 break-words">{report.costsError}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
{isLoading ? (
|
||||
<Card padding="lg"><Loading /></Card>
|
||||
@@ -335,7 +538,7 @@ export const TaxReportPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
) : !report || report.rows.length === 0 ? (
|
||||
) : !hasAnyData ? (
|
||||
<Card padding="lg">
|
||||
<p className="text-center text-sm text-neutral-600 dark:text-neutral-400">
|
||||
{t('taxReport.empty', 'No invoices in this period.')}
|
||||
@@ -343,9 +546,10 @@ export const TaxReportPage: React.FC = () => {
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
{/* Table — full width below the filter + totals row above.
|
||||
The totals card now lives in the top-right of the page
|
||||
header so this section is purely the invoice list. */}
|
||||
{report && report.ledger.length > 0 && (
|
||||
/* Unified ledger (#5) — one typed, signed, sortable table.
|
||||
Outgoing invoices are positive; incoming invoices + expenses
|
||||
negative so the amount columns net toward the Result. */
|
||||
<Card padding="none">
|
||||
{/* Two nested wrappers: the OUTER clips the header row's
|
||||
solid fill so the top corners stay rounded (matches
|
||||
@@ -359,29 +563,65 @@ export const TaxReportPage: React.FC = () => {
|
||||
<thead className="bg-neutral-50 dark:bg-neutral-900 text-neutral-700 dark:text-neutral-300">
|
||||
<tr>
|
||||
<th className="px-2 py-2 text-right font-medium w-10">#</th>
|
||||
<th className="px-2 py-2 text-left font-medium whitespace-nowrap">{t('taxReport.col.date', 'Date')}</th>
|
||||
<th className="px-2 py-2 text-left font-medium whitespace-nowrap">{t('taxReport.col.invoice', 'Invoice')}</th>
|
||||
<th className="px-2 py-2 text-left font-medium">{t('taxReport.col.customer', 'Customer')}</th>
|
||||
<th className="px-2 py-2 text-left font-medium whitespace-nowrap">
|
||||
<button type="button" onClick={() => toggleSort('type')} className="font-medium hover:text-primary-600 dark:hover:text-primary-400">
|
||||
{t('taxReport.col.type', 'Type')}{sortIndicator('type')}
|
||||
</button>
|
||||
</th>
|
||||
<th className="px-2 py-2 text-left font-medium whitespace-nowrap">
|
||||
<button type="button" onClick={() => toggleSort('date')} className="font-medium hover:text-primary-600 dark:hover:text-primary-400">
|
||||
{t('taxReport.col.date', 'Date')}{sortIndicator('date')}
|
||||
</button>
|
||||
</th>
|
||||
<th className="px-2 py-2 text-left font-medium whitespace-nowrap">{t('taxReport.col.reference', 'Reference')}</th>
|
||||
<th className="px-2 py-2 text-left font-medium">
|
||||
<button type="button" onClick={() => toggleSort('party')} className="font-medium hover:text-primary-600 dark:hover:text-primary-400">
|
||||
{t('taxReport.col.party', 'Customer / supplier')}{sortIndicator('party')}
|
||||
</button>
|
||||
</th>
|
||||
<th className="px-2 py-2 text-left font-medium">{t('taxReport.col.event', 'Event')}</th>
|
||||
<th className="px-2 py-2 text-right font-medium whitespace-nowrap">{t('taxReport.col.vatRate', 'VAT %')}</th>
|
||||
<th className="px-2 py-2 text-right font-medium whitespace-nowrap">{t('taxReport.col.net', 'Net')}</th>
|
||||
<th className="px-2 py-2 text-right font-medium whitespace-nowrap">{t('taxReport.col.vat', 'VAT')}</th>
|
||||
<th className="px-2 py-2 text-right font-medium whitespace-nowrap">{t('taxReport.col.total', 'Gross')}</th>
|
||||
<th className="px-2 py-2 text-left font-medium whitespace-nowrap">{t('taxReport.col.tax', 'Tax')}</th>
|
||||
<th className="px-2 py-2 text-right font-medium whitespace-nowrap">
|
||||
<button type="button" onClick={() => toggleSort('net')} className="font-medium hover:text-primary-600 dark:hover:text-primary-400">
|
||||
{t('taxReport.col.net', 'Net')}{sortIndicator('net')}
|
||||
</button>
|
||||
</th>
|
||||
<th className="px-2 py-2 text-right font-medium whitespace-nowrap">
|
||||
<button type="button" onClick={() => toggleSort('vat')} className="font-medium hover:text-primary-600 dark:hover:text-primary-400">
|
||||
{t('taxReport.col.vat', 'VAT')}{sortIndicator('vat')}
|
||||
</button>
|
||||
</th>
|
||||
<th className="px-2 py-2 text-right font-medium whitespace-nowrap">
|
||||
<button type="button" onClick={() => toggleSort('gross')} className="font-medium hover:text-primary-600 dark:hover:text-primary-400">
|
||||
{t('taxReport.col.total', 'Gross')}{sortIndicator('gross')}
|
||||
</button>
|
||||
</th>
|
||||
<th className="px-2 py-2 text-right font-medium whitespace-nowrap">{t('taxReport.col.skonto', 'Skonto')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-neutral-200 dark:divide-neutral-800">
|
||||
{report.rows.map((row, i) => (
|
||||
{sortedLedger.map((row, i) => (
|
||||
<tr
|
||||
key={row.id}
|
||||
key={row.key}
|
||||
className={row.isCancelled
|
||||
? 'text-neutral-400 dark:text-neutral-500 italic'
|
||||
: 'text-neutral-900 dark:text-neutral-100'}
|
||||
>
|
||||
<td className="px-2 py-1.5 text-right tabular-nums">{i + 1}</td>
|
||||
<td className="px-2 py-1.5 whitespace-nowrap tabular-nums">{fmtDate(row.issueDate.slice(0, 10))}</td>
|
||||
<td className="px-2 py-1.5 whitespace-nowrap">
|
||||
<span className="font-medium">{row.invoiceNumber}</span>
|
||||
<span className={`inline-block px-1.5 py-0.5 text-[10px] uppercase tracking-wider rounded font-semibold not-italic ${
|
||||
row.type === 'outgoing'
|
||||
? 'bg-teal-100 text-teal-800 dark:bg-teal-900/40 dark:text-teal-300'
|
||||
: row.type === 'incoming'
|
||||
? 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/40 dark:text-indigo-300'
|
||||
: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300'
|
||||
}`}>
|
||||
{t(`taxReport.type.${row.type}`, row.type)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-2 py-1.5 whitespace-nowrap tabular-nums">{fmtDate(String(row.date).slice(0, 10))}</td>
|
||||
<td className="px-2 py-1.5 whitespace-nowrap">
|
||||
<span className="font-medium">{row.reference}</span>
|
||||
{row.isCancelled && (
|
||||
<span className="ml-2 inline-block px-1.5 py-0.5 text-[10px] uppercase tracking-wider rounded bg-neutral-200 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 font-semibold not-italic">
|
||||
{t('taxReport.statusCancelled', 'Cancelled')}
|
||||
@@ -389,35 +629,38 @@ export const TaxReportPage: React.FC = () => {
|
||||
)}
|
||||
{/* Storno + Reissue lineage markers — parity
|
||||
with the admin invoices list so the same
|
||||
colour scheme distinguishes the three row
|
||||
kinds at a glance across both surfaces. */}
|
||||
colour scheme distinguishes the row kinds at
|
||||
a glance across both surfaces. */}
|
||||
{row.kind === 'storno' && (
|
||||
<span className="ml-2 inline-block px-1.5 py-0.5 text-[10px] uppercase tracking-wider rounded bg-purple-100 text-purple-800 font-semibold not-italic">
|
||||
<span className="ml-2 inline-block px-1.5 py-0.5 text-[10px] uppercase tracking-wider rounded bg-purple-100 dark:bg-purple-900/40 text-purple-800 dark:text-purple-300 font-semibold not-italic">
|
||||
{t('bills.kind.storno', 'Storno')}
|
||||
</span>
|
||||
)}
|
||||
{row.isReissue && (
|
||||
<span className="ml-2 inline-block px-1.5 py-0.5 text-[10px] uppercase tracking-wider rounded bg-blue-100 text-blue-800 font-semibold not-italic">
|
||||
<span className="ml-2 inline-block px-1.5 py-0.5 text-[10px] uppercase tracking-wider rounded bg-blue-100 dark:bg-blue-900/40 text-blue-800 dark:text-blue-300 font-semibold not-italic">
|
||||
{t('bills.kind.reissue', 'Reissue')}
|
||||
</span>
|
||||
)}
|
||||
{row.replacedByInvoiceNumber && (
|
||||
<span className="ml-1 text-xs text-neutral-500 dark:text-neutral-400 not-italic">
|
||||
→ {row.replacedByInvoiceNumber}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2 py-1.5 truncate max-w-[180px]" title={row.customerLabel}>{row.customerLabel}</td>
|
||||
<td className="px-2 py-1.5 truncate max-w-[180px]" title={row.eventName}>{row.eventName}</td>
|
||||
<td className="px-2 py-1.5 text-right tabular-nums whitespace-nowrap">{Number(row.vatRate).toFixed(1)}%</td>
|
||||
<td className="px-2 py-1.5 text-right tabular-nums whitespace-nowrap">
|
||||
{formatMinor(row.netMinor, row.currency, intlLocale)}
|
||||
<td className="px-2 py-1.5 truncate max-w-[180px]" title={row.party}>{row.party}</td>
|
||||
<td className="px-2 py-1.5 truncate max-w-[180px]" title={row.eventName}>
|
||||
{row.eventName || (row.type !== 'outgoing'
|
||||
? <span className="text-neutral-400 dark:text-neutral-500">{t('taxReport.cost.company', 'Company')}</span>
|
||||
: '')}
|
||||
</td>
|
||||
<td className="px-2 py-1.5 whitespace-nowrap">
|
||||
{row.type === 'outgoing'
|
||||
? <span className="tabular-nums">{Number(row.vatRate).toFixed(1)}%</span>
|
||||
: <span className="text-xs text-neutral-500 dark:text-neutral-400">{row.taxTreatment}</span>}
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-right tabular-nums whitespace-nowrap">
|
||||
{formatMinor(row.vatMinor, row.currency, intlLocale)}
|
||||
{formatMinor(row.netMinor, report.currency, intlLocale)}
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-right tabular-nums whitespace-nowrap">
|
||||
{formatMinor(row.vatMinor, report.currency, intlLocale)}
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-right tabular-nums whitespace-nowrap font-medium">
|
||||
{formatMinor(row.totalMinor, row.currency, intlLocale)}
|
||||
{formatMinor(row.totalMinor, report.currency, intlLocale)}
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-right tabular-nums whitespace-nowrap"
|
||||
title={row.skontoApplied
|
||||
@@ -425,7 +668,7 @@ export const TaxReportPage: React.FC = () => {
|
||||
: undefined}>
|
||||
{row.skontoApplied ? (
|
||||
<span className="text-teal-700 dark:text-teal-300">
|
||||
−{formatMinor(row.skontoAmountMinor, row.currency, intlLocale)}
|
||||
−{formatMinor(row.skontoAmountMinor, report.currency, intlLocale)}
|
||||
</span>
|
||||
) : ''}
|
||||
</td>
|
||||
@@ -436,6 +679,23 @@ export const TaxReportPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Legal disclaimer — tax figures are a guideline. Per project
|
||||
rule: any surface touching tax/financial output must point
|
||||
the user at a professional. Shown whenever the ledger holds
|
||||
any cost row. */}
|
||||
{ledgerHasCosts && (
|
||||
<p className="flex items-start gap-2 text-xs text-neutral-500 dark:text-neutral-400">
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0 mt-0.5" />
|
||||
<span>
|
||||
{t(
|
||||
'taxReport.costsDisclaimer',
|
||||
'This income/expense overview is a guideline for your records (Einnahmen-Ausgaben-Rechnung). VAT reclaimability and the result figure depend on each cost’s tax treatment — verify with your Treuhänder / tax authority before filing.',
|
||||
)}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -243,10 +243,10 @@ export const BlockLibraryPage: React.FC = () => {
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
{t('contracts.blocks.back', 'Back to contracts')}
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold flex-1 text-theme">
|
||||
<h1 className="text-2xl font-bold flex-1 text-neutral-900 dark:text-neutral-100">
|
||||
{t('contracts.blocks.title', 'Contract block library')}
|
||||
</h1>
|
||||
<label className="flex items-center gap-2 text-sm text-muted-theme">
|
||||
<label className="flex items-center gap-2 text-sm text-neutral-500 dark:text-neutral-400">
|
||||
<input type="checkbox" checked={hideInactive} onChange={(e) => setHideInactive(e.target.checked)} />
|
||||
{t('contracts.blocks.hideInactive', 'Hide inactive')}
|
||||
</label>
|
||||
@@ -340,7 +340,7 @@ export const BlockLibraryPage: React.FC = () => {
|
||||
);
|
||||
})}
|
||||
{blocks.length === 0 && (
|
||||
<p className="text-center text-sm text-muted-theme py-6">
|
||||
<p className="text-center text-sm text-neutral-500 dark:text-neutral-400 py-6">
|
||||
{t('contracts.blocks.empty', 'No blocks yet.')}
|
||||
</p>
|
||||
)}
|
||||
@@ -352,7 +352,7 @@ export const BlockLibraryPage: React.FC = () => {
|
||||
<div className="lg:col-span-2">
|
||||
{selection === null ? (
|
||||
<Card padding="md">
|
||||
<p className="text-center text-muted-theme py-8">
|
||||
<p className="text-center text-neutral-500 dark:text-neutral-400 py-8">
|
||||
{t('contracts.blocks.selectPrompt', 'Select a block on the left or create a new one to start editing.')}
|
||||
</p>
|
||||
</Card>
|
||||
@@ -366,7 +366,7 @@ export const BlockLibraryPage: React.FC = () => {
|
||||
</h3>
|
||||
<div className="flex gap-2 items-center flex-wrap">
|
||||
{selection.mode === 'edit' && (
|
||||
<label className="flex items-center gap-2 text-sm text-muted-theme mr-2">
|
||||
<label className="flex items-center gap-2 text-sm text-neutral-500 dark:text-neutral-400 mr-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selection.block.isActive}
|
||||
|
||||
@@ -590,7 +590,7 @@ export const ContractDetailPage: React.FC = () => {
|
||||
{c.convertedEventId && (
|
||||
<Card padding="md" className="mb-4">
|
||||
<p className="text-sm">
|
||||
<span className="text-muted-theme mr-2">
|
||||
<span className="text-neutral-500 dark:text-neutral-400 mr-2">
|
||||
{t('contracts.detail.convertedToEvent', 'Converted to event')}:
|
||||
</span>
|
||||
<Link to={`/admin/events/${c.convertedEventId}`} className="font-medium text-primary-600 dark:text-primary-400 hover:underline">
|
||||
|
||||
@@ -67,7 +67,7 @@ export const ContractsListPage: React.FC = () => {
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-2xl font-bold text-theme">{t('contracts.title', 'Contracts')}</h1>
|
||||
<h1 className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">{t('contracts.title', 'Contracts')}</h1>
|
||||
{/* Beta badge — matches Customers + Quotes + Invoices. */}
|
||||
<span
|
||||
className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded font-semibold bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300"
|
||||
@@ -76,7 +76,7 @@ export const ContractsListPage: React.FC = () => {
|
||||
{t('navigation.betaTag', 'Beta')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-theme mt-1">
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-1">
|
||||
{t('contracts.subtitle', 'Compose contracts from reusable blocks and have customers sign in-browser or upload a wet-signed PDF.')}
|
||||
</p>
|
||||
</div>
|
||||
@@ -129,7 +129,7 @@ export const ContractsListPage: React.FC = () => {
|
||||
single-card layout used by Customers / Quotes / Invoices. */}
|
||||
<div className="mt-4">
|
||||
{isLoading ? <Loading /> : !data || data.contracts.length === 0 ? (
|
||||
<p className="text-center text-muted-theme py-8">{t('contracts.list.empty', 'No contracts yet.')}</p>
|
||||
<p className="text-center text-neutral-500 dark:text-neutral-400 py-8">{t('contracts.list.empty', 'No contracts yet.')}</p>
|
||||
) : (
|
||||
<div className="rounded-lg border border-neutral-200 dark:border-neutral-700 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
@@ -179,7 +179,7 @@ export const ContractsListPage: React.FC = () => {
|
||||
</div>
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-between items-center px-3 py-2 border-t border-neutral-200 dark:border-neutral-700 text-sm">
|
||||
<span className="text-muted-theme">
|
||||
<span className="text-neutral-500 dark:text-neutral-400">
|
||||
{t('contracts.list.pagination', 'Page {{page}} of {{total}} · {{count}} contracts', {
|
||||
page, total: totalPages, count: data.total,
|
||||
})}
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
import { LineItemsTable, type EditableLineItem } from '../../../components/admin/LineItemsTable';
|
||||
import { CustomerPicker } from '../../../components/admin/CustomerPicker';
|
||||
import { ProjectSelect } from '../../../components/admin/ProjectSelect';
|
||||
import { VatRateSelect } from '../../../components/admin/VatRateSelect';
|
||||
import { InstallmentsPanel } from '../../../components/admin/InstallmentsPanel';
|
||||
import { customerAdminService } from '../../../services/customerAdmin.service';
|
||||
import { userManagementService } from '../../../services/userManagement.service';
|
||||
@@ -54,6 +55,8 @@ interface FormState {
|
||||
paymentNetDaysTemplateId: number | null;
|
||||
paymentTimingTemplateId: number | null;
|
||||
vatRate: number;
|
||||
/** Migration 130 — snapshot of the chosen output VAT code (null = custom rate). */
|
||||
vatCode: string | null;
|
||||
shippingAmount: number;
|
||||
introText: string;
|
||||
outroText: string;
|
||||
@@ -85,6 +88,7 @@ const empty: FormState = {
|
||||
paymentNetDaysTemplateId: null,
|
||||
paymentTimingTemplateId: null,
|
||||
vatRate: 0,
|
||||
vatCode: null,
|
||||
shippingAmount: 0,
|
||||
introText: '',
|
||||
outroText: '',
|
||||
@@ -121,6 +125,7 @@ function buildPayload(f: FormState): QuoteCreatePayload {
|
||||
// installments on the snapshot. Sent only when populated.
|
||||
installments: f.installments && f.installments.length > 0 ? f.installments : undefined,
|
||||
vatRate: f.vatRate,
|
||||
vatCode: f.vatCode,
|
||||
shippingAmountMinor: toMinor(f.shippingAmount),
|
||||
introText: f.introText || undefined,
|
||||
outroText: f.outroText || undefined,
|
||||
@@ -214,6 +219,7 @@ export const QuoteEditorPage: React.FC = () => {
|
||||
paymentNetDaysTemplateId: q.paymentNetDaysTemplateId,
|
||||
paymentTimingTemplateId: q.paymentTimingTemplateId,
|
||||
vatRate: Number(q.vatRate || 0),
|
||||
vatCode: (q as { vatCode?: string | null }).vatCode ?? null,
|
||||
shippingAmount: Number(q.shippingAmountMinor || 0) / 100,
|
||||
introText: q.introText || '',
|
||||
outroText: q.outroText || '',
|
||||
@@ -533,9 +539,11 @@ export const QuoteEditorPage: React.FC = () => {
|
||||
onChange={(items) => setForm((f) => ({ ...f, lineItems: items }))}
|
||||
/>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 mt-4">
|
||||
<Input type="number" step="0.1" label={t('quotes.field.vatRate', 'VAT rate %') as string}
|
||||
value={form.vatRate}
|
||||
onChange={(e) => setForm((f) => ({ ...f, vatRate: Number(e.target.value) }))} />
|
||||
<VatRateSelect
|
||||
label={t('quotes.field.vatRate', 'VAT rate %') as string}
|
||||
rate={form.vatRate}
|
||||
code={form.vatCode}
|
||||
onChange={(rate, code) => setForm((f) => ({ ...f, vatRate: rate, vatCode: code }))} />
|
||||
<Input type="number" step="0.01" label={t('quotes.field.shipping', 'Shipping amount') as string}
|
||||
value={form.shippingAmount}
|
||||
onChange={(e) => setForm((f) => ({ ...f, shippingAmount: Number(e.target.value) }))} />
|
||||
|
||||
@@ -53,7 +53,7 @@ export const QuotesListPage: React.FC = () => {
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-2xl font-bold text-theme">{t('quotes.title', 'Quotes')}</h1>
|
||||
<h1 className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">{t('quotes.title', 'Quotes')}</h1>
|
||||
{/* Beta badge — feature is functional but the surface is
|
||||
still evolving (matches Customers + Invoices). */}
|
||||
<span
|
||||
@@ -63,7 +63,7 @@ export const QuotesListPage: React.FC = () => {
|
||||
{t('navigation.betaTag', 'Beta')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-theme mt-1">
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-1">
|
||||
{t('quotes.subtitle', 'Send, track and convert quotes into events.')}
|
||||
</p>
|
||||
</div>
|
||||
@@ -104,7 +104,7 @@ export const QuotesListPage: React.FC = () => {
|
||||
single-card layout used by Customers/Invitations. */}
|
||||
<div className="mt-4">
|
||||
{isLoading ? <Loading /> : !data || data.quotes.length === 0 ? (
|
||||
<p className="text-center text-muted-theme py-8">{t('quotes.empty', 'No quotes yet.')}</p>
|
||||
<p className="text-center text-neutral-500 dark:text-neutral-400 py-8">{t('quotes.empty', 'No quotes yet.')}</p>
|
||||
) : (
|
||||
<div className="rounded-lg border border-neutral-200 dark:border-neutral-700 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
@@ -147,7 +147,7 @@ export const QuotesListPage: React.FC = () => {
|
||||
</div>
|
||||
{data.pagination.totalPages > 1 && (
|
||||
<div className="flex justify-between items-center px-3 py-2 border-t border-neutral-200 dark:border-neutral-700 text-sm">
|
||||
<span className="text-muted-theme">
|
||||
<span className="text-neutral-500 dark:text-neutral-400">
|
||||
{t('quotes.pagination', 'Page {{page}} of {{total}} · {{count}} quotes', {
|
||||
page: data.pagination.page, total: data.pagination.totalPages, count: data.pagination.total,
|
||||
})}
|
||||
|
||||
@@ -177,7 +177,7 @@ export const CrmSettingsPage: React.FC = () => {
|
||||
|
||||
{!anySection && (
|
||||
<Card>
|
||||
<p className="text-sm text-muted-theme">
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
{t('crmSettings.emptyState',
|
||||
'No CRM features are currently enabled. Turn on Quotes, Invoices, or Contracts in Settings → Features to see the matching configuration here.')}
|
||||
</p>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user