Merge pull request #622 from Luca-Timo/feat/accounting-inbound-invoices

Accounting suite + CRM hardening
This commit is contained in:
Paul Nothaft
2026-06-17 22:17:22 +02:00
committed by GitHub
110 changed files with 8828 additions and 479 deletions
+8 -2
View File
@@ -134,7 +134,10 @@ jobs:
platforms: ${{ matrix.platform }} platforms: ${{ matrix.platform }}
labels: ${{ steps.meta-backend.outputs.labels }} labels: ${{ steps.meta-backend.outputs.labels }}
cache-from: type=gha,scope=backend-${{ env.PLATFORM_PAIR }} 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' }} 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: | build-args: |
CACHEBUST=${{ github.run_number }} CACHEBUST=${{ github.run_number }}
@@ -360,7 +363,10 @@ jobs:
platforms: ${{ matrix.platform }} platforms: ${{ matrix.platform }}
labels: ${{ steps.meta-frontend.outputs.labels }} labels: ${{ steps.meta-frontend.outputs.labels }}
cache-from: type=gha,scope=frontend-${{ env.PLATFORM_PAIR }} 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' }} 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: | build-args: |
CACHEBUST=${{ github.run_number }} CACHEBUST=${{ github.run_number }}
+2 -1
View File
@@ -61,7 +61,8 @@ jobs:
load: true load: true
tags: picpeak-backend:smoke tags: picpeak-backend:smoke
cache-from: type=gha,scope=install-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 - name: Create Docker network
run: docker network create picpeak-smoke run: docker network create picpeak-smoke
+3
View File
@@ -129,3 +129,6 @@ docker-compose.dev.yml
# New layout development files # New layout development files
new-layouts/ new-layouts/
# Generated CRM/accounting documents (runtime) — never commit
backend/storage/business-docs/
+25 -6
View File
@@ -68,6 +68,14 @@ Unlike expensive SaaS solutions, PicPeak gives you:
- 🛡️ **Security First** - JWT auth, rate limiting, CORS protection - 🛡️ **Security First** - JWT auth, rate limiting, CORS protection
- 📈 **Scalable** - From small studios to large agencies - 📈 **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 ## 🚀 Quick Start
Get PicPeak running in under 5 minutes: 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 | | 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 | | **Simple Deployment Script** | One-click deployment script for quick server setup with automated configuration and dependency installation | 🧪 Beta |
### 📋 Future Enhancements ### 📋 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: 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. - [**@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. 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. 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 The CRM & accounting modules (contracts, invoices, QR-bills, the tax
that is intended as a **starting point only**: 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, - **Contract blocks** (image rights, NDA, model release, cancellation,
jurisdiction, …) are written by the maintainer, **not by a lawyer**. 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 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 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. 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 Read [`docs/crm-disclaimers.md`](docs/crm-disclaimers.md) before
enabling the Contracts or Invoices features. enabling the Contracts, Invoices or Accounting features.
## 📄 License ## 📄 License
+6 -1
View File
@@ -50,8 +50,13 @@ RUN npm install -g npm@10
# Unicode fallback; picpeak's own brand fonts (assets/fonts/, the same files # 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 # PDFKit + the web UI use) are registered with fontconfig further down so the
# logo's text renders in its actual typeface, not a fallback. # 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 \ 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 fc-cache -f
# Create non-root user # 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 // of permissions, so for happy-path tests we flip every CRM flag
// on. Negative tests (no-token, bad-signature) hit adminAuth // on. Negative tests (no-token, bad-signature) hit adminAuth
// first and never reach the flag check, so they're unaffected. // 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) { for (const key of crmFlags) {
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
await db('feature_flags').where({ key }).update({ value: 1 }); 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' });
});
});
+23 -11
View File
@@ -22,19 +22,26 @@ function makeChain(initialRows) {
}, },
leftJoin: jest.fn(function () { return this; }), leftJoin: jest.fn(function () { return this; }),
where: jest.fn(function () { return this; }), where: jest.fn(function () { return this; }),
whereNot: jest.fn(function () { return this; }),
whereIn: jest.fn(function () { return this; }), whereIn: jest.fn(function () { return this; }),
whereNotIn: jest.fn(function () { return this; }),
whereBetween: jest.fn(function () { return this; }), whereBetween: jest.fn(function () { return this; }),
whereRaw: jest.fn(function () { return this; }),
orderBy: 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); }), select: jest.fn(function () { return Promise.resolve(this._rows); }),
}; };
} }
const mockDbFn = jest.fn((tableName) => { const mockDbFn = jest.fn((tableName) => {
callCount += 1;
// Route by table name when supplied — the Skonto aggregate (added // Route by table name when supplied — the Skonto aggregate (added
// by migration 126) queries `invoice_payment_log`; everything else // by migration 126) queries `invoice_payment_log`; the #4 cost side
// (main listing, replacements lookup) hits `invoices`. // queries `inbound_documents` + `expenses`; everything else (main
// listing, replacements lookup) hits `invoices`.
if (tableName === 'invoice_payment_log') return makeChain([]); 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); if (callCount === 1) return makeChain(invoiceRowsForRun);
return makeChain(replacementsRowsForRun); return makeChain(replacementsRowsForRun);
}); });
@@ -42,6 +49,10 @@ const mockDbFn = jest.fn((tableName) => {
// COALESCE (migration 123). The chain's select() ignores its // COALESCE (migration 123). The chain's select() ignores its
// arguments so the raw() return value just needs to exist. // arguments so the raw() return value just needs to exist.
mockDbFn.raw = jest.fn((sql) => sql); 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', () => ({ jest.mock('../../src/database/db', () => ({
db: mockDbFn, db: mockDbFn,
@@ -182,8 +193,11 @@ describe('renderTaxReportCsv', () => {
expect(contentType).toMatch(/text\/csv/); expect(contentType).toMatch(/text\/csv/);
expect(filename).toBe('tax_report_2026-01-01_to_2026-03-31_CHF.csv'); expect(filename).toBe('tax_report_2026-01-01_to_2026-03-31_CHF.csv');
const lines = content.split('\r\n'); const lines = content.split('\r\n');
expect(lines[0]).toContain('Rechnung'); // de header for tax_col_invoice // Unified ledger CSV: Typ / Referenz / Kunde-Lieferant columns replace the
expect(lines[0]).toContain('Kunde'); // 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'); expect(lines[0]).toContain('Netto');
}); });
@@ -223,19 +237,17 @@ describe('renderTaxReportCsv', () => {
expect(content).toMatch(/"161\.55"/); 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 = [ invoiceRowsForRun = [
SAMPLE_ROW({ status: 'cancelled' }), SAMPLE_ROW({ status: 'cancelled' }),
]; ];
const { content } = await taxReportService.renderTaxReportCsv({ const { content } = await taxReportService.renderTaxReportCsv({
from: '2026-01-01', to: '2026-03-31', currency: 'CHF', locale: 'en', from: '2026-01-01', to: '2026-03-31', currency: 'CHF', locale: 'en',
}); });
// Migration 126 added a trailing Skonto column. The cancelled // Unified ledger CSV has no separate cancelled column — a cancelled row is
// marker is now second-to-last; the Skonto cell is empty for // flagged by appending the localised "(Cancelled)" tag to its Reference.
// non-Skonto rows. Asserting on a regex keeps the test stable
// against future trailing-column additions.
const dataRow = content.split('\r\n')[1]; 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 () => { it('uses CRLF line endings (RFC 4180) and BOM-free body', async () => {
@@ -22,6 +22,9 @@
let invoiceRowsForRun = []; let invoiceRowsForRun = [];
let replacementsRowsForRun = []; let replacementsRowsForRun = [];
let inboundRowsForRun = [];
let expenseRowsForRun = [];
let costTablesPresent = false;
let callCount = 0; let callCount = 0;
function makeChain(initialRows) { function makeChain(initialRows) {
@@ -32,24 +35,35 @@ function makeChain(initialRows) {
}, },
leftJoin: jest.fn(function () { return this; }), leftJoin: jest.fn(function () { return this; }),
where: jest.fn(function () { return this; }), where: jest.fn(function () { return this; }),
whereNot: jest.fn(function () { return this; }),
whereIn: jest.fn(function () { return this; }), whereIn: jest.fn(function () { return this; }),
whereNotIn: jest.fn(function () { return this; }),
whereBetween: jest.fn(function () { return this; }), whereBetween: jest.fn(function () { return this; }),
whereRaw: jest.fn(function () { return this; }),
orderBy: 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); }), select: jest.fn(function () { return Promise.resolve(this._rows); }),
}; };
return c; return c;
} }
const mockDbFn = jest.fn((tableName) => { const mockDbFn = jest.fn((tableName) => {
callCount += 1;
// Migration 126 added a Skonto aggregate that hits // Migration 126 added a Skonto aggregate that hits
// `invoice_payment_log` — route those explicitly to an empty list so // `invoice_payment_log` — route those explicitly to an empty list so
// the test surface stays focused on the invoices/replacements flow. // the test surface stays focused on the invoices/replacements flow.
if (tableName === 'invoice_payment_log') return makeChain([]); 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); if (callCount === 1) return makeChain(invoiceRowsForRun);
return makeChain(replacementsRowsForRun); 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 // `.raw()` is used in the .select() column list for the event_name
// COALESCE (migration 123). The chain's select() ignores its // COALESCE (migration 123). The chain's select() ignores its
// arguments and returns the mocked rows, so the raw() return value // arguments and returns the mocked rows, so the raw() return value
@@ -67,6 +81,9 @@ const { grossUpLateFee, computeReportedAmounts, buildCustomerLabel } = taxReport
beforeEach(() => { beforeEach(() => {
invoiceRowsForRun = []; invoiceRowsForRun = [];
replacementsRowsForRun = []; replacementsRowsForRun = [];
inboundRowsForRun = [];
expenseRowsForRun = [];
costTablesPresent = false;
callCount = 0; callCount = 0;
mockDbFn.mockClear(); mockDbFn.mockClear();
}); });
@@ -335,4 +352,180 @@ describe('getTaxReport', () => {
expect(out.totalsByVatRate).toEqual([]); expect(out.totalsByVatRate).toEqual([]);
expect(out.cancelledCount).toBe(0); 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_/);
});
}); });
+19
View File
@@ -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');
});
}
};
+395 -12
View File
@@ -1,12 +1,12 @@
{ {
"name": "picpeak-backend", "name": "picpeak-backend",
"version": "3.47.2-beta.0", "version": "3.60.6-beta.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "picpeak-backend", "name": "picpeak-backend",
"version": "3.47.2-beta.0", "version": "3.60.6-beta.0",
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.850.0", "@aws-sdk/client-s3": "^3.850.0",
"@aws-sdk/lib-storage": "^3.850.0", "@aws-sdk/lib-storage": "^3.850.0",
@@ -29,11 +29,13 @@
"i18next": "25.3.2", "i18next": "25.3.2",
"i18next-browser-languagedetector": "^8.2.0", "i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2", "i18next-http-backend": "^3.0.2",
"imapflow": "^1.4.0",
"ipaddr.js": "^2.3.0", "ipaddr.js": "^2.3.0",
"joi": "^17.9.1", "joi": "^17.9.1",
"js-yaml": "^4.1.1", "js-yaml": "^4.1.1",
"jsonwebtoken": "^9.0.0", "jsonwebtoken": "^9.0.0",
"knex": "^2.4.2", "knex": "^2.4.2",
"mailparser": "^3.9.9",
"mime-types": "^3.0.1", "mime-types": "^3.0.1",
"multer": "^2.0.2", "multer": "^2.0.2",
"node-cron": "^3.0.2", "node-cron": "^3.0.2",
@@ -2725,6 +2727,12 @@
"pako": "^1.0.10" "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": { "node_modules/@scarf/scarf": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz",
@@ -2732,6 +2740,22 @@
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0" "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": { "node_modules/@sideway/address": {
"version": "4.1.5", "version": "4.1.5",
"resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz",
@@ -3722,6 +3746,17 @@
"dev": true, "dev": true,
"license": "ISC" "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": { "node_modules/abbrev": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
@@ -4047,6 +4082,15 @@
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT" "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": { "node_modules/axios": {
"version": "1.15.2", "version": "1.15.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz", "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz",
@@ -5087,6 +5131,15 @@
"node": ">=0.10.0" "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": { "node_modules/delayed-stream": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
@@ -5329,6 +5382,15 @@
"node": ">= 0.8" "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": { "node_modules/end-of-stream": {
"version": "1.4.5", "version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
@@ -6531,6 +6593,15 @@
"node": ">= 0.4" "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": { "node_modules/helmet": {
"version": "7.2.0", "version": "7.2.0",
"resolved": "https://registry.npmjs.org/helmet/-/helmet-7.2.0.tgz", "resolved": "https://registry.npmjs.org/helmet/-/helmet-7.2.0.tgz",
@@ -6556,6 +6627,56 @@
"void-elements": "3.1.0" "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": { "node_modules/htmlparser2": {
"version": "8.0.2", "version": "8.0.2",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
@@ -6700,6 +6821,22 @@
"cross-fetch": "4.1.0" "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": { "node_modules/ieee754": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@@ -6737,6 +6874,23 @@
"dev": true, "dev": true,
"license": "ISC" "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": { "node_modules/import-fresh": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "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", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
"license": "MIT", "license": "MIT",
"optional": true,
"engines": { "engines": {
"node": ">= 12" "node": ">= 12"
} }
@@ -7972,6 +8125,15 @@
"safe-buffer": "~5.1.0" "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": { "node_modules/leven": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
@@ -7996,6 +8158,30 @@
"node": ">= 0.8.0" "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": { "node_modules/linebreak": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz", "resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
@@ -8022,6 +8208,25 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/locate-path": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@@ -8164,6 +8369,24 @@
"yallist": "^3.0.2" "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": { "node_modules/make-dir": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
@@ -8836,9 +9059,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/nodemailer": { "node_modules/nodemailer": {
"version": "8.0.7", "version": "8.0.10",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.7.tgz", "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.10.tgz",
"integrity": "sha512-pkjE4mkBzQjdJT4/UmlKl3pX0rC9fZmjh7c6C9o7lv66Ac6w9WCnzPzhbPNxwZAzlF4mdq4CSWB5+FbK6FWCow==", "integrity": "sha512-BLFuSth7QtHOkBzyqTehWWyub0NTRDuK2Q2SQfnGLsrJnzyU+Yeh4WpV1eZGuARFj1xQJHIdnTuJZLP+b9R1GQ==",
"license": "MIT-0", "license": "MIT-0",
"engines": { "engines": {
"node": ">=6.0.0" "node": ">=6.0.0"
@@ -9036,6 +9259,15 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/on-finished": {
"version": "2.4.1", "version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@@ -9214,6 +9446,19 @@
"integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==", "integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==",
"license": "MIT" "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": { "node_modules/parseurl": {
"version": "1.3.3", "version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -9318,6 +9563,15 @@
"png-js": "^1.0.0" "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": { "node_modules/pg": {
"version": "8.16.3", "version": "8.16.3",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
@@ -9432,6 +9686,43 @@
"url": "https://github.com/sponsors/jonschlinkert" "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": { "node_modules/pirates": {
"version": "4.0.7", "version": "4.0.7",
"resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
@@ -9665,6 +9956,22 @@
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT" "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": { "node_modules/promise-inflight": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz",
@@ -9758,6 +10065,15 @@
"node": ">=6" "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": { "node_modules/pure-rand": {
"version": "6.1.0", "version": "6.1.0",
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
@@ -9966,6 +10282,12 @@
], ],
"license": "MIT" "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": { "node_modules/range-parser": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
@@ -10078,6 +10400,15 @@
"url": "https://paulmillr.com/funding/" "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": { "node_modules/rechoir": {
"version": "0.8.0", "version": "0.8.0",
"resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz", "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz",
@@ -10285,6 +10616,18 @@
"postcss": "^8.3.11" "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": { "node_modules/semver": {
"version": "6.3.1", "version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "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", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
"license": "MIT", "license": "MIT",
"optional": true,
"engines": { "engines": {
"node": ">= 6.0.0", "node": ">= 6.0.0",
"npm": ">= 3.0.0" "npm": ">= 3.0.0"
} }
}, },
"node_modules/socks": { "node_modules/socks": {
"version": "2.8.7", "version": "2.8.9",
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz",
"integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==",
"license": "MIT", "license": "MIT",
"optional": true,
"dependencies": { "dependencies": {
"ip-address": "^10.0.1", "ip-address": "^10.1.1",
"smart-buffer": "^4.2.0" "smart-buffer": "^4.2.0"
}, },
"engines": { "engines": {
@@ -10664,6 +11005,15 @@
"node": ">= 10" "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": { "node_modules/source-map": {
"version": "0.6.1", "version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
@@ -11284,6 +11634,24 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/tildify": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/tildify/-/tildify-2.0.0.tgz", "resolved": "https://registry.npmjs.org/tildify/-/tildify-2.0.0.tgz",
@@ -11299,6 +11667,15 @@
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
"license": "MIT" "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": { "node_modules/tmpl": {
"version": "1.0.5", "version": "1.0.5",
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
@@ -11447,6 +11824,12 @@
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"license": "MIT" "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": { "node_modules/undefsafe": {
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
+2
View File
@@ -35,11 +35,13 @@
"i18next": "25.3.2", "i18next": "25.3.2",
"i18next-browser-languagedetector": "^8.2.0", "i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2", "i18next-http-backend": "^3.0.2",
"imapflow": "^1.4.0",
"ipaddr.js": "^2.3.0", "ipaddr.js": "^2.3.0",
"joi": "^17.9.1", "joi": "^17.9.1",
"js-yaml": "^4.1.1", "js-yaml": "^4.1.1",
"jsonwebtoken": "^9.0.0", "jsonwebtoken": "^9.0.0",
"knex": "^2.4.2", "knex": "^2.4.2",
"mailparser": "^3.9.9",
"mime-types": "^3.0.1", "mime-types": "^3.0.1",
"multer": "^2.0.2", "multer": "^2.0.2",
"node-cron": "^3.0.2", "node-cron": "^3.0.2",
+14
View File
@@ -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/calendar', require('./src/routes/adminCalendar'));
app.use('/api/admin/deals', require('./src/routes/adminDeals')); app.use('/api/admin/deals', require('./src/routes/adminDeals'));
app.use('/api/admin/tax-report', require('./src/routes/adminTaxReport')); 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/system-health', require('./src/routes/adminSystemHealth'));
app.use('/api/admin/dev', require('./src/routes/adminDev')); app.use('/api/admin/dev', require('./src/routes/adminDev'));
app.use('/api/public/quotes', require('./src/routes/publicQuotes')); app.use('/api/public/quotes', require('./src/routes/publicQuotes'));
@@ -837,6 +842,15 @@ async function startServer() {
} }
startEmailQueueProcessor(); 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) // Start webhook delivery worker (#327)
const { startWebhookDeliveryWorker } = require('./src/services/webhookDeliveryWorker'); const { startWebhookDeliveryWorker } = require('./src/services/webhookDeliveryWorker');
startWebhookDeliveryWorker(); 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 };
+17
View File
@@ -10,6 +10,13 @@ const express = require('express');
const { body, param, query } = require('express-validator'); const { body, param, query } = require('express-validator');
const { adminAuth } = require('../middleware/auth'); const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions'); 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 { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
const customerAccountsService = require('../services/customerAccountsService'); const customerAccountsService = require('../services/customerAccountsService');
const customerHoursService = require('../services/customerHoursService'); 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 // set one; the editor surfaces it as an empty input and forces a
// per-entry override on every logged block. // per-entry override on every logged block.
featureHoursLogging: c.feature_hours_logging === true || c.feature_hours_logging === 1, 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, hourlyRateMinor: c.hourly_rate_minor != null ? Number(c.hourly_rate_minor) : null,
// Per-customer Skonto opt-out (migration 112). When true, none of // Per-customer Skonto opt-out (migration 112). When true, none of
// this customer's invoices qualify for an early-payment discount, // this customer's invoices qualify for an early-payment discount,
@@ -387,6 +397,7 @@ router.put('/:id', [
body('feature_calendar').optional().isBoolean(), body('feature_calendar').optional().isBoolean(),
body('feature_quotes').optional().isBoolean(), body('feature_quotes').optional().isBoolean(),
body('feature_bills').optional().isBoolean(), body('feature_bills').optional().isBoolean(),
body('feature_contracts').optional().isBoolean(),
// Hours logging (migration 129). // Hours logging (migration 129).
body('feature_hours_logging').optional().isBoolean(), body('feature_hours_logging').optional().isBoolean(),
body('hourly_rate_minor').optional({ nullable: true }).isInt({ min: 0 }), 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. // can't collide with the int-validated :id pattern.
router.get('/hour-entries/unbilled-summary', [ router.get('/hour-entries/unbilled-summary', [
adminAuth, adminAuth,
requireHoursLogging,
requirePermission('customers.view'), requirePermission('customers.view'),
], handleAsync(async (req, res) => { ], handleAsync(async (req, res) => {
const summary = await customerHoursService.getUnbilledSummaryByCustomer(); const summary = await customerHoursService.getUnbilledSummaryByCustomer();
@@ -549,6 +561,7 @@ router.get('/hour-entries/unbilled-summary', [
router.get('/:id/hour-entries', [ router.get('/:id/hour-entries', [
adminAuth, adminAuth,
requireHoursLogging,
requirePermission('customers.view'), requirePermission('customers.view'),
param('id').isInt({ min: 1 }), param('id').isInt({ min: 1 }),
query('status').optional().isIn(['unbilled', 'billed', 'cancelled']), query('status').optional().isIn(['unbilled', 'billed', 'cancelled']),
@@ -563,6 +576,7 @@ router.get('/:id/hour-entries', [
router.post('/:id/hour-entries', [ router.post('/:id/hour-entries', [
adminAuth, adminAuth,
requireHoursLogging,
// Migration 134 — hour entries are customer-scoped writes; same scope // Migration 134 — hour entries are customer-scoped writes; same scope
// as customer record edits, narrower than invite/create. // as customer record edits, narrower than invite/create.
requirePermission('customers.edit'), requirePermission('customers.edit'),
@@ -585,6 +599,7 @@ router.post('/:id/hour-entries', [
router.put('/:id/hour-entries/:entryId', [ router.put('/:id/hour-entries/:entryId', [
adminAuth, adminAuth,
requireHoursLogging,
requirePermission('customers.edit'), requirePermission('customers.edit'),
param('id').isInt({ min: 1 }), param('id').isInt({ min: 1 }),
param('entryId').isInt({ min: 1 }), param('entryId').isInt({ min: 1 }),
@@ -605,6 +620,7 @@ router.put('/:id/hour-entries/:entryId', [
router.delete('/:id/hour-entries/:entryId', [ router.delete('/:id/hour-entries/:entryId', [
adminAuth, adminAuth,
requireHoursLogging,
requirePermission('customers.edit'), requirePermission('customers.edit'),
param('id').isInt({ min: 1 }), param('id').isInt({ min: 1 }),
param('entryId').isInt({ min: 1 }), param('entryId').isInt({ min: 1 }),
@@ -619,6 +635,7 @@ router.delete('/:id/hour-entries/:entryId', [
router.post('/:id/hour-entries/bill', [ router.post('/:id/hour-entries/bill', [
adminAuth, adminAuth,
requireHoursLogging,
requirePermission('customers.edit'), requirePermission('customers.edit'),
param('id').isInt({ min: 1 }), param('id').isInt({ min: 1 }),
], handleAsync(async (req, res) => { ], handleAsync(async (req, res) => {
+4
View File
@@ -21,6 +21,7 @@ const express = require('express');
const { param, body } = require('express-validator'); const { param, body } = require('express-validator');
const { adminAuth } = require('../middleware/auth'); const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions'); const { requirePermission } = require('../middleware/permissions');
const { requireFeatureFlag } = require('../middleware/requireFeatureFlag');
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers'); const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
const dealsService = require('../services/dealsService'); const dealsService = require('../services/dealsService');
const invoiceService = require('../services/invoiceService'); const invoiceService = require('../services/invoiceService');
@@ -56,6 +57,9 @@ router.get(
*/ */
router.put( router.put(
'/:uuid/installment-plan', '/: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'), requirePermission('bills.manage'),
[ [
param('uuid').isString().isLength({ min: 32, max: 36 }), param('uuid').isString().isLength({ min: 32, max: 36 }),
+155
View File
@@ -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 || ''}”) isnt an email address, so the round-trip test cant 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 // Test email configuration
router.post('/test', adminAuth, requirePermission('email.send'), async (req, res) => { router.post('/test', adminAuth, requirePermission('email.send'), async (req, res) => {
try { try {
+234
View File
@@ -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;
+36 -9
View File
@@ -18,6 +18,7 @@ const router = express.Router();
const { db, logActivity } = require('../database/db'); const { db, logActivity } = require('../database/db');
const { adminAuth } = require('../middleware/auth'); const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions'); const { requirePermission } = require('../middleware/permissions');
const { invalidateFeatureFlagCache } = require('../middleware/requireFeatureFlag');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
// Canonical flag list. Keep in sync with frontend // Canonical flag list. Keep in sync with frontend
@@ -25,6 +26,9 @@ const logger = require('../utils/logger');
const KNOWN_FLAGS = [ const KNOWN_FLAGS = [
'galleries', 'galleries',
'reminderEmails', 'reminderEmails',
// Incoming mail (migration 128) — IMAP polling of a dedicated mailbox into
// the incoming-invoices inbox. Standalone toggle.
'incomingMail',
'calendar', 'calendar',
'calendarBooking', 'calendarBooking',
'quotes', 'quotes',
@@ -45,10 +49,9 @@ const KNOWN_FLAGS = [
// payment-check email flow without waiting 30 days, etc.). // payment-check email flow without waiting 30 days, etc.).
// Strictly opt-in. // Strictly opt-in.
'crmDevelopment', 'crmDevelopment',
// Tax / Steuer report sub-tab under Clients. Independent toggle so // Tax / Steuer report — an Accounting sub-feature (moved out of CRM).
// admins who use Bills but don't need the tax export (or aren't // Independent of `bills`; forced off when the `accounting` master is off
// ready to enable it yet) can leave it off. Forced off when `bills` // (see applyDependencyRules below).
// is off (no invoices → nothing to report).
'taxReport', 'taxReport',
// Hours logging (migration 129). Master switch for the per-customer // Hours logging (migration 129). Master switch for the per-customer
// Hours card + the auto-append into monthly draft / "Bill these // 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 // upload). Seeded block bodies are EXAMPLES ONLY; admins must have a
// lawyer review before sending. See docs/crm-disclaimers.md. // lawyer review before sending. See docs/crm-disclaimers.md.
'contracts', '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 + // Projects (migration 120). Admin-only grouping layer above events +
// the Project Overview cockpit ("book to project" hours control, 360° // the Project Overview cockpit ("book to project" hours control, 360°
// rollup feed). Lights up the Clients section. Customers never see it. // 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). // new release that hasn't run its migration yet on this instance).
const DEFAULT_FLAGS = { const DEFAULT_FLAGS = {
galleries: true, galleries: true,
incomingMail: false,
// F.3 — reminderEmails is a placeholder card in the Features tab // F.3 — reminderEmails is a placeholder card in the Features tab
// (lockedReason: NOT_YET_AVAILABLE). Default FALSE so it matches // (lockedReason: NOT_YET_AVAILABLE). Default FALSE so it matches
// the locked-but-off visual state of messaging / calendarBooking // the locked-but-off visual state of messaging / calendarBooking
@@ -88,6 +103,9 @@ const DEFAULT_FLAGS = {
taxReport: false, taxReport: false,
hoursLogging: false, hoursLogging: false,
contracts: false, contracts: false,
accounting: false,
incomingInvoices: false,
expenses: false,
projects: false, projects: false,
}; };
@@ -109,10 +127,14 @@ function applyDependencyRules(flags) {
// Sub-features can't outlive their parents. // Sub-features can't outlive their parents.
if (out.quotes === false) out.bills = false; if (out.quotes === false) out.bills = false;
if (out.calendar === false) out.calendarBooking = false; if (out.calendar === false) out.calendarBooking = false;
// Tax report only makes sense when bills are on — turning bills off // Accounting is a top-level MASTER; its sub-features can't outlive it.
// implicitly turns the tax report off too. Admins enabling tax // Tax export is now independent of Bills — it relocated permanently
// report must first enable bills. // into the Accounting section (its own master gate).
if (out.bills === false) out.taxReport = false; if (out.accounting === false) {
out.taxReport = false;
out.incomingInvoices = false;
out.expenses = false;
}
// Clients parent flag is DERIVED from its children. Admins don't // Clients parent flag is DERIVED from its children. Admins don't
// toggle it directly in the Features tab — they enable a specific // toggle it directly in the Features tab — they enable a specific
// sub-feature (Accounts today; Calendar/Quotes/Bills/Messaging // sub-feature (Accounts today; Calendar/Quotes/Bills/Messaging
@@ -125,9 +147,10 @@ function applyDependencyRules(flags) {
|| out.crmDevelopment || out.crmDevelopment
|| out.quotes || out.quotes
|| out.bills || out.bills
|| out.taxReport
|| out.hoursLogging || out.hoursLogging
|| out.contracts || 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. // Migration 120 — admin-only Project Overview cockpit lives under Clients.
|| out.projects || out.projects
// Migration 137 — admin calendar lights up the Clients section. // 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( await logActivity(
'feature_flags_updated', 'feature_flags_updated',
{ changed, actor: adminUsername }, { changed, actor: adminUsername },
+125
View File
@@ -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;
+56 -10
View File
@@ -19,6 +19,7 @@ const {
getRawPublicSiteSettings, getRawPublicSiteSettings,
} = require('../services/publicSiteService'); } = require('../services/publicSiteService');
const { sanitizeCss } = require('../utils/cssSanitizer'); const { sanitizeCss } = require('../utils/cssSanitizer');
const { upsertAppSetting } = require('../utils/appSettings');
const { clearShareLinkSettingsCache } = require('../services/shareLinkService'); const { clearShareLinkSettingsCache } = require('../services/shareLinkService');
const { resetSecurityConfigCache } = require('../utils/authSecurity'); const { resetSecurityConfigCache } = require('../utils/authSecurity');
const router = express.Router(); const router = express.Router();
@@ -211,16 +212,7 @@ router.put('/customer-surface', adminAuth, requirePermission('settings.edit'), a
} }
for (const u of updates) { for (const u of updates) {
const existing = await db('app_settings').where('setting_key', u.setting_key).first(); await upsertAppSetting(u.setting_key, u.setting_value, u.setting_type);
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() });
}
} }
// Clear the public-site cache so any consumer relying on it // 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 // Get settings by type
router.get('/:type', adminAuth, requirePermission('settings.view'), async (req, res) => { router.get('/:type', adminAuth, requirePermission('settings.view'), async (req, res) => {
try { try {
+20 -13
View File
@@ -8,9 +8,11 @@
* GET /pdf → landscape A4 PDF, Content-Disposition: attachment * GET /pdf → landscape A4 PDF, Content-Disposition: attachment
* GET /csv → RFC-4180 CSV, Content-Disposition: attachment * GET /csv → RFC-4180 CSV, Content-Disposition: attachment
* *
* Reuses the existing `bills` feature flag + `bills.view` permission. * Gated by the Accounting master flag + the `taxReport` sub-flag
* Tax data is just a different lens on invoice data — admins who can * (independent of `bills` — Tax export was moved out of CRM into
* read invoices can read the tax report; no new RBAC surface needed. * 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'); const express = require('express');
@@ -23,19 +25,19 @@ const { db } = require('../database/db');
const router = express.Router(); const router = express.Router();
// The tax report has its own dedicated flag (taxReport) — independent // The tax report now lives under the Accounting master flag and has its
// from `bills` so admins can leave it off until they actually need to // own dedicated `taxReport` sub-flag — it is INDEPENDENT of `bills`
// run the export. The frontend mirrors the dependency rule (bills off // (Tax export was moved permanently out of CRM into Accounting). The
// → taxReport off) but we re-check both server-side for defence in // frontend mirrors the dependency rule (accounting off → taxReport off)
// depth. // but we re-check both server-side for defence in depth.
async function requireTaxReportFlag(req, res, next) { async function requireTaxReportFlag(req, res, next) {
try { 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 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')); const taxReport = isOn(rows.find((r) => r.key === 'taxReport'));
if (!bills) { if (!accounting) {
return res.status(403).json({ error: 'Bills feature is disabled', code: 'BILLS_DISABLED' }); return res.status(403).json({ error: 'Accounting feature is disabled', code: 'ACCOUNTING_DISABLED' });
} }
if (!taxReport) { if (!taxReport) {
return res.status(403).json({ error: 'Tax report feature is disabled', code: 'TAX_REPORT_DISABLED' }); 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) { 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 { return {
from: req.query.from, from: req.query.from,
to: req.query.to, to: req.query.to,
currency: String(req.query.currency || '').toUpperCase(), currency: String(req.query.currency || '').toUpperCase(),
locale: req.query.locale || undefined, locale: req.query.locale || undefined,
scope,
}; };
} }
@@ -92,7 +98,8 @@ router.get(
validateRequest(req); validateRequest(req);
const params = parseParams(req); const params = parseParams(req);
const buffer = await taxReportService.renderTaxReportPdf(params); 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-Type', 'application/pdf');
res.set('Content-Disposition', `attachment; filename="${filename}"`); res.set('Content-Disposition', `attachment; filename="${filename}"`);
res.set('Content-Length', String(buffer.length)); res.set('Content-Length', String(buffer.length));
+33
View File
@@ -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;
+25 -13
View File
@@ -24,6 +24,19 @@ const { customerAuth } = require('../middleware/customerAuth');
const { setGalleryAuthCookies } = require('../utils/tokenUtils'); const { setGalleryAuthCookies } = require('../utils/tokenUtils');
const customerAccountsService = require('../services/customerAccountsService'); 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 * Customer-side password policy mirrors the one in customerAuth.js — kept
* deliberately simple (8 chars, one uppercase, one digit) since a customer * 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) => { router.get('/quotes', customerAuth, async (req, res) => {
try { try {
const { db: dbi } = require('../database/db'); const { db: dbi } = require('../database/db');
// Customer-feature gate. is_active is enforced by customerAuth. // Customer-feature gate — master flag AND per-customer override.
const customer = await dbi('customer_accounts').where({ id: req.customer.id }).first(); if (!(await customerFeatureAllowed(req, res, 'quotes', 'Quotes'))) return;
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' });
}
const rows = await dbi('quotes') const rows = await dbi('quotes')
.where({ customer_account_id: req.customer.id }) .where({ customer_account_id: req.customer.id })
// Hide drafts — they're admin scratch work; nothing has been // 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) => { router.get('/invoices', customerAuth, async (req, res) => {
try { try {
const { db: dbi } = require('../database/db'); const { db: dbi } = require('../database/db');
const customer = await dbi('customer_accounts').where({ id: req.customer.id }).first(); // Customer-feature gate — master flag AND per-customer override.
if (!customer || customer.feature_bills === false || customer.feature_bills === 0) { if (!(await customerFeatureAllowed(req, res, 'bills', 'Invoices'))) return;
return res.status(403).json({ error: 'Invoices are disabled for this account', code: 'CUSTOMER_FEATURE_DISABLED' });
}
// Visibility rules for the customer-facing list: // Visibility rules for the customer-facing list:
// - Hide `scheduled` always (drafts the admin is still tweaking). // - Hide `scheduled` always (drafts the admin is still tweaking).
// - Show `sent`, `overdue`, `paid` always (the customer's // - 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) => { router.get('/quotes/:id/pdf', customerAuth, async (req, res) => {
try { 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'); 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') const quote = await dbi('quotes')
.where({ id: parseInt(req.params.id, 10), customer_account_id: req.customer.id }) .where({ id: parseInt(req.params.id, 10), customer_account_id: req.customer.id })
.first(); .first();
@@ -584,6 +590,8 @@ router.get('/quotes/:id/pdf', customerAuth, async (req, res) => {
router.get('/invoices/:id/pdf', customerAuth, async (req, res) => { router.get('/invoices/:id/pdf', customerAuth, async (req, res) => {
try { try {
const { db: dbi } = require('../database/db'); 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') const invoice = await dbi('invoices')
.where({ id: parseInt(req.params.id, 10), customer_account_id: req.customer.id }) .where({ id: parseInt(req.params.id, 10), customer_account_id: req.customer.id })
.first(); .first();
@@ -623,6 +631,8 @@ router.get('/contracts', customerAuth, async (req, res) => {
// Feature not migrated on this install yet. // Feature not migrated on this install yet.
return res.json({ contracts: [] }); 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') const rows = await dbi('contracts')
.where({ customer_account_id: req.customer.id }) .where({ customer_account_id: req.customer.id })
.whereNotIn('status', ['draft']) .whereNotIn('status', ['draft'])
@@ -680,6 +690,8 @@ router.get('/contracts/:id/pdf', customerAuth, async (req, res) => {
if (!(await dbi.schema.hasTable('contracts'))) { if (!(await dbi.schema.hasTable('contracts'))) {
return res.status(404).json({ error: 'Contract not found' }); 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') const contract = await dbi('contracts')
.where({ id: parseInt(req.params.id, 10), customer_account_id: req.customer.id }) .where({ id: parseInt(req.params.id, 10), customer_account_id: req.customer.id })
.first(); .first();
@@ -480,6 +480,7 @@ async function listCustomers({ search } = {}) {
'customer_accounts.feature_quotes', 'customer_accounts.feature_quotes',
'customer_accounts.feature_bills', 'customer_accounts.feature_bills',
'customer_accounts.feature_hours_logging', 'customer_accounts.feature_hours_logging',
'customer_accounts.feature_contracts',
'customer_accounts.hourly_rate_minor', 'customer_accounts.hourly_rate_minor',
'customer_accounts.last_login', 'customer_accounts.last_login',
'customer_accounts.created_at', 'customer_accounts.created_at',
@@ -548,6 +549,9 @@ async function updateCustomer(id, updates, updatedByAdminId) {
// Per-customer feature flags (#354 follow-up). Booleans below are // Per-customer feature flags (#354 follow-up). Booleans below are
// coerced via formatBoolean for SQLite compatibility. // coerced via formatBoolean for SQLite compatibility.
'feature_calendar', 'feature_quotes', 'feature_bills', 'feature_hours_logging', '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 // CRM billing cadence (migration 102). 'per_event' (default) keeps
// each invoice firing on its own schedule; monthly/quarterly snap // each invoice firing on its own schedule; monthly/quarterly snap
// every scheduled invoice to billing_cycle_day of the next period. // every scheduled invoice to billing_cycle_day of the next period.
@@ -570,6 +574,7 @@ async function updateCustomer(id, updates, updatedByAdminId) {
} else if ( } else if (
f === 'feature_calendar' || f === 'feature_quotes' f === 'feature_calendar' || f === 'feature_quotes'
|| f === 'feature_bills' || f === 'feature_hours_logging' || f === 'feature_bills' || f === 'feature_hours_logging'
|| f === 'feature_contracts'
|| f === 'skonto_disabled' || f === 'skonto_disabled'
) { ) {
allowed[f] = formatBoolean(updates[f]); 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. // for hours, so we skip the third gate the bills/quotes use.
const hoursMaster = await db('feature_flags').where({ key: 'hoursLogging' }).first(); const hoursMaster = await db('feature_flags').where({ key: 'hoursLogging' }).first();
const hoursLoggingMaster = hoursMaster ? Boolean(hoursMaster.value) : true; const hoursLoggingMaster = hoursMaster ? Boolean(hoursMaster.value) : true;
// Contracts (migration 130): no per-customer flag, just the global // Contracts: global feature_flags row AND the per-customer override
// feature_flags row. When on, every customer with an active account // (migration 131). feature_contracts defaults TRUE, so existing customers
// sees the Contracts tab on their portal. // keep their Contracts tab; an admin can hide it per customer.
const contractsMaster = await db('feature_flags').where({ key: 'contracts' }).first(); const contractsMaster = await db('feature_flags').where({ key: 'contracts' }).first();
const contractsEnabled = contractsMaster ? Boolean(contractsMaster.value) : false; const contractsEnabled = contractsMaster ? Boolean(contractsMaster.value) : false;
return { return {
@@ -1277,7 +1282,7 @@ async function getEffectiveFeaturesForCustomer(customerOrId) {
quotes: globals.quotesEnabled && truthy(customer.feature_quotes), quotes: globals.quotesEnabled && truthy(customer.feature_quotes),
bills: globals.billsEnabled && truthy(customer.feature_bills), bills: globals.billsEnabled && truthy(customer.feature_bills),
hoursLogging: hoursLoggingMaster && truthy(customer.feature_hours_logging), hoursLogging: hoursLoggingMaster && truthy(customer.feature_hours_logging),
contracts: contractsEnabled, contracts: contractsEnabled && truthy(customer.feature_contracts),
}; };
} }
+392
View File
@@ -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 };
+535
View File
@@ -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 },
};
+54
View File
@@ -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 };
+12
View File
@@ -43,6 +43,7 @@ function getHierarchyHelpers() {
// D.2 — `ensureInt` + `ensureNumber` consolidated into utils/numericHelpers. // D.2 — `ensureInt` + `ensureNumber` consolidated into utils/numericHelpers.
const { ensureInt, ensureNumber } = require('../utils/numericHelpers'); const { ensureInt, ensureNumber } = require('../utils/numericHelpers');
const { hasColumnCached } = require('../utils/schemaCache');
function formatNumberInTemplate(format, year, seq) { function formatNumberInTemplate(format, year, seq) {
return format return format
@@ -955,6 +956,11 @@ async function createInvoice(payload, adminId, trx = db) {
created_at: new Date(), created_at: new Date(),
updated_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 inserted = await trx('invoices').insert(row).returning('id');
const invoiceId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0]; const invoiceId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
@@ -1774,6 +1780,8 @@ async function buildInvoiceRenderContext(invoice, lineItems) {
totals: { totals: {
netAmountMinor: invoice.net_amount_minor, netAmountMinor: invoice.net_amount_minor,
vatRate: invoice.vat_rate, vatRate: invoice.vat_rate,
// Migration 130 — VAT-code snapshot (so re-editing preserves it).
vatCode: invoice.vat_code ?? null,
vatAmountMinor: invoice.vat_amount_minor, vatAmountMinor: invoice.vat_amount_minor,
shippingAmountMinor: invoice.shipping_amount_minor, shippingAmountMinor: invoice.shipping_amount_minor,
totalAmountMinor: invoice.total_amount_minor, totalAmountMinor: invoice.total_amount_minor,
@@ -2212,6 +2220,10 @@ async function createStorno(originalId, adminId, trx = db) {
currency: original.currency, currency: original.currency,
language: original.language, language: original.language,
vat_rate: original.vat_rate, 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), shipping_amount_minor: -ensureInt(original.shipping_amount_minor || 0),
net_amount_minor: -ensureInt(original.net_amount_minor), net_amount_minor: -ensureInt(original.net_amount_minor),
vat_amount_minor: -ensureInt(original.vat_amount_minor), vat_amount_minor: -ensureInt(original.vat_amount_minor),
+507
View File
@@ -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 categoryaccount and tax_treatmentVAT-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 },
};
+114
View File
@@ -82,6 +82,13 @@ const LABELS = {
tax_col_total: 'Gross', tax_col_total: 'Gross',
tax_col_status: 'Status', tax_col_status: 'Status',
tax_col_skonto: 'Skonto', 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_status_cancelled: 'Cancelled',
tax_totals_by_rate: 'Totals by VAT rate', tax_totals_by_rate: 'Totals by VAT rate',
tax_grand_total_net: 'Total net', tax_grand_total_net: 'Total net',
@@ -89,6 +96,18 @@ const LABELS = {
tax_grand_total_gross: 'Total gross', tax_grand_total_gross: 'Total gross',
tax_cancelled_footnote: '{count} cancelled invoice(s) — amounts excluded from totals (shown for audit-trail continuity).', 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_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 // Contracts (migration 130). Section labels stay in sync with the
// SECTIONS_ORDER enum in contractService. // SECTIONS_ORDER enum in contractService.
contract_title: 'Contract', contract_title: 'Contract',
@@ -190,6 +209,13 @@ const LABELS = {
tax_col_total: 'Brutto', tax_col_total: 'Brutto',
tax_col_status: 'Status', tax_col_status: 'Status',
tax_col_skonto: 'Skonto', 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_status_cancelled: 'Storniert',
tax_totals_by_rate: 'Summen nach MwSt-Satz', tax_totals_by_rate: 'Summen nach MwSt-Satz',
tax_grand_total_net: 'Gesamt Netto', tax_grand_total_net: 'Gesamt Netto',
@@ -197,6 +223,18 @@ const LABELS = {
tax_grand_total_gross: 'Gesamt Brutto', 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_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_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_title: 'Vertrag',
contract_number_label: 'Vertragsnummer', contract_number_label: 'Vertragsnummer',
section_basics: 'Vertragsgrundlagen', section_basics: 'Vertragsgrundlagen',
@@ -291,6 +329,13 @@ const LABELS = {
tax_col_total: 'Brut', tax_col_total: 'Brut',
tax_col_status: 'Statut', tax_col_status: 'Statut',
tax_col_skonto: 'Escompte', 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_status_cancelled: 'Annulée',
tax_totals_by_rate: 'Totaux par taux de TVA', tax_totals_by_rate: 'Totaux par taux de TVA',
tax_grand_total_net: 'Total net', tax_grand_total_net: 'Total net',
@@ -298,6 +343,18 @@ const LABELS = {
tax_grand_total_gross: 'Total brut', 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_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_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: { nl: {
// Machine-translated, flagged for native review. // Machine-translated, flagged for native review.
@@ -361,6 +418,13 @@ const LABELS = {
tax_col_total: 'Bruto', tax_col_total: 'Bruto',
tax_col_status: 'Status', tax_col_status: 'Status',
tax_col_skonto: 'Korting', 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_status_cancelled: 'Geannuleerd',
tax_totals_by_rate: 'Totalen per btw-tarief', tax_totals_by_rate: 'Totalen per btw-tarief',
tax_grand_total_net: 'Totaal netto', tax_grand_total_net: 'Totaal netto',
@@ -368,6 +432,18 @@ const LABELS = {
tax_grand_total_gross: 'Totaal bruto', 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_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_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: { pt: {
// Machine-translated, flagged for native review. // Machine-translated, flagged for native review.
@@ -431,6 +507,13 @@ const LABELS = {
tax_col_total: 'Bruto', tax_col_total: 'Bruto',
tax_col_status: 'Estado', tax_col_status: 'Estado',
tax_col_skonto: 'Desconto', 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_status_cancelled: 'Cancelada',
tax_totals_by_rate: 'Totais por taxa de IVA', tax_totals_by_rate: 'Totais por taxa de IVA',
tax_grand_total_net: 'Total líquido', tax_grand_total_net: 'Total líquido',
@@ -438,6 +521,18 @@ const LABELS = {
tax_grand_total_gross: 'Total bruto', 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_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_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: { ru: {
// Machine-translated, flagged for native review. // Machine-translated, flagged for native review.
@@ -501,6 +596,13 @@ const LABELS = {
tax_col_total: 'Брутто', tax_col_total: 'Брутто',
tax_col_status: 'Статус', tax_col_status: 'Статус',
tax_col_skonto: 'Скидка', 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_status_cancelled: 'Аннулирован',
tax_totals_by_rate: 'Итоги по ставкам НДС', tax_totals_by_rate: 'Итоги по ставкам НДС',
tax_grand_total_net: 'Итого нетто', tax_grand_total_net: 'Итого нетто',
@@ -508,6 +610,18 @@ const LABELS = {
tax_grand_total_gross: 'Итого брутто', tax_grand_total_gross: 'Итого брутто',
tax_cancelled_footnote: '{count} аннулированных счёт(а/ов) — суммы исключены из итогов (показаны для непрерывности аудиторской цепочки).', tax_cancelled_footnote: '{count} аннулированных счёт(а/ов) — суммы исключены из итогов (показаны для непрерывности аудиторской цепочки).',
tax_no_invoices: 'Нет счетов за этот период.', 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: 'НДС к уплате (исходящий − входящий)',
}, },
}; };
+11
View File
@@ -558,6 +558,11 @@ async function createQuote(payload, adminId) {
if (payload.projectId !== undefined && await hasColumnCached('quotes', 'project_id')) { if (payload.projectId !== undefined && await hasColumnCached('quotes', 'project_id')) {
row.project_id = payload.projectId || null; 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 inserted = await trx('quotes').insert(row).returning('id');
const quoteId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0]; 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')) { if (Object.prototype.hasOwnProperty.call(payload, 'projectId') && await hasColumnCached('quotes', 'project_id')) {
updates.project_id = payload.projectId || null; 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); await trx('quotes').where({ id }).update(updates);
// When linked to a project, cascade across the deal lineage so the linked // 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, expectedDurationHours: quote.expected_duration_hours,
paymentTermTemplateId: quote.payment_term_template_id, paymentTermTemplateId: quote.payment_term_template_id,
vatRate: quote.vat_rate, vatRate: quote.vat_rate,
// Migration 130 — VAT-code snapshot (so re-editing preserves it).
vatCode: quote.vat_code ?? null,
shippingAmountMinor: quote.shipping_amount_minor, shippingAmountMinor: quote.shipping_amount_minor,
introText: quote.intro_text, introText: quote.intro_text,
outroText: quote.outro_text, outroText: quote.outro_text,
+76
View File
@@ -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 };
+450 -85
View File
@@ -49,6 +49,8 @@ const REPORTABLE_STATUSES = ['sent', 'paid', 'overdue', 'pending_delivery', 'can
// D.2 — `ensureInt` consolidated into utils/numericHelpers. // D.2 — `ensureInt` consolidated into utils/numericHelpers.
const { ensureInt } = require('../utils/numericHelpers'); const { ensureInt } = require('../utils/numericHelpers');
const { neutralizeSpreadsheetFormula } = require('../utils/spreadsheetSafe');
const logger = require('../utils/logger');
function ensureRate(v) { function ensureRate(v) {
if (v === null || v === undefined || v === '') return 0; if (v === null || v === undefined || v === '') return 0;
@@ -157,6 +159,198 @@ async function loadSkontoMap(invoiceIds) {
return map; 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. * The main entry point.
* *
@@ -166,8 +360,13 @@ async function loadSkontoMap(invoiceIds) {
* required and must match `invoices.currency` exactly mixing * required and must match `invoices.currency` exactly mixing
* currencies in one report is unsound for tax filing, so the API * currencies in one report is unsound for tax filing, so the API
* forces a single-currency view. * 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) { if (!from || !to) {
throw new Error('getTaxReport: `from` and `to` are required (YYYY-MM-DD)'); 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); 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 { return {
rows, rows,
totalsByVatRate, totalsByVatRate,
@@ -290,6 +587,10 @@ async function getTaxReport({ from, to, currency } = {}) {
grandTotalVat, grandTotalVat,
grandTotal, grandTotal,
cancelledCount, cancelledCount,
costs,
costsError,
summary,
ledger,
currency: cur, currency: cur,
period: { from, to }, period: { from, to },
}; };
@@ -351,21 +652,19 @@ async function loadRenderContext(locale) {
// uncluttered with just "R-2026-0001" — easier to scan for an // uncluttered with just "R-2026-0001" — easier to scan for an
// auditor looking at the sequence. // auditor looking at the sequence.
const TAX_TABLE_COLS = [ const TAX_TABLE_COLS = [
{ key: 'idx', labelKey: 'tax_col_no', width: 26, align: 'right' }, { key: 'idx', labelKey: 'tax_col_no', width: 22, align: 'right' },
{ key: 'date', labelKey: 'tax_col_date', width: 60, align: 'left' }, { key: 'type', labelKey: 'tax_col_type', width: 58, align: 'left' },
{ key: 'invoice', labelKey: 'tax_col_invoice', width: 100, align: 'left' }, { key: 'date', labelKey: 'tax_col_date', width: 56, align: 'left' },
{ key: 'customer', labelKey: 'tax_col_customer', width: 132, align: 'left' }, { key: 'reference', labelKey: 'tax_col_reference', width: 88, align: 'left' },
{ key: 'event', labelKey: 'tax_col_event', width: 95, align: 'left' }, { key: 'party', labelKey: 'tax_col_party', width: 116, align: 'left' },
{ key: 'vatRate', labelKey: 'tax_col_vat_rate', width: 42, align: 'right' }, { key: 'event', labelKey: 'tax_col_event', width: 86, align: 'left' },
{ key: 'net', labelKey: 'tax_col_net', width: 70, align: 'right' }, { key: 'tax', labelKey: 'tax_col_tax', width: 64, align: 'left' },
{ key: 'vat', labelKey: 'tax_col_vat', width: 60, align: 'right' }, { key: 'net', labelKey: 'tax_col_net', width: 70, align: 'right' },
{ key: 'total', labelKey: 'tax_col_total', width: 80, 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 // Skonto column (migration 126) — blank for non-Skonto rows so the
// column reads quietly until it has data. Shrunk neighbouring text // column reads quietly until it has data.
// columns slightly to make space without going over the landscape { key: 'skonto', labelKey: 'tax_col_skonto', width: 50, align: 'right' },
// content width.
{ key: 'skonto', labelKey: 'tax_col_skonto', width: 56, align: 'right' },
{ key: 'status', labelKey: 'tax_col_status', width: 58, align: 'left' },
]; ];
function colX(leftMargin, index) { function colX(leftMargin, index) {
@@ -400,22 +699,31 @@ function formatVatRate(rate, locale) {
return `${formatted} %`; return `${formatted} %`;
} }
function rowCellValues(row, idx, locale, dateFormat) { function rowCellValues(row, idx, locale, dateFormat, currency) {
const intlLocale = locale === 'de' ? 'de-CH' : 'en-GB'; 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 { return {
idx: String(idx), idx: String(idx),
date: formatDate(row.issueDate, dateFormat), type: typeLabel,
invoice: row.invoiceNumber, // no inline "(Cancelled)" — keep the column tidy; status is its own column date: formatDate(row.date, dateFormat),
customer: row.customerLabel || '', reference,
party: row.party || '',
event: row.eventName || '', event: row.eventName || '',
vatRate: formatVatRate(row.vatRate, locale), tax: row.type === 'outgoing' ? formatVatRate(row.vatRate, locale) : (row.taxTreatment || ''),
net: formatMinor(row.netMinor, row.currency, intlLocale), net: formatMinor(row.netMinor, currency, intlLocale),
vat: formatMinor(row.vatMinor, row.currency, intlLocale), vat: formatMinor(row.vatMinor, currency, intlLocale),
total: formatMinor(row.totalMinor, row.currency, intlLocale), total: formatMinor(row.totalMinor, currency, intlLocale),
skonto: row.skontoApplied 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 * Currency is required and used to scope the data (same contract as
* getTaxReport). Locale defaults to the business profile's default. * 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 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 renderCtx = await loadRenderContext(locale);
const useLocale = renderCtx.locale; const useLocale = renderCtx.locale;
const intlLocale = useLocale === 'de' ? 'de-CH' : 'en-GB'; 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 tableBottomLimit = page.height - page.marginBottom - 110; // leave room for totals
const tableWidth = TAX_TABLE_COLS.reduce((s, c) => s + c.width, 0); 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') doc.font(fonts.body).fontSize(10).fillColor('#555')
.text(t(useLocale, 'tax_no_invoices'), leftMargin, y + 6, { .text(t(useLocale, 'tax_no_invoices'), leftMargin, y + 6, {
width: tableWidth, align: 'center', 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 // wrap (they're either ints or money strings whose width we
// budget for) — only text cells (customer, event, invoice, // budget for) — only text cells (customer, event, invoice,
// status) opt into natural wrapping. // 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 measureCellHeight = (value, col) => {
const s = safeStr(value); const s = safeStr(value);
if (!s) return 0; if (!s) return 0;
@@ -520,9 +849,9 @@ async function renderTaxReportPdf({ from, to, currency, locale } = {}) {
return doc.heightOfString(s, opts); return doc.heightOfString(s, opts);
}; };
for (let i = 0; i < report.rows.length; i += 1) { for (let i = 0; i < report.ledger.length; i += 1) {
const row = report.rows[i]; const row = report.ledger[i];
const cells = rowCellValues(row, i + 1, useLocale, renderCtx.dateFormat); const cells = rowCellValues(row, i + 1, useLocale, renderCtx.dateFormat, report.currency);
// Set the font BEFORE measuring so heightOfString reads the // Set the font BEFORE measuring so heightOfString reads the
// exact rendering state we'll use for doc.text below. // 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 // otherwise PDFKit auto-paginates mid-totals, creating phantom
// pages whose footer ends up at unexpected Y positions on the // pages whose footer ends up at unexpected Y positions on the
// subsequent bufferedPageRange loop. // 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 const footerReserve = 24; // 12 above + 12 of page-number text room
if (y + 12 + totalsHeightEstimate + footerReserve > page.height - page.marginBottom) { if (y + 12 + totalsHeightEstimate + footerReserve > page.height - page.marginBottom) {
doc.addPage({ doc.addPage({
@@ -619,22 +951,25 @@ async function renderTaxReportPdf({ from, to, currency, locale } = {}) {
totalsX + 270, ty, { width: 90, align: 'right' }); totalsX + 270, ty, { width: 90, align: 'right' });
ty += 13; ty += 13;
} }
// Divider above grand totals. // Divider above the income / costs / result summary.
doc.moveTo(totalsX, ty + 2).lineTo(totalsX + totalsBoxWidth, ty + 2) doc.moveTo(totalsX, ty + 2).lineTo(totalsX + totalsBoxWidth, ty + 2)
.lineWidth(0.6).strokeColor('#000').stroke(); .lineWidth(0.6).strokeColor('#000').stroke();
ty += 6; ty += 6;
doc.font(fonts.bold); // Income / Costs / Result summary (mirrors the on-screen summary
doc.text(t(useLocale, 'tax_grand_total_net'), totalsX, ty, { width: 170, align: 'left' }); // box). Costs are shown NEGATIVE so the Result reads as a plain
doc.text(formatMinor(report.grandTotalNet, report.currency, intlLocale), // sum of the column. Net / VAT / Gross across the three lines.
totalsX + 175, ty, { width: 90, align: 'right' }); const s = report.summary;
ty += 13; const summaryLine = (labelKey, netMinor, vatMinor, grossMinor, bold) => {
doc.text(t(useLocale, 'tax_grand_total_vat'), totalsX, ty, { width: 170, align: 'left' }); doc.font(bold ? fonts.bold : fonts.body).fontSize(9).fillColor('#000');
doc.text(formatMinor(report.grandTotalVat, report.currency, intlLocale), doc.text(t(useLocale, labelKey), totalsX, ty, { width: 80, align: 'left' });
totalsX + 175, ty, { width: 90, align: 'right' }); doc.text(formatMinor(netMinor, report.currency, intlLocale), totalsX + 80, ty, { width: 90, align: 'right' });
ty += 13; doc.text(formatMinor(vatMinor, report.currency, intlLocale), totalsX + 175, ty, { width: 90, align: 'right' });
doc.text(t(useLocale, 'tax_grand_total_gross'), totalsX, ty, { width: 170, align: 'left' }); doc.text(formatMinor(grossMinor, report.currency, intlLocale), totalsX + 270, ty, { width: 90, align: 'right' });
doc.text(formatMinor(report.grandTotal, report.currency, intlLocale), ty += 13;
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. // Cancelled footnote (bottom-left). Only when there are any.
if (report.cancelledCount > 0) { if (report.cancelledCount > 0) {
@@ -688,66 +1023,96 @@ async function renderTaxReportPdf({ from, to, currency, locale } = {}) {
* renderTaxReportCsv({ from, to, currency, locale }) * renderTaxReportCsv({ from, to, currency, locale })
* Promise<{ content, filename, contentType }> * 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 report = await getTaxReport({ from, to, currency });
const xScope = normalizeScope(scope);
report.ledger = scopeLedger(report.ledger, xScope);
const useLocale = locale || 'en'; 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 escape = (cell) => {
const s = cell === null || cell === undefined ? '' : String(cell); // Formula-injection defence (Excel/Numbers) THEN RFC-4180 quote-wrap. The
// RFC 4180: wrap in quotes when the value contains comma, quote, // quote wrap alone does NOT stop formula evaluation — only the leading
// or newline. We always wrap, simpler + bulletproof for Excel. // single-quote prefix does.
const s = neutralizeSpreadsheetFormula(cell === null || cell === undefined ? '' : String(cell));
return `"${s.replace(/"/g, '""')}"`; return `"${s.replace(/"/g, '""')}"`;
}; };
const minorToDotDecimal = (m) => ((Number(m) || 0) / 100).toFixed(2); 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(',')]; 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([ lines.push([
i + 1, i + 1,
row.issueDate, t(useLocale, typeLabelKey(row.type)),
row.invoiceNumber, isoDate(row.date),
row.customerLabel, reference,
row.party,
row.eventName, row.eventName,
Number(row.vatRate).toFixed(2), tax,
minorToDotDecimal(row.netMinor), minorToDotDecimal(row.netMinor),
minorToDotDecimal(row.vatMinor), minorToDotDecimal(row.vatMinor),
minorToDotDecimal(row.totalMinor), minorToDotDecimal(row.totalMinor),
row.isCancelled ? '1' : '0',
row.skontoApplied ? minorToDotDecimal(row.skontoAmountMinor) : '', row.skontoApplied ? minorToDotDecimal(row.skontoAmountMinor) : '',
].map(escape).join(',')); ].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.
lines.push(''); const summary = report.summary;
lines.push([ if (summary) {
'', '', '', lines.push('');
t(useLocale, 'tax_grand_total_gross'), lines.push(escape(t(useLocale, 'tax_summary_section')));
'', '', const sline = (labelKey, net, vat, gross) => lines.push([
minorToDotDecimal(report.grandTotalNet), '', '', '', t(useLocale, labelKey), '', '',
minorToDotDecimal(report.grandTotalVat), minorToDotDecimal(net), minorToDotDecimal(vat), minorToDotDecimal(gross),
minorToDotDecimal(report.grandTotal), ].map(escape).join(','));
'', '', if (xScope !== 'cost') sline('tax_summary_income', summary.incomeNetMinor, summary.incomeVatMinor, summary.incomeGrossMinor);
].map(escape).join(',')); 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 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' }; return { content, filename, contentType: 'text/csv; charset=utf-8' };
} }
@@ -756,5 +1121,5 @@ module.exports = {
renderTaxReportPdf, renderTaxReportPdf,
renderTaxReportCsv, renderTaxReportCsv,
// Exposed for unit tests. // Exposed for unit tests.
_internal: { grossUpLateFee, computeReportedAmounts, buildCustomerLabel, formatVatRate }, _internal: { grossUpLateFee, computeReportedAmounts, buildCustomerLabel, formatVatRate, loadCosts, scopeLedger, normalizeScope },
}; };
+19 -1
View File
@@ -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 };
+15
View File
@@ -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 };
+19
View File
@@ -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
+65
View File
@@ -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 122125)
Numbered from **122** to avoid colliding with the in-flight `feat/crm-improvements` migrations **117121** (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
View File
@@ -66,6 +66,9 @@ import {
import { CustomerAuthProvider } from './contexts/CustomerAuthContext'; import { CustomerAuthProvider } from './contexts/CustomerAuthContext';
import { AdminLayout, AdminAuthWrapper } from './components/admin'; import { AdminLayout, AdminAuthWrapper } from './components/admin';
import { ClientsLayout } from './components/admin/ClientsLayout'; 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 { RequireFeature } from './components/admin/RequireFeature';
import { PageErrorBoundary, OfflineIndicator, SkipLink, DynamicFavicon, RobotsMetaTags, CMSContentBlock, Loading } from './components/common'; import { PageErrorBoundary, OfflineIndicator, SkipLink, DynamicFavicon, RobotsMetaTags, CMSContentBlock, Loading } from './components/common';
import { MaintenanceWrapper } from './components/MaintenanceWrapper'; import { MaintenanceWrapper } from './components/MaintenanceWrapper';
@@ -251,10 +254,10 @@ function App() {
/> />
</Route> </Route>
{/* Tax / Steuer report — gated by `taxReport`. */} {/* Tax export moved permanently to the Accounting
<Route element={<RequireFeature flag="taxReport" />}> section. Keep this path as a redirect so old
<Route path="tax-report" element={<TaxReportPage />} /> bookmarks / links don't 404. */}
</Route> <Route path="tax-report" element={<Navigate to="/admin/accounting/tax-report" replace />} />
{/* Developer tools — gated by `crmDevelopment`. */} {/* Developer tools — gated by `crmDevelopment`. */}
<Route element={<RequireFeature flag="crmDevelopment" />}> <Route element={<RequireFeature flag="crmDevelopment" />}>
<Route path="development" element={<CrmDevelopmentPage />} /> <Route path="development" element={<CrmDevelopmentPage />} />
@@ -268,6 +271,32 @@ function App() {
</Route> </Route>
</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 {/* Old /admin/customers paths now live under
/admin/clients/accounts. Kept indefinitely as /admin/clients/accounts. Kept indefinitely as
redirects so existing bookmarks and email links 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;
};
+15 -1
View File
@@ -10,6 +10,7 @@ import {
X, X,
Users, Users,
Briefcase, Briefcase,
Landmark,
PanelLeftClose, PanelLeftClose,
PanelLeftOpen, PanelLeftOpen,
} from 'lucide-react'; } from 'lucide-react';
@@ -91,11 +92,24 @@ const navigation: NavItem[] = [
// can't disagree: any sub-feature on lights up the entry, all off // can't disagree: any sub-feature on lights up the entry, all off
// hides it. Future siblings (e.g. `messaging`) get appended here // hides it. Future siblings (e.g. `messaging`) get appended here
// AND in the context derivation. // 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: [ featureFlagsAny: [
'customerPortal', 'crmDevelopment', 'quotes', 'bills', '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 }) => { 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änders 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, icon: Receipt,
featureFlag: 'bills', featureFlag: 'bills',
}, },
{ // Tax export moved permanently to the Accounting section (it is no
key: 'tax-report', // longer a CRM sub-feature). See AccountingLayout.
to: '/admin/clients/tax-report',
label: t('clients.subnav.taxReport', 'Tax'),
icon: Calculator,
featureFlag: 'taxReport',
},
// Future sub-features: // Future sub-features:
// { key: 'messaging', ... featureFlag: 'messaging' } // { key: 'messaging', ... featureFlag: 'messaging' }
{ {
@@ -120,10 +120,10 @@ export const CustomerAccountPicker: React.FC<Props> = ({ value, onChange, disabl
return ( return (
<div ref={containerRef} className="relative"> <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')} {t('events.customerPicker.label', 'Customer accounts')}
</label> </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 */} {/* Selected chips */}
{value.length > 0 && ( {value.length > 0 && (
@@ -131,16 +131,11 @@ export const CustomerAccountPicker: React.FC<Props> = ({ value, onChange, disabl
{value.map((c) => ( {value.map((c) => (
<span <span
key={c.id} key={c.id}
className="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs" 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"
style={{
backgroundColor: 'var(--color-elevated, #f5f5f5)',
color: 'var(--color-text)',
border: '1px solid var(--color-surface-border, #e5e5e5)',
}}
> >
<span className="font-medium">{c.displayName?.trim() || c.email}</span> <span className="font-medium">{c.displayName?.trim() || c.email}</span>
{c.displayName?.trim() && c.email !== c.displayName && ( {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 && ( {!disabled && (
<button <button
@@ -174,18 +169,14 @@ export const CustomerAccountPicker: React.FC<Props> = ({ value, onChange, disabl
{/* Dropdown */} {/* Dropdown */}
{isOpen && query.trim() !== '' && ( {isOpen && query.trim() !== '' && (
<div <div
className="absolute left-0 right-0 mt-1 z-20 rounded-lg shadow-lg border max-h-72 overflow-y-auto" 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"
style={{
backgroundColor: 'var(--color-surface, #ffffff)',
borderColor: 'var(--color-surface-border, #e5e5e5)',
}}
> >
{isSearching ? ( {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…')} {t('events.customerPicker.searching', 'Searching…')}
</div> </div>
) : results.length === 0 ? ( ) : 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.')} {t('events.customerPicker.noResults', 'No matches. Invite this customer from Clients → Accounts first.')}
</div> </div>
) : ( ) : (
@@ -197,7 +188,7 @@ export const CustomerAccountPicker: React.FC<Props> = ({ value, onChange, disabl
onClick={() => select(r)} 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" 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> <span className="flex-1 truncate">{labelFor(r)}</span>
</button> </button>
</li> </li>
@@ -57,7 +57,7 @@ const QuotesPanel: React.FC<Props> = ({ customerAccountId }) => {
return ( return (
<Card padding="lg"> <Card padding="lg">
<div className="flex items-center justify-between mb-3"> <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')} <FileText className="w-5 h-5" /> {t('customers.detail.quotesSection', 'Quotes')}
</h2> </h2>
<div className="flex gap-2"> <div className="flex gap-2">
@@ -78,18 +78,18 @@ const QuotesPanel: React.FC<Props> = ({ customerAccountId }) => {
</div> </div>
{isLoading ? <Loading /> : !data || data.quotes.length === 0 ? ( {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.')} {t('customers.detail.noQuotes', 'No quotes for this customer yet.')}
</p> </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) => ( {data.quotes.map((q) => (
<li key={q.id} className="py-2 flex items-center justify-between gap-3"> <li key={q.id} className="py-2 flex items-center justify-between gap-3">
<div className="min-w-0 flex-1"> <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} {q.quoteNumber}
</Link> </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> </div>
<span className="text-sm tabular-nums">{formatMoney(Number(q.totalAmountMinor) / 100, q.currency)}</span> <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 ${ <span className={`px-2 py-0.5 rounded text-xs font-medium ${
@@ -118,7 +118,7 @@ const ContractsPanel: React.FC<Props> = ({ customerAccountId }) => {
return ( return (
<Card padding="lg"> <Card padding="lg">
<div className="flex items-center justify-between mb-3"> <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')} <ScrollText className="w-5 h-5" /> {t('customers.detail.contractsSection', 'Contracts')}
</h2> </h2>
<div className="flex gap-2"> <div className="flex gap-2">
@@ -132,18 +132,18 @@ const ContractsPanel: React.FC<Props> = ({ customerAccountId }) => {
</div> </div>
{isLoading ? <Loading /> : !data || data.contracts.length === 0 ? ( {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.')} {t('customers.detail.noContracts', 'No contracts for this customer yet.')}
</p> </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) => ( {data.contracts.map((c) => (
<li key={c.id} className="py-2 flex items-center justify-between gap-3"> <li key={c.id} className="py-2 flex items-center justify-between gap-3">
<div className="min-w-0 flex-1"> <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} {c.contractNumber}
</Link> </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> </div>
<span className={`px-2 py-0.5 rounded text-xs font-medium ${ <span className={`px-2 py-0.5 rounded text-xs font-medium ${
c.status === 'fully_signed' ? 'bg-green-100 text-green-800' c.status === 'fully_signed' ? 'bg-green-100 text-green-800'
@@ -172,7 +172,7 @@ const InvoicesPanel: React.FC<Props> = ({ customerAccountId }) => {
return ( return (
<Card padding="lg"> <Card padding="lg">
<div className="flex items-center justify-between mb-3"> <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')} <Receipt className="w-5 h-5" /> {t('customers.detail.billsSection', 'Invoices')}
</h2> </h2>
<div className="flex gap-2"> <div className="flex gap-2">
@@ -187,18 +187,18 @@ const InvoicesPanel: React.FC<Props> = ({ customerAccountId }) => {
</div> </div>
{isLoading ? <Loading /> : !data || data.invoices.length === 0 ? ( {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.')} {t('customers.detail.noBills', 'No invoices for this customer yet.')}
</p> </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) => ( {data.invoices.map((inv) => (
<li key={inv.id} className="py-2 flex items-center justify-between gap-3"> <li key={inv.id} className="py-2 flex items-center justify-between gap-3">
<div className="min-w-0 flex-1"> <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} {inv.invoiceNumber}
</Link> </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)} {fmtDate(inv.dueDate)}
{inv.installmentTotal > 1 ? ` · ${inv.installmentIndex + 1}/${inv.installmentTotal}` : ''} {inv.installmentTotal > 1 ? ` · ${inv.installmentIndex + 1}/${inv.installmentTotal}` : ''}
</span> </span>
@@ -62,8 +62,8 @@ const Toggle: React.FC<ToggleProps> = ({ enabled, onChange, label, hint, icon: I
role="switch" role="switch"
aria-checked={enabled} aria-checked={enabled}
onClick={onChange} 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" 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={{ backgroundColor: enabled ? 'var(--color-accent, #5C8762)' : '#cbd5e1' }} style={enabled ? { backgroundColor: 'var(--color-accent, #5C8762)' } : undefined}
> >
<span <span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${enabled ? 'translate-x-6' : 'translate-x-1'}`} 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) { if (isLoading) {
return ( return (
<Card padding="md" className={className}> <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…')} {t('dealLineage.loading', 'Loading related documents…')}
</p> </p>
</Card> </Card>
@@ -128,7 +128,7 @@ export const DocumentLineageCard: React.FC<DocumentLineageCardProps> = ({
<h2 className="font-semibold mb-1 flex items-center gap-2"> <h2 className="font-semibold mb-1 flex items-center gap-2">
{t('dealLineage.title', 'Related documents')} {t('dealLineage.title', 'Related documents')}
</h2> </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.')} {t('dealLineage.empty', 'No other documents share this deal yet. New invoices, contracts, or installments will show up here once created.')}
</p> </p>
</Card> </Card>
@@ -256,7 +256,7 @@ const Group: React.FC<{
action?: React.ReactNode; action?: React.ReactNode;
}> = ({ icon, label, count, children, action }) => ( }> = ({ icon, label, count, children, action }) => (
<div className="mb-3 last:mb-0"> <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} {icon}
<span>{label}</span> <span>{label}</span>
<span>({count})</span> <span>({count})</span>
@@ -282,19 +282,19 @@ const Row: React.FC<{
const inner = ( const inner = (
<div className="flex items-center justify-between gap-3 py-1.5"> <div className="flex items-center justify-between gap-3 py-1.5">
<div className="flex items-center gap-2 min-w-0"> <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 && ( {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"> <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} {badge}
</span> </span>
)} )}
{meta && ( {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>
<div className="flex items-center gap-2 shrink-0 text-xs"> <div className="flex items-center gap-2 shrink-0 text-xs">
{right && <span className="tabular-nums">{right}</span>} {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>
</div> </div>
); );
@@ -153,7 +153,7 @@ export const EditInstallmentPlanModal: React.FC<EditInstallmentPlanModalProps> =
<h2 className="text-xl font-semibold"> <h2 className="text-xl font-semibold">
{t('dealLineage.editPlanModalTitle', 'Edit installment plan')} {t('dealLineage.editPlanModalTitle', 'Edit installment plan')}
</h2> </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', {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.')} '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> </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 && ( {enabled && (
<> <>
<p className="text-xs text-muted-theme mb-3"> <p className="text-xs text-neutral-500 dark:text-neutral-400 mb-3">
{advanced {advanced
? t('installments.advancedHint', ? 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.') '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" 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"> <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', '%')} {t('installments.percent', '%')}
</label> </label>
<Input <Input
@@ -206,7 +206,7 @@ export const InstallmentsPanel: React.FC<InstallmentsPanelProps> = ({
/> />
</div> </div>
<div className="col-span-4"> <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')} {t('installments.label', 'Label')}
</label> </label>
<Input <Input
@@ -219,11 +219,11 @@ export const InstallmentsPanel: React.FC<InstallmentsPanelProps> = ({
{!advanced ? ( {!advanced ? (
<div className="col-span-5"> <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')} {t('installments.sendOn', 'Send on')}
</label> </label>
{row.trigger === 'after_delivery' ? ( {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', {t('installments.onDeliveryHint',
'On delivery — admin releases manually. Switch to advanced to change.')} 'On delivery — admin releases manually. Switch to advanced to change.')}
</div> </div>
@@ -242,7 +242,7 @@ export const InstallmentsPanel: React.FC<InstallmentsPanelProps> = ({
) : ( ) : (
<> <>
<div className="col-span-3"> <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')} {t('installments.trigger', 'Trigger')}
</label> </label>
<select <select
@@ -262,7 +262,7 @@ export const InstallmentsPanel: React.FC<InstallmentsPanelProps> = ({
</select> </select>
</div> </div>
<div className="col-span-2"> <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)')} {t('installments.offsetDays', 'Offset (days)')}
</label> </label>
<Input <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"> <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<Card className="max-w-md w-full"> <Card className="max-w-md w-full">
<div className="flex items-center justify-between mb-4"> <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')} {resultPassword ? t('events.passwordReset.newTitle') : t('events.passwordReset.title')}
</h2> </h2>
<button <button
onClick={onClose} 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" /> <X className="w-5 h-5" />
</button> </button>
@@ -163,12 +163,12 @@ export const PasswordResetModal: React.FC<PasswordResetModalProps> = ({
type="checkbox" type="checkbox"
checked={sendEmail} checked={sendEmail}
onChange={(e) => setSendEmail(e.target.checked)} 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-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Mail className="w-4 h-4 text-neutral-500" /> <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')} {t('events.passwordReset.sendEmail')}
</span> </span>
</div> </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 slotBeforeCustomCss
}) => { }) => {
const { t } = useTranslation(); 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 [localTheme, setLocalTheme] = useState<ThemeConfig>(value);
const [selectedPreset, setSelectedPreset] = useState(presetName); const [selectedPreset, setSelectedPreset] = useState(presetName);
const [customCss, setCustomCss] = useState(value.customCss || ''); const [customCss, setCustomCss] = useState(value.customCss || '');
@@ -915,6 +924,14 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
{/* Color Mode Selector */} {/* Color Mode Selector */}
<div className="mb-6"> <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 cant be overridden per gallery.')}
</div>
)}
{!forcedColorActive && (<>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2"> <label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('branding.colorMode', 'Color Mode')} {t('branding.colorMode', 'Color Mode')}
</label> </label>
@@ -969,6 +986,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
<p className="mt-1 text-sm text-neutral-500 dark:text-neutral-400"> <p className="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
{t('branding.colorModeHelp', 'Auto follows the visitor\'s system preference.')} {t('branding.colorModeHelp', 'Auto follows the visitor\'s system preference.')}
</p> </p>
</>)}
{/* {/*
* Force color mode (instance-wide). Lives next to the per-theme * 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 * same height so the four Surfaces and the two Accent rows align
* cleanly side-by-side. * cleanly side-by-side.
*/} */}
{!hideGalleryColors && (
<div className="space-y-6"> <div className="space-y-6">
{/* Surfaces */} {/* Surfaces */}
<div> <div>
@@ -1179,6 +1198,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
handleChange() no dedicated picker. */} handleChange() no dedicated picker. */}
</div> </div>
</div> </div>
)}
</Card> </Card>
{/* Typography & Style */} {/* Typography & Style */}
@@ -0,0 +1,224 @@
/**
* VAT-codes manager the single home for VAT codes + the ratecode /
* treatmentcode 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>
);
};
+20 -2
View File
@@ -21,6 +21,8 @@ export const DEFAULT_FLAGS: FeatureFlags = {
quotes: false, quotes: false,
bills: false, bills: false,
messaging: false, messaging: false,
// Incoming mail (migration 128) — IMAP intake. Standalone, default off.
incomingMail: false,
analytics: true, analytics: true,
userManagement: true, userManagement: true,
// Top-level Clients section (#354 follow-up). Migration 097 mirrors // 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 // Settings → Features once they've reviewed the seeded block
// library with their lawyer. // library with their lawyer.
contracts: false, 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 + // Projects (migration 120). Admin-only grouping layer above events +
// the Project Overview cockpit. Off by default — admin opts in under // the Project Overview cockpit. Off by default — admin opts in under
// Settings → Features once they want the CRM → Overview area. // Settings → Features once they want the CRM → Overview area.
@@ -80,7 +91,13 @@ function applyDependencyRules(flags: FeatureFlags): FeatureFlags {
out.galleries = true; // foundation — always on out.galleries = true; // foundation — always on
if (out.quotes === false) out.bills = false; // bills depend on quotes if (out.quotes === false) out.bills = false; // bills depend on quotes
if (out.calendar === false) out.calendarBooking = false; // booking depends on calendar 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 // Clients parent flag is DERIVED from its children. Admins don't
// toggle it directly — enabling any CRM-area sub-feature // toggle it directly — enabling any CRM-area sub-feature
// (Accounts today; future Calendar / Quotes / Bills / Messaging) // (Accounts today; future Calendar / Quotes / Bills / Messaging)
@@ -91,11 +108,12 @@ function applyDependencyRules(flags: FeatureFlags): FeatureFlags {
|| out.crmDevelopment || out.crmDevelopment
|| out.quotes || out.quotes
|| out.bills || out.bills
|| out.taxReport
|| out.hoursLogging || out.hoursLogging
|| out.contracts || out.contracts
// Migration 137 — admin calendar lights up the Clients section. // Migration 137 — admin calendar lights up the Clients section.
|| out.calendar || 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 // future siblings: || out.messaging
); );
return out; return out;
+1
View File
@@ -18,3 +18,4 @@ export { SEOTab } from './tabs/SEOTab';
export { ThumbnailsTab } from './tabs/ThumbnailsTab'; export { ThumbnailsTab } from './tabs/ThumbnailsTab';
export { ApiTokensTab } from './tabs/ApiTokensTab'; export { ApiTokensTab } from './tabs/ApiTokensTab';
export { WebhooksTab } from './tabs/WebhooksTab'; 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 / treatmentcode 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, Images,
BellRing, BellRing,
MessageSquare, MessageSquare,
Mailbox,
CalendarDays, CalendarDays,
FileSignature, FileSignature,
ScrollText, ScrollText,
@@ -16,6 +17,9 @@ import {
Briefcase, Briefcase,
Wrench, Wrench,
Calculator, Calculator,
Landmark,
ScanLine,
Wallet,
FolderKanban, FolderKanban,
} from 'lucide-react'; } from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -160,6 +164,21 @@ export const FeaturesTab: React.FC = () => {
onToggle={(next) => setFlag('reminderEmails', next)} 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 <FeatureCard
icon={MessageSquare} icon={MessageSquare}
title={t('settings.features.messaging.title', 'Messaging')} title={t('settings.features.messaging.title', 'Messaging')}
@@ -260,25 +279,6 @@ export const FeaturesTab: React.FC = () => {
onToggle={(next) => setFlag('bills', next)} 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 <FeatureCard
icon={Briefcase} icon={Briefcase}
title={t('settings.features.hoursLogging.title', 'Hours logging')} title={t('settings.features.hoursLogging.title', 'Hours logging')}
@@ -308,6 +308,82 @@ export const FeaturesTab: React.FC = () => {
/> />
</Section> </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 */} {/* Insights & Access */}
<Section title={t('settings.features.sections.insights', 'Insights & Access')}> <Section title={t('settings.features.sections.insights', 'Insights & Access')}>
<FeatureCard <FeatureCard
+365 -3
View File
@@ -98,6 +98,7 @@
"error": "Fehler", "error": "Fehler",
"configureInSettings": "Standards in den Einstellungen anpassen ↗", "configureInSettings": "Standards in den Einstellungen anpassen ↗",
"save": "Speichern", "save": "Speichern",
"saving": "Speichern…",
"cancel": "Abbrechen", "cancel": "Abbrechen",
"delete": "Löschen", "delete": "Löschen",
"edit": "Bearbeiten", "edit": "Bearbeiten",
@@ -196,6 +197,7 @@
"users": "Benutzer", "users": "Benutzer",
"calendar": "Kalender", "calendar": "Kalender",
"clients": "CRM", "clients": "CRM",
"accounting": "Buchhaltung",
"betaTag": "Beta" "betaTag": "Beta"
}, },
"eventTypes": { "eventTypes": {
@@ -1584,6 +1586,7 @@
"communication": "Kommunikation", "communication": "Kommunikation",
"scheduling": "Terminplanung", "scheduling": "Terminplanung",
"sales": "Vertrieb", "sales": "Vertrieb",
"accounting": "Buchhaltung",
"insights": "Auswertungen & Zugriff", "insights": "Auswertungen & Zugriff",
"customers": "Kunden", "customers": "Kunden",
"clients": "CRM" "clients": "CRM"
@@ -1600,6 +1603,10 @@
"description": "Der Kern von PicPeak. Immer verfügbar.", "description": "Der Kern von PicPeak. Immer verfügbar.",
"locked": "Galerien sind die Grundlage von PicPeak und können nicht deaktiviert werden." "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": { "reminderEmails": {
"title": "Erinnerungs-E-Mails", "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." "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" "sidebar": "Rechnungen"
}, },
"taxReport": { "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.", "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", "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": { "analytics": {
"title": "Statistiken", "title": "Statistiken",
@@ -1689,6 +1714,24 @@
}, },
"crm": { "crm": {
"title": "CRM-Verhalten" "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": { "branding": {
@@ -1913,6 +1956,8 @@
"forceColorModeNone": "Kein Zwang (Benutzerauswahl)", "forceColorModeNone": "Kein Zwang (Benutzerauswahl)",
"forceColorModeDark": "Dunkelmodus erzwingen", "forceColorModeDark": "Dunkelmodus erzwingen",
"forceColorModeLight": "Hellmodus 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", "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.", "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.", "backgroundColorHelp": "Die Seite selbst — Hintergrundfarbe jeder Galerie, Admin-Seite und CMS-Seite.",
@@ -2444,6 +2489,55 @@
"success": "Warteschlange geleert {{sent}} gesendet, {{failed}} fehlgeschlagen", "success": "Warteschlange geleert {{sent}} gesendet, {{failed}} fehlgeschlagen",
"empty": "Keine ausstehenden E-Mails zum Senden" "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": { "sentEmails": {
"tab": "Gesendete E-Mails", "tab": "Gesendete E-Mails",
"title": "Gesendete E-Mails", "title": "Gesendete E-Mails",
@@ -3382,6 +3476,180 @@
"development": "Entwicklung" "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": { "projects": {
"title": "Projektübersicht", "title": "Projektübersicht",
"subtitle": "Fasse Events zu Projekten zusammen und sieh jede E-Mail, jedes Dokument, jede Galerie und jede Stunde in einem Cockpit.", "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": { "taxReport": {
"title": "Steuerliste", "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.", "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" "currency": "Währung"
}, },
"exportPdf": "PDF exportieren", "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", "exportCsv": "CSV exportieren",
"exportFailed": "Export fehlgeschlagen. Bitte erneut versuchen.", "exportFailed": "Export fehlgeschlagen. Bitte erneut versuchen.",
"errorTitle": "Steuerliste konnte nicht geladen werden", "errorTitle": "Steuerliste konnte nicht geladen werden",
@@ -3638,6 +3969,32 @@
"grandTotalVat": "Gesamt MwSt.", "grandTotalVat": "Gesamt MwSt.",
"grandTotalGross": "Gesamt Brutto", "grandTotalGross": "Gesamt Brutto",
"cancelledFootnote": "{{count}} stornierte Rechnung(en) — Beträge nicht in den Summen enthalten (für lückenlose Nummernfolge dargestellt).", "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": { "col": {
"date": "Datum", "date": "Datum",
"invoice": "Rechnung", "invoice": "Rechnung",
@@ -3646,7 +4003,12 @@
"vatRate": "MwSt %", "vatRate": "MwSt %",
"net": "Netto", "net": "Netto",
"vat": "MwSt.", "vat": "MwSt.",
"total": "Brutto" "total": "Brutto",
"skonto": "Skonto",
"type": "Typ",
"reference": "Referenz",
"party": "Kunde / Lieferant",
"tax": "Steuer"
} }
}, },
"quotes": { "quotes": {
+365 -3
View File
@@ -98,6 +98,7 @@
"error": "Error", "error": "Error",
"configureInSettings": "Configure defaults in Settings ↗", "configureInSettings": "Configure defaults in Settings ↗",
"save": "Save", "save": "Save",
"saving": "Saving…",
"cancel": "Cancel", "cancel": "Cancel",
"delete": "Delete", "delete": "Delete",
"edit": "Edit", "edit": "Edit",
@@ -196,6 +197,7 @@
"users": "Users", "users": "Users",
"calendar": "Calendar", "calendar": "Calendar",
"clients": "CRM", "clients": "CRM",
"accounting": "Accounting",
"betaTag": "Beta" "betaTag": "Beta"
}, },
"archives": { "archives": {
@@ -1142,6 +1144,7 @@
"communication": "Communication", "communication": "Communication",
"scheduling": "Scheduling", "scheduling": "Scheduling",
"sales": "Sales", "sales": "Sales",
"accounting": "Accounting",
"insights": "Insights & Access", "insights": "Insights & Access",
"customers": "Customers", "customers": "Customers",
"clients": "CRM" "clients": "CRM"
@@ -1158,6 +1161,10 @@
"description": "The core PicPeak surface. Always available.", "description": "The core PicPeak surface. Always available.",
"locked": "Galleries are the foundation of PicPeak and can't be turned off." "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": { "reminderEmails": {
"title": "Reminder Emails", "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." "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" "sidebar": "Invoices"
}, },
"taxReport": { "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.", "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", "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": { "analytics": {
"title": "Analytics", "title": "Analytics",
@@ -1247,6 +1272,24 @@
}, },
"crm": { "crm": {
"title": "CRM behaviour" "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": { "analytics": {
@@ -1502,6 +1545,8 @@
"forceColorModeNone": "No force (user choice)", "forceColorModeNone": "No force (user choice)",
"forceColorModeDark": "Force dark", "forceColorModeDark": "Force dark",
"forceColorModeLight": "Force light", "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 cant be overridden per gallery.",
"colorGroupSurfaces": "Surfaces", "colorGroupSurfaces": "Surfaces",
"colorGroupSurfacesHelp": "The neutral layers behind your content. Background sits furthest back; Surface and Elevated stack on top.", "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.", "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", "success": "Email queue flushed — {{sent}} sent, {{failed}} failed",
"empty": "No pending emails to send" "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": { "sentEmails": {
"tab": "Sent emails", "tab": "Sent emails",
"title": "Sent emails", "title": "Sent emails",
@@ -3382,6 +3476,180 @@
"development": "Development" "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 clients 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": { "projects": {
"title": "Project Overview", "title": "Project Overview",
"subtitle": "Group events into projects and see every email, document, gallery and hour in one cockpit.", "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änders 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änders 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": { "taxReport": {
"title": "Tax report", "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.", "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" "currency": "Currency"
}, },
"exportPdf": "Export PDF", "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", "exportCsv": "Export CSV",
"exportFailed": "Export failed. Please try again.", "exportFailed": "Export failed. Please try again.",
"errorTitle": "Could not load tax report", "errorTitle": "Could not load tax report",
@@ -3638,6 +3969,32 @@
"grandTotalVat": "Total VAT", "grandTotalVat": "Total VAT",
"grandTotalGross": "Total gross", "grandTotalGross": "Total gross",
"cancelledFootnote": "{{count}} cancelled invoice(s) — amounts excluded from totals (shown for audit-trail continuity).", "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 costs 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 isnt configured, so VAT payable cant 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": { "col": {
"date": "Date", "date": "Date",
"invoice": "Invoice", "invoice": "Invoice",
@@ -3646,7 +4003,12 @@
"vatRate": "VAT %", "vatRate": "VAT %",
"net": "Net", "net": "Net",
"vat": "VAT", "vat": "VAT",
"total": "Gross" "total": "Gross",
"skonto": "Skonto",
"type": "Type",
"reference": "Reference",
"party": "Customer / supplier",
"tax": "Tax"
} }
}, },
"quotes": { "quotes": {
@@ -694,6 +694,7 @@ export const CreateEventPage: React.FC = () => {
onChange={handleThemeChange} onChange={handleThemeChange}
presetName={formData.theme_preset} presetName={formData.theme_preset}
onPresetChange={handlePresetChange} onPresetChange={handlePresetChange}
forceColorMode={publicSettings?.branding_force_color_mode ?? null}
showGalleryLayouts={true} showGalleryLayouts={true}
hideActions={true} hideActions={true}
onSyncFromBranding={() => { onSyncFromBranding={() => {
+68 -62
View File
@@ -39,7 +39,7 @@ type EditableFields =
| 'phone' | 'companyName' | 'billingEmail' | 'vatId' | 'phone' | 'companyName' | 'billingEmail' | 'vatId'
| 'addressLine1' | 'addressLine2' | 'postalCode' | 'city' | 'state' | 'addressLine1' | 'addressLine2' | 'postalCode' | 'city' | 'state'
| 'countryCode' | 'countryName' | 'preferredLanguage' | 'notes' | 'countryCode' | 'countryName' | 'preferredLanguage' | 'notes'
| 'featureCalendar' | 'featureQuotes' | 'featureBills' | 'featureHoursLogging' | 'featureCalendar' | 'featureQuotes' | 'featureBills' | 'featureHoursLogging' | 'featureContracts'
| 'hourlyRateMinor' | 'billingCadence' | 'billingCycleDay' | 'skontoDisabled'; | 'hourlyRateMinor' | 'billingCadence' | 'billingCycleDay' | 'skontoDisabled';
// `fmtDate` (from useLocalizedDate, below) is the single canonical date // `fmtDate` (from useLocalizedDate, below) is the single canonical date
@@ -137,6 +137,9 @@ export const CustomerDetailPage: React.FC = () => {
featureQuotes: customer.featureQuotes ?? false, featureQuotes: customer.featureQuotes ?? false,
featureBills: customer.featureBills ?? false, featureBills: customer.featureBills ?? false,
featureHoursLogging: customer.featureHoursLogging ?? 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, hourlyRateMinor: customer.hourlyRateMinor ?? null,
billingCadence: customer.billingCadence ?? 'per_event', billingCadence: customer.billingCadence ?? 'per_event',
billingCycleDay: customer.billingCycleDay ?? 1, billingCycleDay: customer.billingCycleDay ?? 1,
@@ -145,7 +148,7 @@ export const CustomerDetailPage: React.FC = () => {
} }
}, [customer, form]); }, [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); 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" className="p-2 -ml-2 rounded hover:bg-neutral-100 dark:hover:bg-neutral-700"
aria-label={t('common.back', 'Back')} 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> </Link>
<div className="min-w-0"> <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} {customer.displayName || customer.email}
</h1> </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> </div>
<div className="flex flex-col items-end gap-1"> <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')} {t('customers.passive.badge', 'Passive — admin only')}
</span> </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')} {t('customers.passive.activeLabel', 'Has portal access')}
</span> </span>
)} )}
@@ -339,16 +342,16 @@ export const CustomerDetailPage: React.FC = () => {
{/* Account section */} {/* Account section */}
<Card padding="lg"> <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')} <Mail className="w-5 h-5" /> {t('customers.detail.accountSection', 'Account')}
</h2> </h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <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')} /> <Input type="email" value={form.email || ''} onChange={setField('email')} />
</div> </div>
<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 <select
value={form.preferredLanguage || profileDefaultLocale} value={form.preferredLanguage || profileDefaultLocale}
onChange={setField('preferredLanguage')} onChange={setField('preferredLanguage')}
@@ -372,12 +375,12 @@ export const CustomerDetailPage: React.FC = () => {
{/* Personal section */} {/* Personal section */}
<Card padding="lg"> <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')} {t('customers.detail.personalSection', 'Personal information')}
</h2> </h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div> <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", {/* Salutation values are stored verbatim in the DB ("Herr",
"Frau", "Mx", "Dr") those are the canonical token values "Frau", "Mx", "Dr") those are the canonical token values
across locales. Display labels are translated; the value across locales. Display labels are translated; the value
@@ -397,25 +400,25 @@ export const CustomerDetailPage: React.FC = () => {
</select> </select>
</div> </div>
<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')} /> <Input value={form.firstName || ''} onChange={setField('firstName')} />
</div> </div>
<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')} /> <Input value={form.lastName || ''} onChange={setField('lastName')} />
</div> </div>
<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')} /> <Input value={form.displayName || ''} onChange={setField('displayName')} />
</div> </div>
<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')} <Phone className="w-4 h-4" /> {t('customers.detail.phone', 'Phone')}
</label> </label>
<Input value={form.phone || ''} onChange={setField('phone')} /> <Input value={form.phone || ''} onChange={setField('phone')} />
</div> </div>
<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')} <Building2 className="w-4 h-4" /> {t('customers.detail.company', 'Company')}
</label> </label>
<Input value={form.companyName || ''} onChange={setField('companyName')} /> <Input value={form.companyName || ''} onChange={setField('companyName')} />
@@ -434,10 +437,10 @@ export const CustomerDetailPage: React.FC = () => {
{/* Notes (admin-only) */} {/* Notes (admin-only) */}
<Card padding="lg"> <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')} <FileText className="w-5 h-5" /> {t('customers.detail.notesSection', 'Internal notes')}
</h2> </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.')} {t('customers.detail.notesHint', 'Visible only to admins. Never shown to the customer.')}
</p> </p>
<textarea <textarea
@@ -451,7 +454,7 @@ export const CustomerDetailPage: React.FC = () => {
{/* Assigned events */} {/* Assigned events */}
<Card padding="lg"> <Card padding="lg">
<div className="flex items-center justify-between gap-4 mb-4 flex-wrap"> <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')} <Calendar className="w-5 h-5" /> {t('customers.detail.eventsSection', 'Assigned events')}
</h2> </h2>
{/* Manage galleries: opens the multi-select dialog that {/* Manage galleries: opens the multi-select dialog that
@@ -470,17 +473,17 @@ export const CustomerDetailPage: React.FC = () => {
</Button> </Button>
</div> </div>
{customer.events.length === 0 ? ( {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.')} {t('customers.detail.noEvents', 'Not assigned to any events yet. Use "Manage galleries" to add some.')}
</p> </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) => ( {customer.events.map((ev) => (
<li key={ev.id} className="py-2 flex items-center justify-between"> <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} {ev.eventName}
</Link> </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.eventDate ? fmtDate(ev.eventDate) : ''}
{ev.expiresAt ? ` · ${t('customers.detail.expires', 'expires')} ${fmtDate(ev.expiresAt)}` : ''} {ev.expiresAt ? ` · ${t('customers.detail.expires', 'expires')} ${fmtDate(ev.expiresAt)}` : ''}
</span> </span>
@@ -506,36 +509,36 @@ export const CustomerDetailPage: React.FC = () => {
{/* Address + billing */} {/* Address + billing */}
<Card padding="lg"> <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')} <MapPin className="w-5 h-5" /> {t('customers.detail.billingSection', 'Address & billing')}
</h2> </h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <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')} /> <Input type="email" value={form.billingEmail || ''} onChange={setField('billingEmail')} />
</div> </div>
<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')} /> <Input value={form.vatId || ''} onChange={setField('vatId')} />
</div> </div>
<div className="md:col-span-2"> <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')} /> <Input value={form.addressLine1 || ''} onChange={setField('addressLine1')} />
</div> </div>
<div className="md:col-span-2"> <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')} /> <Input value={form.addressLine2 || ''} onChange={setField('addressLine2')} />
</div> </div>
<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')} /> <Input value={form.postalCode || ''} onChange={setField('postalCode')} />
</div> </div>
<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')} /> <Input value={form.city || ''} onChange={setField('city')} />
</div> </div>
<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')} /> <Input value={form.state || ''} onChange={setField('state')} />
</div> </div>
<div> <div>
@@ -570,13 +573,13 @@ export const CustomerDetailPage: React.FC = () => {
toggle inside it is OFF an empty "Customer features" card toggle inside it is OFF an empty "Customer features" card
with just a title + hint reads as broken. The Card reappears with just a title + hint reads as broken. The Card reappears
the moment any master flag is re-enabled. */} 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"> <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" /> <ToggleLeft className="w-5 h-5" />
{t('customers.detail.featuresSection', 'Customer features')} {t('customers.detail.featuresSection', 'Customer features')}
</h2> </h2>
<p className="text-xs text-muted-theme mb-4"> <p className="text-xs text-neutral-500 dark:text-neutral-400 mb-4">
{t( {t(
'customers.detail.featuresHint', '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.' '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 ...(flags.hoursLogging
? [{ key: 'featureHoursLogging' as const, labelKey: 'customers.field.featureHoursLogging', fallback: 'Hours logging', badge: 'new' as const }] ? [{ 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 }) => { ] as const).map(({ key, labelKey, fallback, badge }) => {
const enabled = !!form[key]; const enabled = !!form[key];
return ( return (
<label key={key} className="flex items-center justify-between gap-3 cursor-pointer"> <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)} {t(labelKey, fallback)}
{/* Status pill 'soon' = amber, 'new' = green. {/* Status pill 'soon' = amber, 'new' = green.
Colors match Settings Features StatusBadge so Colors match Settings Features StatusBadge so
@@ -633,8 +639,8 @@ export const CustomerDetailPage: React.FC = () => {
role="switch" role="switch"
aria-checked={enabled} aria-checked={enabled}
onClick={() => toggleFeature(key)} 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" 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={{ backgroundColor: enabled ? 'var(--color-accent)' : 'var(--color-surface-border)' }} style={enabled ? { backgroundColor: 'var(--color-accent)' } : undefined}
> >
<span <span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${enabled ? 'translate-x-6' : 'translate-x-1'}`} 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. */} per-entry basis from the standalone Hours logging page. */}
{flags.hoursLogging && form.featureHoursLogging && ( {flags.hoursLogging && form.featureHoursLogging && (
<div className="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-700"> <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')} {t('customers.field.hourlyRate', 'Default hourly rate')}
</label> </label>
<DecimalInput <DecimalInput
@@ -668,7 +674,7 @@ export const CustomerDetailPage: React.FC = () => {
placeholder="150.00" placeholder="150.00"
className="w-40 input" 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', {t('customers.field.hourlyRateHint',
'Major units (e.g. 150.00 for {{currency}} 150). Leave blank to require a per-entry override on every block.', 'Major units (e.g. 150.00 for {{currency}} 150). Leave blank to require a per-entry override on every block.',
{ currency: profileDefaultCurrency })} { currency: profileDefaultCurrency })}
@@ -687,17 +693,17 @@ export const CustomerDetailPage: React.FC = () => {
off admin has nothing to bill, so cadence is moot. */} off admin has nothing to bill, so cadence is moot. */}
{flags.bills && ( {flags.bills && (
<Card padding="lg"> <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" /> <Calendar className="w-5 h-5" />
{t('customers.billing.section', 'Billing cadence')} {t('customers.billing.section', 'Billing cadence')}
</h2> </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', {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.')} '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> </p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3"> <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div> <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')} {t('customers.billing.cadence', 'Billing cadence')}
</label> </label>
<select <select
@@ -713,7 +719,7 @@ export const CustomerDetailPage: React.FC = () => {
</div> </div>
{(form.billingCadence === 'monthly' || form.billingCadence === 'quarterly') && ( {(form.billingCadence === 'monthly' || form.billingCadence === 'quarterly') && (
<div> <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')} {t('customers.billing.cycleDay', 'Cycle day')}
</label> </label>
<input <input
@@ -724,7 +730,7 @@ export const CustomerDetailPage: React.FC = () => {
onChange={(e) => setForm((prev) => ({ ...prev, billingCycleDay: Number(e.target.value) } as any))} onChange={(e) => setForm((prev) => ({ ...prev, billingCycleDay: Number(e.target.value) } as any))}
className="input w-full" 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', {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).')} '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> </p>
@@ -735,7 +741,7 @@ export const CustomerDetailPage: React.FC = () => {
{/* Per-customer Skonto opt-out (migration 112). For B2B {/* Per-customer Skonto opt-out (migration 112). For B2B
customers who negotiated "no early-payment discount" set customers who negotiated "no early-payment discount" set
once instead of ticking the per-invoice toggle every time. */} 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 <input
type="checkbox" type="checkbox"
checked={!!form.skontoDisabled} checked={!!form.skontoDisabled}
@@ -744,7 +750,7 @@ export const CustomerDetailPage: React.FC = () => {
/> />
<span> <span>
{t('customers.billing.skontoDisabled', 'No Skonto for this customer')} {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', {t('customers.billing.skontoDisabledHint',
'Disables the early-payment discount on all of this customers invoices, regardless of template or global defaults.')} 'Disables the early-payment discount on all of this customers invoices, regardless of template or global defaults.')}
</span> </span>
@@ -759,14 +765,14 @@ export const CustomerDetailPage: React.FC = () => {
{(form.billingCadence === 'monthly' || form.billingCadence === 'manual') && monthlyDraft && monthlyDraft.lineItems.length > 0 && ( {(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="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-700">
<div className="flex items-center justify-between mb-2"> <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' {form.billingCadence === 'manual'
? t('customers.billing.draftPreview.titleManual', ? t('customers.billing.draftPreview.titleManual',
'Pending — ships on manual trigger') 'Pending — ships on manual trigger')
: t('customers.billing.draftPreview.title', : t('customers.billing.draftPreview.title',
'Pending in this month\'s bill')} 'Pending in this month\'s bill')}
</h3> </h3>
<span className="text-xs text-muted-theme"> <span className="text-xs text-neutral-500 dark:text-neutral-400">
{monthlyDraft.periodStart && monthlyDraft.periodEnd {monthlyDraft.periodStart && monthlyDraft.periodEnd
? t('customers.billing.draftPreview.periodRange', ? t('customers.billing.draftPreview.periodRange',
'{{number}} · {{from}} {{to}}', '{{number}} · {{from}} {{to}}',
@@ -800,10 +806,10 @@ export const CustomerDetailPage: React.FC = () => {
<tbody> <tbody>
{monthlyDraft.lineItems.map((li) => ( {monthlyDraft.lineItems.map((li) => (
<tr key={li.id} className="border-t border-neutral-200 dark:border-neutral-700"> <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"> <td className="px-3 py-1.5">
{li.parentPosition != null && ( {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} {li.description}
</td> </td>
@@ -857,7 +863,7 @@ export const CustomerDetailPage: React.FC = () => {
> >
{t('customers.billing.triggerNow', 'Trigger invoice now')} {t('customers.billing.triggerNow', 'Trigger invoice now')}
</Button> </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' {form.billingCadence === 'manual'
? t('customers.billing.triggerHintManual', ? 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.') '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 firing the standard portal-invitation email. We show ONE
card with the right action based on the customer's state. */} card with the right action based on the customer's state. */}
<Card padding="lg"> <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" /> <KeyRound className="w-5 h-5" />
{t('customers.detail.passwordSection', 'Account actions')} {t('customers.detail.passwordSection', 'Account actions')}
</h2> </h2>
{customer.isPassive ? ( {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( {t(
'customers.passive.detailHint', '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.', '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')} {t('customers.passive.sendInvite', 'Send portal invitation')}
</Button> </Button>
{!customer.isActive && ( {!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', {t('customers.passive.deactivatedHint',
'Reactivate the customer before sending the invitation.')} 'Reactivate the customer before sending the invitation.')}
</p> </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( {t(
'customers.detail.passwordHint', '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.' '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')} {t('customers.detail.passwordReset.button', 'Send password reset email')}
</Button> </Button>
{!customer.isActive && ( {!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.')} {t('customers.detail.passwordReset.inactive', 'Reactivate the customer before sending a reset.')}
</p> </p>
)} )}
@@ -996,15 +1002,15 @@ export const CustomerDetailPage: React.FC = () => {
{confirmDeactivate && ( {confirmDeactivate && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4"> <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="p-6">
<div className="flex items-start gap-3 mb-4"> <div className="flex items-start gap-3 mb-4">
<AlertTriangle className="w-5 h-5 mt-0.5 text-amber-500" /> <AlertTriangle className="w-5 h-5 mt-0.5 text-amber-500" />
<div> <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?')} {t('customers.deactivate.title', 'Deactivate customer?')}
</h2> </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', {t('customers.deactivate.body',
'They will no longer be able to log in. You can re-activate or fully erase them later.')} 'They will no longer be able to log in. You can re-activate or fully erase them later.')}
</p> </p>
@@ -1033,15 +1039,15 @@ export const CustomerDetailPage: React.FC = () => {
and audit-log references are preserved. */} and audit-log references are preserved. */}
{confirmErase && ( {confirmErase && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4"> <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="p-6">
<div className="flex items-start gap-3 mb-4"> <div className="flex items-start gap-3 mb-4">
<AlertTriangle className="w-5 h-5 mt-0.5 text-red-600" /> <AlertTriangle className="w-5 h-5 mt-0.5 text-red-600" />
<div> <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?')} {t('customers.erase.title', 'Erase customer data?')}
</h2> </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', {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.')} '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> </p>
@@ -118,16 +118,16 @@ export const CustomerManagementPage: React.FC = () => {
const display = c.displayName?.trim() const display = c.displayName?.trim()
|| [c.firstName, c.lastName].filter(Boolean).join(' ').trim() || [c.firstName, c.lastName].filter(Boolean).join(' ').trim()
|| c.companyName?.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 = () => ( 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 <button
type="button" type="button"
onClick={() => setActiveTab('customers')} onClick={() => setActiveTab('customers')}
className={`pb-3 -mb-px border-b-2 text-sm font-medium ${ 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')} {t('customers.tabs.customers', 'Customers')}
@@ -137,7 +137,7 @@ export const CustomerManagementPage: React.FC = () => {
type="button" type="button"
onClick={() => setActiveTab('invitations')} onClick={() => setActiveTab('invitations')}
className={`pb-3 -mb-px border-b-2 text-sm font-medium ${ 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')} {t('customers.tabs.invitations', 'Invitations')}
@@ -151,7 +151,7 @@ export const CustomerManagementPage: React.FC = () => {
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<div> <div>
<div className="flex items-center gap-2"> <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 {/* Beta badge Calendar/Quotes/Bills tabs in the customer
surface are placeholders, so flag the whole feature as surface are placeholders, so flag the whole feature as
still evolving. Keeps expectations honest. */} still evolving. Keeps expectations honest. */}
@@ -162,7 +162,7 @@ export const CustomerManagementPage: React.FC = () => {
{t('navigation.betaTag', 'Beta')} {t('navigation.betaTag', 'Beta')}
</span> </span>
</div> </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.')} {t('customers.pageSubtitle', 'Recurring customer accounts that can log in at /customer/login.')}
</p> </p>
</div> </div>
@@ -197,14 +197,14 @@ export const CustomerManagementPage: React.FC = () => {
{t('customers.loadError', 'Could not load customers')} {t('customers.loadError', 'Could not load customers')}
</div> </div>
) : filteredCustomers.length === 0 ? ( ) : 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.')} {t('customers.empty', 'No customers yet. Click "Invite customer" to add one.')}
</div> </div>
) : ( ) : (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="min-w-full text-sm"> <table className="min-w-full text-sm">
<thead> <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.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.email', 'Email')}</th>
<th className="px-3 py-2 font-medium">{t('customers.table.company', 'Company')}</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> </thead>
<tbody> <tbody>
{filteredCustomers.map((c) => ( {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"> <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)} {renderCustomerName(c)}
</Link> </Link>
</td> </td>
<td className="px-3 py-3 text-muted-theme">{c.email}</td> <td className="px-3 py-3 text-neutral-500 dark:text-neutral-400">{c.email}</td>
<td className="px-3 py-3 text-muted-theme">{c.companyName || '—'}</td> <td className="px-3 py-3 text-neutral-500 dark:text-neutral-400">{c.companyName || '—'}</td>
<td className="px-3 py-3 text-muted-theme">{c.eventCount ?? 0}</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-muted-theme">{formatDate(c.lastLogin)}</td> <td className="px-3 py-3 text-neutral-500 dark:text-neutral-400">{formatDate(c.lastLogin)}</td>
<td className="px-3 py-3"> <td className="px-3 py-3">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{c.isActive ? ( {c.isActive ? (
@@ -278,14 +278,14 @@ export const CustomerManagementPage: React.FC = () => {
{t('customers.loadInvitationsError', 'Could not load invitations')} {t('customers.loadInvitationsError', 'Could not load invitations')}
</div> </div>
) : filteredInvitations.length === 0 ? ( ) : 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.')} {t('customers.invitations.empty', 'No pending invitations.')}
</div> </div>
) : ( ) : (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="min-w-full text-sm"> <table className="min-w-full text-sm">
<thead> <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.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.invitedBy', 'Invited by')}</th>
<th className="px-3 py-2 font-medium">{t('customers.invitations.expiresAt', 'Expires')}</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> </thead>
<tbody> <tbody>
{filteredInvitations.map((inv: CustomerInvitationSummary) => ( {filteredInvitations.map((inv: CustomerInvitationSummary) => (
<tr key={inv.id} className="border-t" style={{ borderColor: 'var(--color-surface-border)' }}> <tr key={inv.id} className="border-t border-neutral-200 dark:border-neutral-700">
<td className="px-3 py-3 text-theme">{inv.email}</td> <td className="px-3 py-3 text-neutral-900 dark:text-neutral-100">{inv.email}</td>
<td className="px-3 py-3 text-muted-theme">{inv.invitedBy || '—'}</td> <td className="px-3 py-3 text-neutral-500 dark:text-neutral-400">{inv.invitedBy || '—'}</td>
<td className="px-3 py-3 text-muted-theme"> <td className="px-3 py-3 text-neutral-500 dark:text-neutral-400">
<span className="inline-flex items-center gap-1"> <span className="inline-flex items-center gap-1">
<Clock className="w-3.5 h-3.5" /> <Clock className="w-3.5 h-3.5" />
{formatDate(inv.expiresAt)} {formatDate(inv.expiresAt)}
</span> </span>
</td> </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"> <td className="px-3 py-3 text-right">
<Button <Button
type="button" type="button"
@@ -338,8 +338,7 @@ export const CustomerManagementPage: React.FC = () => {
onClick={() => setCreateMode(null)} onClick={() => setCreateMode(null)}
> >
<div <div
className="w-full max-w-2xl rounded-xl shadow-lg max-h-[90vh] overflow-y-auto" className="w-full max-w-2xl rounded-xl shadow-lg max-h-[90vh] overflow-y-auto bg-white dark:bg-neutral-900"
style={{ backgroundColor: 'var(--color-surface)' }}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<div className="p-6"> <div className="p-6">
@@ -359,17 +358,17 @@ export const CustomerManagementPage: React.FC = () => {
{confirm && ( {confirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4"> <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="p-6">
<div className="flex items-start gap-3 mb-4"> <div className="flex items-start gap-3 mb-4">
<AlertTriangle className="w-5 h-5 mt-0.5 text-amber-500" /> <AlertTriangle className="w-5 h-5 mt-0.5 text-amber-500" />
<div> <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' {confirm.kind === 'deactivate'
? t('customers.deactivate.title', 'Deactivate customer?') ? t('customers.deactivate.title', 'Deactivate customer?')
: t('customers.cancelInvitation.title', 'Cancel invitation?')} : t('customers.cancelInvitation.title', 'Cancel invitation?')}
</h2> </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' {confirm.kind === 'deactivate'
? t('customers.deactivate.body', ? t('customers.deactivate.body',
'They will no longer be able to log in. You can re-invite them later.') 'They will no longer be able to log in. You can re-invite them later.')
+21 -1
View File
@@ -19,6 +19,8 @@ import { Button, Input, Card, Loading } from '../../components/common';
import { EmailPreviewModal } from '../../components/admin/EmailPreviewModal'; import { EmailPreviewModal } from '../../components/admin/EmailPreviewModal';
import { EmailTemplateEditor } from '../../components/admin/EmailTemplateEditor'; import { EmailTemplateEditor } from '../../components/admin/EmailTemplateEditor';
import { SentEmailsPanel } from '../../components/admin/SentEmailsPanel'; 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 { Palette, RefreshCw, Info } from 'lucide-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { emailService, type EmailConfig, type EmailTemplate, type EmailTemplateTranslation } from '../../services/email.service'; 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 = () => { export const EmailConfigPage: React.FC = () => {
const { t } = useTranslation(); 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 [selectedTemplateKey, setSelectedTemplateKey] = useState<string>('gallery_created');
const [editedTemplate, setEditedTemplate] = useState<Partial<EmailTemplate>>({}); const [editedTemplate, setEditedTemplate] = useState<Partial<EmailTemplate>>({});
const [editingLang, setEditingLang] = useState<string>('en'); const [editingLang, setEditingLang] = useState<string>('en');
@@ -496,12 +498,27 @@ export const EmailConfigPage: React.FC = () => {
> >
{t('email.sentEmails.tab', 'Sent emails')} {t('email.sentEmails.tab', 'Sent emails')}
</button> </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> </nav>
</div> </div>
{/* Sent emails Tab */} {/* Sent emails Tab */}
{activeTab === 'sent' && <SentEmailsPanel />} {activeTab === 'sent' && <SentEmailsPanel />}
{/* Received emails Tab */}
{activeTab === 'received' && <ReceivedEmailsPanel />}
{/* SMTP Settings Tab */} {/* SMTP Settings Tab */}
{activeTab === 'smtp' && ( {activeTab === 'smtp' && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
@@ -800,6 +817,9 @@ export const EmailConfigPage: React.FC = () => {
)} )}
{/* Email Templates Tab */} {/* Email Templates Tab */}
{/* Incoming mail (IMAP) — a second block under SMTP, flag-gated. */}
{activeTab === 'smtp' && featureFlags.incomingMail && <IncomingMailConfigCard />}
{activeTab === 'templates' && ( {activeTab === 'templates' && (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<Card padding="sm"> <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> <h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('branding.themeAndStyle')}</h2>
<ThemeCustomizerEnhanced <ThemeCustomizerEnhanced
value={currentTheme || GALLERY_THEME_PRESETS.default.config} value={currentTheme || GALLERY_THEME_PRESETS.default.config}
forceColorMode={publicSettings?.branding_force_color_mode ?? null}
onChange={(theme) => { onChange={(theme) => {
setCurrentTheme(theme); setCurrentTheme(theme);
setEditForm(prev => ({ ...prev, color_theme: JSON.stringify(theme) })); setEditForm(prev => ({ ...prev, color_theme: JSON.stringify(theme) }));
+10 -4
View File
@@ -40,6 +40,7 @@ import {
ThumbnailsTab, ThumbnailsTab,
ApiTokensTab, ApiTokensTab,
WebhooksTab, WebhooksTab,
AccountingTab,
} from '../../features/settings'; } from '../../features/settings';
import { EmailConfigPage } from './EmailConfigPage'; import { EmailConfigPage } from './EmailConfigPage';
import { BrandingPage } from './BrandingPage'; import { BrandingPage } from './BrandingPage';
@@ -52,7 +53,7 @@ import { CrmSettingsPage } from './settings/CrmSettingsPage';
import { ReminderTemplatesPage } from './settings/ReminderTemplatesPage'; import { ReminderTemplatesPage } from './settings/ReminderTemplatesPage';
import { BlockLibraryPage } from './contracts/BlockLibraryPage'; import { BlockLibraryPage } from './contracts/BlockLibraryPage';
import { useFeatureFlags } from '../../contexts/FeatureFlagsContext'; 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 // Tab keys driving the inner-nav. Must include every key used in
// `navGroups` below and in the switch at the bottom of the component. // `navGroups` below and in the switch at the bottom of the component.
@@ -81,7 +82,8 @@ type TabType =
| 'businessProfile' | 'businessProfile'
| 'crm' | 'crm'
| 'contracts' | 'contracts'
| 'reminderTemplates'; | 'reminderTemplates'
| 'accounting';
interface NavItem { interface NavItem {
key: TabType; key: TabType;
@@ -101,7 +103,7 @@ const ALL_TAB_KEYS: TabType[] = [
'security', 'imageSecurity', 'seo', 'security', 'imageSecurity', 'seo',
'apiTokens', 'webhooks', 'apiTokens', 'webhooks',
'status', 'analytics', 'backup', 'status', 'analytics', 'backup',
'businessProfile', 'crm', 'contracts', 'reminderTemplates', 'businessProfile', 'crm', 'contracts', 'reminderTemplates', 'accounting',
]; ];
function isValidTab(value: string | null): value is TabType { function isValidTab(value: string | null): value is TabType {
@@ -251,6 +253,9 @@ export const SettingsPage: React.FC = () => {
...(flags.reminderEmails ...(flags.reminderEmails
? [{ key: 'reminderTemplates' as const, label: t('settings.reminderTemplates.title', 'Reminder emails'), icon: Mail }] ? [{ 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]); setActiveTab(visibleKeys[0]);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // 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 // For tabs that mount existing top-level pages OR bring their own
// header (FeaturesTab has its own icon+title+description block), skip // header (FeaturesTab has its own icon+title+description block), skip
@@ -414,6 +419,7 @@ export const SettingsPage: React.FC = () => {
{activeTab === 'crm' && <CrmSettingsPage />} {activeTab === 'crm' && <CrmSettingsPage />}
{activeTab === 'contracts' && <BlockLibraryPage />} {activeTab === 'contracts' && <BlockLibraryPage />}
{activeTab === 'reminderTemplates' && <ReminderTemplatesPage />} {activeTab === 'reminderTemplates' && <ReminderTemplatesPage />}
{activeTab === 'accounting' && <AccountingTab />}
{activeTab === 'status' && ( {activeTab === 'status' && (
<StatusTab <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 clients 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><div className="text-neutral-600 dark:text-neutral-300">{t('bills.field.eventName', 'Event')}</div>
<div> <div>
{inv.eventId ? ( {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.eventName}
{inv.eventDate ? ` · ${fmtDate(inv.eventDate)}` : ''} {inv.eventDate ? ` · ${fmtDate(inv.eventDate)}` : ''}
</div> </div>
@@ -14,6 +14,7 @@ import { quotesService } from '../../../services/quotes.service';
import { contractsService } from '../../../services/contracts.service'; import { contractsService } from '../../../services/contracts.service';
import { businessProfileService } from '../../../services/businessProfile.service'; import { businessProfileService } from '../../../services/businessProfile.service';
import { CustomerPicker } from '../../../components/admin/CustomerPicker'; import { CustomerPicker } from '../../../components/admin/CustomerPicker';
import { VatRateSelect } from '../../../components/admin/VatRateSelect';
import { LineItemsTable, type EditableLineItem } from '../../../components/admin/LineItemsTable'; import { LineItemsTable, type EditableLineItem } from '../../../components/admin/LineItemsTable';
import { InstallmentsPanel } from '../../../components/admin/InstallmentsPanel'; import { InstallmentsPanel } from '../../../components/admin/InstallmentsPanel';
import { customerAdminService } from '../../../services/customerAdmin.service'; 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.) // effect; new invoices start as null so they pick up profile.)
const [qrFormat, setQrFormat] = useState<InvoiceQrFormat | null>(null); const [qrFormat, setQrFormat] = useState<InvoiceQrFormat | null>(null);
const [vatRate, setVatRate] = useState(0); 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 [shipping, setShipping] = useState(0);
const [ccPdfEmail, setCcPdfEmail] = useState(''); const [ccPdfEmail, setCcPdfEmail] = useState('');
const [lineItems, setLineItems] = useState<EditableLineItem[]>([]); const [lineItems, setLineItems] = useState<EditableLineItem[]>([]);
@@ -137,6 +140,8 @@ export const BillEditorPage: React.FC = () => {
// it inherits the profile default at render time. // it inherits the profile default at render time.
setQrFormat((inv.qrFormat as InvoiceQrFormat | null) || null); setQrFormat((inv.qrFormat as InvoiceQrFormat | null) || null);
setVatRate(Number(inv.vatRate || 0)); 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); setShipping(Number(inv.shippingAmountMinor || 0) / 100);
setCcPdfEmail(inv.ccPdfEmail || ''); setCcPdfEmail(inv.ccPdfEmail || '');
setPaymentTermTemplateId(inv.paymentTermTemplateId ?? null); setPaymentTermTemplateId(inv.paymentTermTemplateId ?? null);
@@ -280,6 +285,7 @@ export const BillEditorPage: React.FC = () => {
const { quote, lineItems: quoteLineItems } = await quotesService.get(contract.sourceQuoteId); const { quote, lineItems: quoteLineItems } = await quotesService.get(contract.sourceQuoteId);
if (quote.currency) setCurrency(quote.currency); if (quote.currency) setCurrency(quote.currency);
if (quote.vatRate != null) setVatRate(Number(quote.vatRate)); if (quote.vatRate != null) setVatRate(Number(quote.vatRate));
setVatCode((quote as { vatCode?: string | null }).vatCode ?? null);
if (quote.shippingAmountMinor != null) { if (quote.shippingAmountMinor != null) {
setShipping(Number(quote.shippingAmountMinor) / 100); setShipping(Number(quote.shippingAmountMinor) / 100);
} }
@@ -373,6 +379,7 @@ export const BillEditorPage: React.FC = () => {
// which is the same outcome but pollutes the column. // which is the same outcome but pollutes the column.
qrFormat: qrFormat || undefined, qrFormat: qrFormat || undefined,
vatRate, vatRate,
vatCode,
shippingAmountMinor: toMinor(shipping), shippingAmountMinor: toMinor(shipping),
ccPdfEmail: ccPdfEmail || undefined, ccPdfEmail: ccPdfEmail || undefined,
// Payment-term template id (migration 113). null = no template // 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} <LineItemsTable items={lineItems} currency={currency} showDiscount={false}
vatRate={vatRate / 100} shippingAmount={shipping} onChange={setLineItems} /> vatRate={vatRate / 100} shippingAmount={shipping} onChange={setLineItems} />
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 mt-4"> <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} <VatRateSelect
value={vatRate} onChange={(e) => setVatRate(Number(e.target.value))} /> 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} <Input type="number" step="0.01" label={t('bills.field.shipping', 'Shipping') as string}
value={shipping} onChange={(e) => setShipping(Number(e.target.value))} /> value={shipping} onChange={(e) => setShipping(Number(e.target.value))} />
<div> <div>
@@ -61,7 +61,7 @@ export const BillsListPage: React.FC = () => {
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<div> <div>
<div className="flex items-center gap-2"> <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. */} {/* Beta badge — matches the Customers + Quotes pages. */}
<span <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" 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')} {t('navigation.betaTag', 'Beta')}
</span> </span>
</div> </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.')} {t('bills.subtitle', 'Schedule, send, track payments and chase late invoices.')}
</p> </p>
</div> </div>
@@ -122,7 +122,7 @@ export const BillsListPage: React.FC = () => {
{/* Body inside the same card (matches Customers + Quotes). */} {/* Body inside the same card (matches Customers + Quotes). */}
<div className="mt-4"> <div className="mt-4">
{isLoading ? <Loading /> : !data || data.invoices.length === 0 ? ( {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="rounded-lg border border-neutral-200 dark:border-neutral-700 overflow-hidden">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
@@ -173,11 +173,11 @@ export const BillsListPage: React.FC = () => {
<td className="px-3 py-2 truncate max-w-xs"> <td className="px-3 py-2 truncate max-w-xs">
{inv.eventName {inv.eventName
? (inv.eventId ? (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) : inv.eventName)
: '—'} : '—'}
</td> </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 || ''}` : '—'} {inv.installmentTotal > 1 ? `${inv.installmentIndex + 1}/${inv.installmentTotal} · ${inv.installmentLabel || ''}` : '—'}
</td> </td>
<td className="px-3 py-2 whitespace-nowrap">{inv.issueDate ? fmtDate(inv.issueDate) : '—'}</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="space-y-4">
<div className="flex items-center justify-between flex-wrap gap-3"> <div className="flex items-center justify-between flex-wrap gap-3">
<div> <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')} {t('calendar.pageTitle', 'Calendar')}
</h1> </h1>
<p className="text-sm text-muted-theme"> <p className="text-sm text-neutral-500 dark:text-neutral-400">
{t('calendar.subtitle', {t('calendar.subtitle',
'Events, logged hours, and pending quotes/contracts in one view.')} 'Events, logged hours, and pending quotes/contracts in one view.')}
</p> </p>
@@ -466,7 +466,7 @@ export const CalendarPage: React.FC = () => {
<Card padding="md"> <Card padding="md">
{itemsLoading && !itemsResp && ( {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 /> <Loading />
<span>{t('calendar.loading', 'Loading items…')}</span> <span>{t('calendar.loading', 'Loading items…')}</span>
</div> </div>
@@ -609,6 +609,11 @@ export const CalendarPage: React.FC = () => {
and the active state (selected view) all read from these and the active state (selected view) all read from these
vars so the calendar respects custom branding palettes. */} vars so the calendar respects custom branding palettes. */}
<style>{` <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 { .cal-dashed {
border-style: dashed !important; border-style: dashed !important;
background-color: transparent !important; background-color: transparent !important;
@@ -624,8 +629,8 @@ export const CalendarPage: React.FC = () => {
/* FC button restyle — match picpeak admin .btn-outline shape. */ /* FC button restyle — match picpeak admin .btn-outline shape. */
.fc .fc-button-primary { .fc .fc-button-primary {
background-color: transparent; background-color: transparent;
border: 1px solid var(--color-surface-border); border: 1px solid var(--cal-border);
color: var(--color-muted-text); color: var(--cal-muted);
text-transform: none; text-transform: none;
font-weight: 500; font-weight: 500;
box-shadow: none; box-shadow: none;
@@ -634,8 +639,8 @@ export const CalendarPage: React.FC = () => {
.fc .fc-button-primary:hover:not(:disabled) { .fc .fc-button-primary:hover:not(:disabled) {
opacity: 0.8; opacity: 0.8;
background-color: transparent; background-color: transparent;
border-color: var(--color-surface-border); border-color: var(--cal-border);
color: var(--color-muted-text); color: var(--cal-muted);
} }
.fc .fc-button-primary:focus, .fc .fc-button-primary:focus,
.fc .fc-button-primary:focus-visible { .fc .fc-button-primary:focus-visible {
@@ -652,14 +657,14 @@ export const CalendarPage: React.FC = () => {
.fc .fc-button-primary:disabled { .fc .fc-button-primary:disabled {
opacity: 0.5; opacity: 0.5;
background-color: transparent; background-color: transparent;
border-color: var(--color-surface-border); border-color: var(--cal-border);
color: var(--color-muted-text); color: var(--cal-muted);
} }
/* Toolbar title (month / week range) reads the regular text /* Toolbar title (month / week range) reads the regular text
colour rather than FC's hardcoded #333 so dark mode looks colour rather than FC's hardcoded #333 so dark mode looks
right. */ right. */
.fc .fc-toolbar-title { .fc .fc-toolbar-title {
color: var(--color-text); color: var(--cal-text);
font-size: 1.125rem; font-size: 1.125rem;
font-weight: 600; font-weight: 600;
} }
@@ -677,12 +682,12 @@ export const CalendarPage: React.FC = () => {
selector that maps to a day column so the rule works selector that maps to a day column so the rule works
regardless of which DOM shape FC ends up rendering. */ regardless of which DOM shape FC ends up rendering. */
.fc { .fc {
--fc-border-color: var(--color-surface-border); --fc-border-color: var(--cal-border);
} }
.fc .fc-timegrid-col:not(:first-of-type), .fc .fc-timegrid-col:not(:first-of-type),
.fc .fc-day:not(:first-child), .fc .fc-day:not(:first-child),
.fc .fc-timegrid-cols > table > tbody > tr > td: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> `}</style>
</div> </div>
@@ -698,7 +703,7 @@ const Legend: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Card padding="sm"> <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_EVENT} label={t('calendar.legend.events', 'Events')} />
<LegendSwatch color={COLOR_HOURS} label={t('calendar.legend.hours', 'Hours')} /> <LegendSwatch color={COLOR_HOURS} label={t('calendar.legend.hours', 'Hours')} />
<LegendSwatch <LegendSwatch
@@ -239,14 +239,14 @@ export const CrmDevelopmentPage: React.FC = () => {
<MailCheck className="w-4 h-4" /> <MailCheck className="w-4 h-4" />
{t('crmDev.paymentCheck.title', 'Test payment-check email (real invoice)')} {t('crmDev.paymentCheck.title', 'Test payment-check email (real invoice)')}
</h3> </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', {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.')} '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> </p>
{invoiceListLoading ? <Loading /> : ( {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')} {t('crmDev.paymentCheck.selectInvoice', 'Sent or overdue invoice')}
</label> </label>
<select <select
@@ -262,7 +262,7 @@ export const CrmDevelopmentPage: React.FC = () => {
))} ))}
</select> </select>
{invoiceList && invoiceList.invoices.length === 0 && ( {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', {t('crmDev.paymentCheck.noneAvailable',
'No sent or overdue invoices in the database.')} 'No sent or overdue invoices in the database.')}
</p> </p>
@@ -285,7 +285,7 @@ export const CrmDevelopmentPage: React.FC = () => {
<Mail className="w-4 h-4" /> <Mail className="w-4 h-4" />
{t('crmDev.templates.title', 'Send any CRM email to me (mock data)')} {t('crmDev.templates.title', 'Send any CRM email to me (mock data)')}
</h3> </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', {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.')} '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> </p>
@@ -294,12 +294,12 @@ export const CrmDevelopmentPage: React.FC = () => {
// Env gate banner above already explains the situation — // Env gate banner above already explains the situation —
// keep this card empty rather than rendering a misleading // keep this card empty rather than rendering a misleading
// "0 templates" list. // "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', {t('crmDev.envDisabled.cardHint',
'Email templates can\'t be listed until the dev-tools env gate is opened.')} 'Email templates can\'t be listed until the dev-tools env gate is opened.')}
</p> </p>
) : (templates && templates.length === 0) ? ( ) : (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', {t('crmDev.templates.empty',
'No CRM email templates found — run migrations to seed them.')} 'No CRM email templates found — run migrations to seed them.')}
</p> </p>
@@ -323,7 +323,7 @@ export const CrmDevelopmentPage: React.FC = () => {
</div> </div>
<div className="text-sm font-medium mt-0.5">{title}</div> <div className="text-sm font-medium mt-0.5">{title}</div>
{description && ( {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> </div>
<Button <Button
@@ -238,7 +238,7 @@ export const HourEntryDragCreateModal: React.FC<HourEntryDragCreateModalProps> =
<h2 className="font-semibold text-lg mb-1"> <h2 className="font-semibold text-lg mb-1">
{t('calendar.hourEntry.createTitle', 'Log hours')} {t('calendar.hourEntry.createTitle', 'Log hours')}
</h2> </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 {/* The pre-filled range is part of the page state, not editable
from this modal. Admin can edit start/end after creating from this modal. Admin can edit start/end after creating
via the inline-edit popover (also in this commit). */} 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"> <h2 className="font-semibold text-lg">
{item.customerName || t('calendar.hourEntry.untitledCustomer', 'Hours')} {item.customerName || t('calendar.hourEntry.untitledCustomer', 'Hours')}
</h2> </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} {item.entryDate} · {item.startTime}{item.endTime}
</p> </p>
</div> </div>
@@ -166,7 +166,7 @@ export const HourEntryInlinePopover: React.FC<HourEntryInlinePopoverProps> = ({
// here so the admin can't accidentally type into a locked // here so the admin can't accidentally type into a locked
// entry. The invoice link is omitted for now — clicking // entry. The invoice link is omitted for now — clicking
// through to the bill belongs on a follow-up commit. // 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.')} {item.description || t('calendar.hourEntry.noDescription', 'No description.')}
</p> </p>
) : ( ) : (
@@ -91,7 +91,7 @@ export const HoursLoggingPage: React.FC = () => {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<div className="flex items-center gap-2"> <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')} {t('hoursLogging.title', 'Hours logging')}
</h1> </h1>
{/* Beta badge matches Customers + Quotes + Contracts + {/* Beta badge matches Customers + Quotes + Contracts +
@@ -104,7 +104,7 @@ export const HoursLoggingPage: React.FC = () => {
{t('navigation.betaTag', 'Beta')} {t('navigation.betaTag', 'Beta')}
</span> </span>
</div> </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', {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.')} '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> </p>
@@ -112,7 +112,7 @@ export const HoursLoggingPage: React.FC = () => {
</div> </div>
<Card padding="lg"> <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')} {t('hoursLogging.pickCustomer', 'Customer')}
</label> </label>
<CustomerPicker <CustomerPicker
@@ -158,22 +158,22 @@ export const HoursLoggingPage: React.FC = () => {
{!selectedId && ( {!selectedId && (
<Card padding="lg"> <Card padding="lg">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<Clock className="w-4 h-4 text-muted-theme" /> <Clock className="w-4 h-4 text-neutral-500 dark:text-neutral-400" />
<h2 className="text-base font-semibold text-theme"> <h2 className="text-base font-semibold text-neutral-900 dark:text-neutral-100">
{t('hoursLogging.openHours.title', 'Open hours across all customers')} {t('hoursLogging.openHours.title', 'Open hours across all customers')}
</h2> </h2>
</div> </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', {t('hoursLogging.openHours.subtitle',
'Unbilled time blocks waiting to be billed. Pick a customer above, or click a row to drill in.')} 'Unbilled time blocks waiting to be billed. Pick a customer above, or click a row to drill in.')}
</p> </p>
{summaryLoading ? ( {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…')} {t('common.loading', 'Loading…')}
</p> </p>
) : summary.length === 0 ? ( ) : 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', {t('hoursLogging.openHours.empty',
'No unbilled hours right now — everything is billed or no time has been logged yet.')} 'No unbilled hours right now — everything is billed or no time has been logged yet.')}
</p> </p>
@@ -188,14 +188,14 @@ export const HoursLoggingPage: React.FC = () => {
> >
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-2"> <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 && ( {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"> <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')} {t('hoursLogging.openHours.passive', 'Passive')}
</span> </span>
)} )}
</div> </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', { {t('hoursLogging.openHours.entryLine', {
count: r.entryCount, count: r.entryCount,
hours: (r.totalMinutes / 60).toFixed(2), 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="flex items-center gap-3 shrink-0">
<div className="text-right"> <div className="text-right">
{r.rateResolvable ? ( {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)} {formatMoneyMinor(r.openAmountMinor, currency)}
</div> </div>
) : ( ) : (
@@ -216,7 +216,7 @@ export const HoursLoggingPage: React.FC = () => {
</div> </div>
)} )}
</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> </div>
</button> </button>
))} ))}
@@ -17,7 +17,8 @@
import React, { useMemo, useState } from 'react'; import React, { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query'; 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'; import { Button, Card, Loading, LocalizedDateInput } from '../../../components/common';
// Lightweight native select styled to match Input — the common barrel // Lightweight native select styled to match Input — the common barrel
@@ -26,9 +27,13 @@ import { Button, Card, Loading, LocalizedDateInput } from '../../../components/c
const selectClassName = 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'; '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 { 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 { useLocalizedDate } from '../../../hooks/useLocalizedDate';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
const LEDGER_FORMATS: ExportFormat[] = ['generic', 'banana', 'banana_ie', 'bexio'];
type PeriodPreset = 'thisYear' | 'lastYear' | 'thisQuarter' | 'lastQuarter' | 'custom'; type PeriodPreset = 'thisYear' | 'lastYear' | 'thisQuarter' | 'lastQuarter' | 'custom';
function isoDate(d: Date): string { function isoDate(d: Date): string {
@@ -89,13 +94,22 @@ function triggerBrowserDownload(url: string, filename: string) {
export const TaxReportPage: React.FC = () => { export const TaxReportPage: React.FC = () => {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const { flags } = useFeatureFlags();
const { format: fmtDate } = useLocalizedDate(); const { format: fmtDate } = useLocalizedDate();
const [preset, setPreset] = useState<PeriodPreset>('thisYear'); const [preset, setPreset] = useState<PeriodPreset>('thisYear');
const initialPeriod = useMemo(() => periodForPreset('thisYear'), []); const initialPeriod = useMemo(() => periodForPreset('thisYear'), []);
const [from, setFrom] = useState(initialPeriod.from); const [from, setFrom] = useState(initialPeriod.from);
const [to, setTo] = useState(initialPeriod.to); const [to, setTo] = useState(initialPeriod.to);
const [currency, setCurrency] = useState<string>('CHF'); 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) => { const onPresetChange = (next: PeriodPreset) => {
setPreset(next); setPreset(next);
@@ -120,9 +134,10 @@ export const TaxReportPage: React.FC = () => {
const handleExport = async (format: 'pdf' | 'csv') => { const handleExport = async (format: 'pdf' | 'csv') => {
setIsExporting(format); setIsExporting(format);
try { try {
const exportParams = { ...params, scope: exportScope };
const { url, filename } = format === 'pdf' const { url, filename } = format === 'pdf'
? await taxReportService.downloadPdfUrl(params) ? await taxReportService.downloadPdfUrl(exportParams)
: await taxReportService.downloadCsvUrl(params); : await taxReportService.downloadCsvUrl(exportParams);
triggerBrowserDownload(url, filename); triggerBrowserDownload(url, filename);
} catch (err) { } catch (err) {
toast.error(t('taxReport.exportFailed', 'Export failed. Please try again.')); 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 // 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 // regardless of document language, so we default to de-CH (the only
// Intl locale producing apostrophe thousands). Non-DACH operators // 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 // rates in the period. With a single rate the breakdown is just a
// restatement of the grand totals — pure noise. // restatement of the grand totals — pure noise.
const showPerRateBreakdown = (report?.totalsByVatRate.length || 0) > 1; 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -232,25 +299,99 @@ export const TaxReportPage: React.FC = () => {
</select> </select>
</div> </div>
<div className="flex flex-wrap items-center justify-end gap-2 pt-1"> {/* Export area two clearly-separated groups so it's obvious
<Button what each file is and who it's for: the human-readable Report
variant="outline" (PDF/CSV) and the accounting Journal (for the Treuhänder). The
onClick={() => handleExport('csv')} Journal group only shows when the accounting layer is on, since
disabled={exportsDisabled} it needs the Chart-of-accounts mapping. */}
isLoading={isExporting === 'csv'} <div className="pt-3 mt-1 border-t border-neutral-200 dark:border-neutral-700 space-y-3">
leftIcon={<FileDown className="w-4 h-4" />} {/* Group 1 — Report (for you) */}
> <div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1">
{t('taxReport.exportCsv', 'Export CSV')} <div className="min-w-0">
</Button> <div className="text-xs font-semibold uppercase tracking-wider text-neutral-500 dark:text-neutral-400">
<Button {t('taxReport.export.reportTitle', 'Report')}
variant="primary" </div>
onClick={() => handleExport('pdf')} <div className="text-[11px] text-neutral-400 dark:text-neutral-500">
disabled={exportsDisabled} {t('taxReport.export.reportHint', 'Readable list — for your own records.')}
isLoading={isExporting === 'pdf'} </div>
leftIcon={<Download className="w-4 h-4" />} </div>
> <div className="flex flex-wrap items-center gap-2">
{t('taxReport.exportPdf', 'Export PDF')} <label className="flex items-center gap-1.5 text-xs font-medium text-neutral-500 dark:text-neutral-400">
</Button> {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}
isLoading={isExporting === 'csv'}
leftIcon={<FileDown className="w-4 h-4" />}
>
{t('taxReport.exportCsv', 'Export CSV')}
</Button>
<Button
className="min-w-[150px]"
variant="primary"
onClick={() => handleExport('pdf')}
disabled={exportsDisabled}
isLoading={isExporting === 'pdf'}
leftIcon={<Download className="w-4 h-4" />}
>
{t('taxReport.exportPdf', 'Export PDF')}
</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>
</div> </div>
</Card> </Card>
@@ -260,8 +401,11 @@ export const TaxReportPage: React.FC = () => {
there are 2+ rates in the period (otherwise it duplicates there are 2+ rates in the period (otherwise it duplicates
the grand totals). Cancelled footnote at the bottom when the grand totals). Cancelled footnote at the bottom when
applicable. */} applicable. */}
{report && report.rows.length > 0 && ( {hasAnyData && report && (
<Card padding="md"> <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="space-y-1.5 text-sm">
<div className="flex justify-between gap-3"> <div className="flex justify-between gap-3">
<span className="text-neutral-700 dark:text-neutral-300">{t('taxReport.grandTotalNet', 'Total net')}</span> <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>
</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 isnt configured, so VAT payable cant be computed. Set it under Settings → Accounting.')}</span>
</p>
)}
</div>
</div>
)}
{showPerRateBreakdown && ( {showPerRateBreakdown && (
<div className="mt-4 pt-3 border-t border-neutral-200 dark:border-neutral-700"> <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"> <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> </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 */} {/* Results */}
{isLoading ? ( {isLoading ? (
<Card padding="lg"><Loading /></Card> <Card padding="lg"><Loading /></Card>
@@ -335,7 +538,7 @@ export const TaxReportPage: React.FC = () => {
</div> </div>
</div> </div>
</Card> </Card>
) : !report || report.rows.length === 0 ? ( ) : !hasAnyData ? (
<Card padding="lg"> <Card padding="lg">
<p className="text-center text-sm text-neutral-600 dark:text-neutral-400"> <p className="text-center text-sm text-neutral-600 dark:text-neutral-400">
{t('taxReport.empty', 'No invoices in this period.')} {t('taxReport.empty', 'No invoices in this period.')}
@@ -343,9 +546,10 @@ export const TaxReportPage: React.FC = () => {
</Card> </Card>
) : ( ) : (
<> <>
{/* Table full width below the filter + totals row above. {report && report.ledger.length > 0 && (
The totals card now lives in the top-right of the page /* Unified ledger (#5) one typed, signed, sortable table.
header so this section is purely the invoice list. */} Outgoing invoices are positive; incoming invoices + expenses
negative so the amount columns net toward the Result. */
<Card padding="none"> <Card padding="none">
{/* Two nested wrappers: the OUTER clips the header row's {/* Two nested wrappers: the OUTER clips the header row's
solid fill so the top corners stay rounded (matches 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"> <thead className="bg-neutral-50 dark:bg-neutral-900 text-neutral-700 dark:text-neutral-300">
<tr> <tr>
<th className="px-2 py-2 text-right font-medium w-10">#</th> <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">
<th className="px-2 py-2 text-left font-medium whitespace-nowrap">{t('taxReport.col.invoice', 'Invoice')}</th> <button type="button" onClick={() => toggleSort('type')} className="font-medium hover:text-primary-600 dark:hover:text-primary-400">
<th className="px-2 py-2 text-left font-medium">{t('taxReport.col.customer', 'Customer')}</th> {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-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-left font-medium whitespace-nowrap">{t('taxReport.col.tax', 'Tax')}</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">
<th className="px-2 py-2 text-right font-medium whitespace-nowrap">{t('taxReport.col.vat', 'VAT')}</th> <button type="button" onClick={() => toggleSort('net')} className="font-medium hover:text-primary-600 dark:hover:text-primary-400">
<th className="px-2 py-2 text-right font-medium whitespace-nowrap">{t('taxReport.col.total', 'Gross')}</th> {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> <th className="px-2 py-2 text-right font-medium whitespace-nowrap">{t('taxReport.col.skonto', 'Skonto')}</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-neutral-200 dark:divide-neutral-800"> <tbody className="divide-y divide-neutral-200 dark:divide-neutral-800">
{report.rows.map((row, i) => ( {sortedLedger.map((row, i) => (
<tr <tr
key={row.id} key={row.key}
className={row.isCancelled className={row.isCancelled
? 'text-neutral-400 dark:text-neutral-500 italic' ? 'text-neutral-400 dark:text-neutral-500 italic'
: 'text-neutral-900 dark:text-neutral-100'} : '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 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"> <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 && ( {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"> <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')} {t('taxReport.statusCancelled', 'Cancelled')}
@@ -389,35 +629,38 @@ export const TaxReportPage: React.FC = () => {
)} )}
{/* Storno + Reissue lineage markers parity {/* Storno + Reissue lineage markers parity
with the admin invoices list so the same with the admin invoices list so the same
colour scheme distinguishes the three row colour scheme distinguishes the row kinds at
kinds at a glance across both surfaces. */} a glance across both surfaces. */}
{row.kind === 'storno' && ( {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')} {t('bills.kind.storno', 'Storno')}
</span> </span>
)} )}
{row.isReissue && ( {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')} {t('bills.kind.reissue', 'Reissue')}
</span> </span>
)} )}
{row.replacedByInvoiceNumber && (
<span className="ml-1 text-xs text-neutral-500 dark:text-neutral-400 not-italic">
{row.replacedByInvoiceNumber}
</span>
)}
</td> </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.party}>{row.party}</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 truncate max-w-[180px]" title={row.eventName}>
<td className="px-2 py-1.5 text-right tabular-nums whitespace-nowrap">{Number(row.vatRate).toFixed(1)}%</td> {row.eventName || (row.type !== 'outgoing'
<td className="px-2 py-1.5 text-right tabular-nums whitespace-nowrap"> ? <span className="text-neutral-400 dark:text-neutral-500">{t('taxReport.cost.company', 'Company')}</span>
{formatMinor(row.netMinor, row.currency, intlLocale)} : '')}
</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>
<td className="px-2 py-1.5 text-right tabular-nums whitespace-nowrap"> <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>
<td className="px-2 py-1.5 text-right tabular-nums whitespace-nowrap font-medium"> <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>
<td className="px-2 py-1.5 text-right tabular-nums whitespace-nowrap" <td className="px-2 py-1.5 text-right tabular-nums whitespace-nowrap"
title={row.skontoApplied title={row.skontoApplied
@@ -425,7 +668,7 @@ export const TaxReportPage: React.FC = () => {
: undefined}> : undefined}>
{row.skontoApplied ? ( {row.skontoApplied ? (
<span className="text-teal-700 dark:text-teal-300"> <span className="text-teal-700 dark:text-teal-300">
{formatMinor(row.skontoAmountMinor, row.currency, intlLocale)} {formatMinor(row.skontoAmountMinor, report.currency, intlLocale)}
</span> </span>
) : ''} ) : ''}
</td> </td>
@@ -436,6 +679,23 @@ export const TaxReportPage: React.FC = () => {
</div> </div>
</div> </div>
</Card> </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 costs tax treatment — verify with your Treuhänder / tax authority before filing.',
)}
</span>
</p>
)}
</> </>
)} )}
</div> </div>
@@ -243,10 +243,10 @@ export const BlockLibraryPage: React.FC = () => {
<ArrowLeft className="w-4 h-4" /> <ArrowLeft className="w-4 h-4" />
{t('contracts.blocks.back', 'Back to contracts')} {t('contracts.blocks.back', 'Back to contracts')}
</Link> </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')} {t('contracts.blocks.title', 'Contract block library')}
</h1> </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)} /> <input type="checkbox" checked={hideInactive} onChange={(e) => setHideInactive(e.target.checked)} />
{t('contracts.blocks.hideInactive', 'Hide inactive')} {t('contracts.blocks.hideInactive', 'Hide inactive')}
</label> </label>
@@ -340,7 +340,7 @@ export const BlockLibraryPage: React.FC = () => {
); );
})} })}
{blocks.length === 0 && ( {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.')} {t('contracts.blocks.empty', 'No blocks yet.')}
</p> </p>
)} )}
@@ -352,7 +352,7 @@ export const BlockLibraryPage: React.FC = () => {
<div className="lg:col-span-2"> <div className="lg:col-span-2">
{selection === null ? ( {selection === null ? (
<Card padding="md"> <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.')} {t('contracts.blocks.selectPrompt', 'Select a block on the left or create a new one to start editing.')}
</p> </p>
</Card> </Card>
@@ -366,7 +366,7 @@ export const BlockLibraryPage: React.FC = () => {
</h3> </h3>
<div className="flex gap-2 items-center flex-wrap"> <div className="flex gap-2 items-center flex-wrap">
{selection.mode === 'edit' && ( {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 <input
type="checkbox" type="checkbox"
checked={selection.block.isActive} checked={selection.block.isActive}
@@ -590,7 +590,7 @@ export const ContractDetailPage: React.FC = () => {
{c.convertedEventId && ( {c.convertedEventId && (
<Card padding="md" className="mb-4"> <Card padding="md" className="mb-4">
<p className="text-sm"> <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')}: {t('contracts.detail.convertedToEvent', 'Converted to event')}:
</span> </span>
<Link to={`/admin/events/${c.convertedEventId}`} className="font-medium text-primary-600 dark:text-primary-400 hover:underline"> <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 className="flex items-center justify-between mb-6">
<div> <div>
<div className="flex items-center gap-2"> <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. */} {/* Beta badge — matches Customers + Quotes + Invoices. */}
<span <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" 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')} {t('navigation.betaTag', 'Beta')}
</span> </span>
</div> </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.')} {t('contracts.subtitle', 'Compose contracts from reusable blocks and have customers sign in-browser or upload a wet-signed PDF.')}
</p> </p>
</div> </div>
@@ -129,7 +129,7 @@ export const ContractsListPage: React.FC = () => {
single-card layout used by Customers / Quotes / Invoices. */} single-card layout used by Customers / Quotes / Invoices. */}
<div className="mt-4"> <div className="mt-4">
{isLoading ? <Loading /> : !data || data.contracts.length === 0 ? ( {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="rounded-lg border border-neutral-200 dark:border-neutral-700 overflow-hidden">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
@@ -179,7 +179,7 @@ export const ContractsListPage: React.FC = () => {
</div> </div>
{totalPages > 1 && ( {totalPages > 1 && (
<div className="flex justify-between items-center px-3 py-2 border-t border-neutral-200 dark:border-neutral-700 text-sm"> <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', { {t('contracts.list.pagination', 'Page {{page}} of {{total}} · {{count}} contracts', {
page, total: totalPages, count: data.total, page, total: totalPages, count: data.total,
})} })}
@@ -25,6 +25,7 @@ import {
import { LineItemsTable, type EditableLineItem } from '../../../components/admin/LineItemsTable'; import { LineItemsTable, type EditableLineItem } from '../../../components/admin/LineItemsTable';
import { CustomerPicker } from '../../../components/admin/CustomerPicker'; import { CustomerPicker } from '../../../components/admin/CustomerPicker';
import { ProjectSelect } from '../../../components/admin/ProjectSelect'; import { ProjectSelect } from '../../../components/admin/ProjectSelect';
import { VatRateSelect } from '../../../components/admin/VatRateSelect';
import { InstallmentsPanel } from '../../../components/admin/InstallmentsPanel'; import { InstallmentsPanel } from '../../../components/admin/InstallmentsPanel';
import { customerAdminService } from '../../../services/customerAdmin.service'; import { customerAdminService } from '../../../services/customerAdmin.service';
import { userManagementService } from '../../../services/userManagement.service'; import { userManagementService } from '../../../services/userManagement.service';
@@ -54,6 +55,8 @@ interface FormState {
paymentNetDaysTemplateId: number | null; paymentNetDaysTemplateId: number | null;
paymentTimingTemplateId: number | null; paymentTimingTemplateId: number | null;
vatRate: number; vatRate: number;
/** Migration 130 — snapshot of the chosen output VAT code (null = custom rate). */
vatCode: string | null;
shippingAmount: number; shippingAmount: number;
introText: string; introText: string;
outroText: string; outroText: string;
@@ -85,6 +88,7 @@ const empty: FormState = {
paymentNetDaysTemplateId: null, paymentNetDaysTemplateId: null,
paymentTimingTemplateId: null, paymentTimingTemplateId: null,
vatRate: 0, vatRate: 0,
vatCode: null,
shippingAmount: 0, shippingAmount: 0,
introText: '', introText: '',
outroText: '', outroText: '',
@@ -121,6 +125,7 @@ function buildPayload(f: FormState): QuoteCreatePayload {
// installments on the snapshot. Sent only when populated. // installments on the snapshot. Sent only when populated.
installments: f.installments && f.installments.length > 0 ? f.installments : undefined, installments: f.installments && f.installments.length > 0 ? f.installments : undefined,
vatRate: f.vatRate, vatRate: f.vatRate,
vatCode: f.vatCode,
shippingAmountMinor: toMinor(f.shippingAmount), shippingAmountMinor: toMinor(f.shippingAmount),
introText: f.introText || undefined, introText: f.introText || undefined,
outroText: f.outroText || undefined, outroText: f.outroText || undefined,
@@ -214,6 +219,7 @@ export const QuoteEditorPage: React.FC = () => {
paymentNetDaysTemplateId: q.paymentNetDaysTemplateId, paymentNetDaysTemplateId: q.paymentNetDaysTemplateId,
paymentTimingTemplateId: q.paymentTimingTemplateId, paymentTimingTemplateId: q.paymentTimingTemplateId,
vatRate: Number(q.vatRate || 0), vatRate: Number(q.vatRate || 0),
vatCode: (q as { vatCode?: string | null }).vatCode ?? null,
shippingAmount: Number(q.shippingAmountMinor || 0) / 100, shippingAmount: Number(q.shippingAmountMinor || 0) / 100,
introText: q.introText || '', introText: q.introText || '',
outroText: q.outroText || '', outroText: q.outroText || '',
@@ -533,9 +539,11 @@ export const QuoteEditorPage: React.FC = () => {
onChange={(items) => setForm((f) => ({ ...f, lineItems: items }))} onChange={(items) => setForm((f) => ({ ...f, lineItems: items }))}
/> />
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 mt-4"> <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} <VatRateSelect
value={form.vatRate} label={t('quotes.field.vatRate', 'VAT rate %') as string}
onChange={(e) => setForm((f) => ({ ...f, vatRate: Number(e.target.value) }))} /> 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} <Input type="number" step="0.01" label={t('quotes.field.shipping', 'Shipping amount') as string}
value={form.shippingAmount} value={form.shippingAmount}
onChange={(e) => setForm((f) => ({ ...f, shippingAmount: Number(e.target.value) }))} /> 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 className="flex items-center justify-between mb-6">
<div> <div>
<div className="flex items-center gap-2"> <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 {/* Beta badge feature is functional but the surface is
still evolving (matches Customers + Invoices). */} still evolving (matches Customers + Invoices). */}
<span <span
@@ -63,7 +63,7 @@ export const QuotesListPage: React.FC = () => {
{t('navigation.betaTag', 'Beta')} {t('navigation.betaTag', 'Beta')}
</span> </span>
</div> </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.')} {t('quotes.subtitle', 'Send, track and convert quotes into events.')}
</p> </p>
</div> </div>
@@ -104,7 +104,7 @@ export const QuotesListPage: React.FC = () => {
single-card layout used by Customers/Invitations. */} single-card layout used by Customers/Invitations. */}
<div className="mt-4"> <div className="mt-4">
{isLoading ? <Loading /> : !data || data.quotes.length === 0 ? ( {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="rounded-lg border border-neutral-200 dark:border-neutral-700 overflow-hidden">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
@@ -147,7 +147,7 @@ export const QuotesListPage: React.FC = () => {
</div> </div>
{data.pagination.totalPages > 1 && ( {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"> <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', { {t('quotes.pagination', 'Page {{page}} of {{total}} · {{count}} quotes', {
page: data.pagination.page, total: data.pagination.totalPages, count: data.pagination.total, page: data.pagination.page, total: data.pagination.totalPages, count: data.pagination.total,
})} })}
@@ -177,7 +177,7 @@ export const CrmSettingsPage: React.FC = () => {
{!anySection && ( {!anySection && (
<Card> <Card>
<p className="text-sm text-muted-theme"> <p className="text-sm text-neutral-500 dark:text-neutral-400">
{t('crmSettings.emptyState', {t('crmSettings.emptyState',
'No CRM features are currently enabled. Turn on Quotes, Invoices, or Contracts in Settings → Features to see the matching configuration here.')} 'No CRM features are currently enabled. Turn on Quotes, Invoices, or Contracts in Settings → Features to see the matching configuration here.')}
</p> </p>

Some files were not shown because too many files have changed in this diff Show More