diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index bf98c108..6aa4d1d5 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -134,7 +134,10 @@ jobs: platforms: ${{ matrix.platform }} labels: ${{ steps.meta-backend.outputs.labels }} cache-from: type=gha,scope=backend-${{ env.PLATFORM_PAIR }} - cache-to: type=gha,mode=max,scope=backend-${{ env.PLATFORM_PAIR }} + # ignore-error: a flaky GitHub Actions cache write ("error writing + # layer blob: not_found") must not fail an otherwise-successful build + # that already pushed the image. + cache-to: type=gha,mode=max,scope=backend-${{ env.PLATFORM_PAIR }},ignore-error=true outputs: ${{ steps.push-decision.outputs.push == 'true' && format('type=image,name={0}/{1},push-by-digest=true,name-canonical=true,push=true', env.REGISTRY, env.BACKEND_IMAGE_NAME) || 'type=cacheonly' }} build-args: | CACHEBUST=${{ github.run_number }} @@ -360,7 +363,10 @@ jobs: platforms: ${{ matrix.platform }} labels: ${{ steps.meta-frontend.outputs.labels }} cache-from: type=gha,scope=frontend-${{ env.PLATFORM_PAIR }} - cache-to: type=gha,mode=max,scope=frontend-${{ env.PLATFORM_PAIR }} + # ignore-error: a flaky GitHub Actions cache write ("error writing + # layer blob: not_found") must not fail an otherwise-successful build + # that already pushed the image. + cache-to: type=gha,mode=max,scope=frontend-${{ env.PLATFORM_PAIR }},ignore-error=true outputs: ${{ steps.push-decision.outputs.push == 'true' && format('type=image,name={0}/{1},push-by-digest=true,name-canonical=true,push=true', env.REGISTRY, env.FRONTEND_IMAGE_NAME) || 'type=cacheonly' }} build-args: | CACHEBUST=${{ github.run_number }} diff --git a/.github/workflows/install-smoke.yml b/.github/workflows/install-smoke.yml index f93ef6bf..b1c655e3 100644 --- a/.github/workflows/install-smoke.yml +++ b/.github/workflows/install-smoke.yml @@ -61,7 +61,8 @@ jobs: load: true tags: picpeak-backend:smoke cache-from: type=gha,scope=install-smoke - cache-to: type=gha,mode=max,scope=install-smoke + # ignore-error: a flaky GHA cache write must not fail the build. + cache-to: type=gha,mode=max,scope=install-smoke,ignore-error=true - name: Create Docker network run: docker network create picpeak-smoke diff --git a/.gitignore b/.gitignore index 52f31cf6..956c3b23 100644 --- a/.gitignore +++ b/.gitignore @@ -129,3 +129,6 @@ docker-compose.dev.yml # New layout development files new-layouts/ + +# Generated CRM/accounting documents (runtime) — never commit +backend/storage/business-docs/ diff --git a/README.md b/README.md index aacc29c5..e571ba24 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,14 @@ Unlike expensive SaaS solutions, PicPeak gives you: - 🛡️ **Security First** - JWT auth, rate limiting, CORS protection - 📈 **Scalable** - From small studios to large agencies +### For Studios — CRM & Accounting (Beta · off by default) +- 📝 **Quotes → Contracts → Invoices** - One deal lineage; cancel-and-reissue (Storno) keeps issued invoices immutable +- ⏱️ **Hours Logging & Calendar** - Per-customer time tracking; admin calendar of events, logged hours, and pending quotes/contracts +- 🧾 **Inbound Supplier Invoices & Expenses** - Capture received invoices (upload/camera, rasterised server-side), categorise, and re-bill costs to clients +- 📊 **Tax Report & Accountant Export** - Period-scoped income/cost report with VAT breakdown; PDF/CSV plus a Treuhänder/Banana (Swiss/LI) journal export, scopable to income-only or cost-only +- 🌍 **VAT & Multi-currency** - Single VAT-code registry snapshotted onto each document; data-driven per-country rates +- ⚠️ **Verify locally** - Feature-flagged off by default. Seeded contracts, QR/IBAN and tax defaults are **examples only** — review your own legal **and tax** regulations first (see disclaimers below) + ## 🚀 Quick Start Get PicPeak running in under 5 minutes: @@ -401,7 +409,7 @@ These features are currently in beta testing and may have limited functionality | Feature | Description | Status | |---------|-------------|--------| -| **CRM Module** | Quotes, contracts, invoices, hours logging, calendar, and tax report — feature-flagged off by default. Seeded contract blocks, payment terms, and IBAN / tax defaults are **examples only** and need legal / financial review before customer-facing use. See [docs.picpeak.app/features/crm](https://docs.picpeak.app/features/crm). | 🧪 Beta | +| **CRM & Accounting Module** | Quotes, contracts, invoices (+ Storno), hours logging, calendar, and tax report — plus inbound supplier-invoice capture, internal expenses, and a Treuhänder/Banana (Swiss/LI) accountant-journal export. Feature-flagged off by default. Seeded contract blocks, payment terms, IBAN / QR-bill and tax defaults are **examples only** and need legal / financial / **tax** review before customer-facing use. See [docs.picpeak.app/features/crm](https://docs.picpeak.app/features/crm). | 🧪 Beta | | **Simple Deployment Script** | One-click deployment script for quick server setup with automated configuration and dependency installation | 🧪 Beta | ### 📋 Future Enhancements @@ -440,7 +448,7 @@ PicPeak is inspired by the best features of commercial platforms while remaining A huge thank you to the people whose code, reports, and feedback have shaped PicPeak: -- [**@Luca-Timo**](https://github.com/Luca-Timo) — native Apple Silicon multi-arch images, external-URL toggle for legal CMS pages, the lazy-loaded folder tree picker, the admin-email picker on event creation, the data-driven self-hosted webfont system, the gallery header/banner decoupling, and several typed-API refactors. Consistently raises the bar with thoughtful PRs. +- [**@Luca-Timo**](https://github.com/Luca-Timo) — native Apple Silicon multi-arch images, external-URL toggle for legal CMS pages, the lazy-loaded folder tree picker, the admin-email picker on event creation, the data-driven self-hosted webfont system, the gallery header/banner decoupling, several typed-API refactors, and the CRM + accounting suite (quotes/contracts/invoices, hours logging, calendar, tax report, inbound supplier-invoice capture, expenses, and the Treuhänder/Banana export). Consistently raises the bar with thoughtful PRs. - [**@Rekoo-PS**](https://github.com/Rekoo-PS) — sharp-eyed bug reporter and product feedback. Filed the issues that drove the login-loop fix, the gallery-loading skeleton work, the redirection cleanup, the mobile-lightbox overhaul, the admin-events search-counter fix, the photo-count column, and the bulk-delete workflow. Also a [BuyMeACoffee](https://buymeacoffee.com/theluap) supporter — the kind of feedback loop that keeps the project useful for real deployments. If you've contributed and aren't listed here, please open a PR — this list is meant to grow. @@ -455,10 +463,11 @@ This project was generated with the assistance of AI technology, but has been: We believe in transparent development practices and the responsible use of AI as a tool to accelerate development while maintaining high standards of quality and security. -## ⚠️ CRM disclaimers — examples only +## ⚠️ CRM & Accounting disclaimers — examples only, verify locally -The CRM module (contracts, invoices, QR-bills) ships seeded content -that is intended as a **starting point only**: +The CRM & accounting modules (contracts, invoices, QR-bills, the tax +report and the accountant exports) ship seeded content and computed +figures that are intended as a **starting point only**: - **Contract blocks** (image rights, NDA, model release, cancellation, jurisdiction, …) are written by the maintainer, **not by a lawyer**. @@ -468,9 +477,19 @@ that is intended as a **starting point only**: typed. Picpeak is open source — please scan a test invoice with your bank's app to check the QR actually works. We are not responsible for any mistakes that come from sending an invoice with bad data on it. +- **Tax, VAT & accounting figures** (the tax report, VAT-payable, the + per-rate breakdown, the Treuhänder / Banana export, etc.) are computed + from the data you enter and the defaults you configure. They are + **guidance only and jurisdiction-specific** — tax rules, VAT rates, + deduction schemes (e.g. the Liechtenstein 20 % Gewinnungskosten flat + rate) and filing duties differ by country and change over time. **Every + operator must check their own tax / VAT regulations and verify the + numbers with their accountant / Treuhänder / tax authority before + relying on any figure or export.** Picpeak makes no warranty that the + output is correct for your jurisdiction or situation. Read [`docs/crm-disclaimers.md`](docs/crm-disclaimers.md) before -enabling the Contracts or Invoices features. +enabling the Contracts, Invoices or Accounting features. ## 📄 License diff --git a/backend/Dockerfile b/backend/Dockerfile index 7192433d..1042a217 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -50,8 +50,13 @@ RUN npm install -g npm@10 # Unicode fallback; picpeak's own brand fonts (assets/fonts/, the same files # PDFKit + the web UI use) are registered with fontconfig further down so the # logo's text renders in its actual typeface, not a fallback. +# poppler-utils provides `pdftoppm`, used to rasterise inbound supplier-invoice +# PDFs to flat PNGs server-side so the admin UI NEVER renders a raw (possibly +# malicious) PDF. pdftoppm does not execute embedded JS or fetch remote +# resources, so it doubles as the SSRF/phone-home guard for untrusted inbound +# documents (see docs/accounting-inbound-invoices.md). RUN apk add --no-cache dumb-init postgresql-client ffmpeg su-exec \ - fontconfig ttf-dejavu ttf-liberation && \ + fontconfig ttf-dejavu ttf-liberation poppler-utils && \ fc-cache -f # Create non-root user diff --git a/backend/__tests__/routes/adminCrmAuth.test.js b/backend/__tests__/routes/adminCrmAuth.test.js index 567f0a6f..3365994c 100644 --- a/backend/__tests__/routes/adminCrmAuth.test.js +++ b/backend/__tests__/routes/adminCrmAuth.test.js @@ -72,7 +72,9 @@ describe('admin CRM routes — auth + permission gate', () => { // of permissions, so for happy-path tests we flip every CRM flag // on. Negative tests (no-token, bad-signature) hit adminAuth // first and never reach the flag check, so they're unaffected. - const crmFlags = ['quotes', 'bills', 'contracts', 'hoursLogging', 'calendar', 'taxReport', 'clients']; + // `accounting` is the master flag the tax-report route now requires + // (tax export moved out of CRM into Accounting, independent of bills). + const crmFlags = ['quotes', 'bills', 'contracts', 'hoursLogging', 'calendar', 'taxReport', 'clients', 'accounting']; for (const key of crmFlags) { // eslint-disable-next-line no-await-in-loop await db('feature_flags').where({ key }).update({ value: 1 }); diff --git a/backend/__tests__/services/expenseService.markup.test.js b/backend/__tests__/services/expenseService.markup.test.js new file mode 100644 index 00000000..a78a538e --- /dev/null +++ b/backend/__tests__/services/expenseService.markup.test.js @@ -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); + }); +}); diff --git a/backend/__tests__/services/ledgerService.test.js b/backend/__tests__/services/ledgerService.test.js new file mode 100644 index 00000000..5ad53bcd --- /dev/null +++ b/backend/__tests__/services/ledgerService.test.js @@ -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$/); + }); +}); diff --git a/backend/__tests__/services/rasterizePageCap.test.js b/backend/__tests__/services/rasterizePageCap.test.js new file mode 100644 index 00000000..cd5715b7 --- /dev/null +++ b/backend/__tests__/services/rasterizePageCap.test.js @@ -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' }); + }); +}); diff --git a/backend/__tests__/services/taxReportPdf.test.js b/backend/__tests__/services/taxReportPdf.test.js index 9838a7da..8b78c6a2 100644 --- a/backend/__tests__/services/taxReportPdf.test.js +++ b/backend/__tests__/services/taxReportPdf.test.js @@ -22,19 +22,26 @@ function makeChain(initialRows) { }, leftJoin: jest.fn(function () { return this; }), where: jest.fn(function () { return this; }), + whereNot: jest.fn(function () { return this; }), whereIn: jest.fn(function () { return this; }), + whereNotIn: jest.fn(function () { return this; }), whereBetween: jest.fn(function () { return this; }), + whereRaw: jest.fn(function () { return this; }), orderBy: jest.fn(function () { return this; }), + orderByRaw: jest.fn(function () { return this; }), select: jest.fn(function () { return Promise.resolve(this._rows); }), }; } const mockDbFn = jest.fn((tableName) => { - callCount += 1; // Route by table name when supplied — the Skonto aggregate (added - // by migration 126) queries `invoice_payment_log`; everything else - // (main listing, replacements lookup) hits `invoices`. + // by migration 126) queries `invoice_payment_log`; the #4 cost side + // queries `inbound_documents` + `expenses`; everything else (main + // listing, replacements lookup) hits `invoices`. if (tableName === 'invoice_payment_log') return makeChain([]); + if (tableName === 'inbound_documents') return makeChain([]); + if (tableName === 'expenses') return makeChain([]); + callCount += 1; if (callCount === 1) return makeChain(invoiceRowsForRun); return makeChain(replacementsRowsForRun); }); @@ -42,6 +49,10 @@ const mockDbFn = jest.fn((tableName) => { // COALESCE (migration 123). The chain's select() ignores its // arguments so the raw() return value just needs to exist. mockDbFn.raw = jest.fn((sql) => sql); +// #4 loadCosts schema-guards each cost table; default the PDF/CSV +// fixtures to "no accounting tables" so these renderers exercise the +// revenue path unchanged. +mockDbFn.schema = { hasTable: jest.fn(async () => false) }; jest.mock('../../src/database/db', () => ({ db: mockDbFn, @@ -182,8 +193,11 @@ describe('renderTaxReportCsv', () => { expect(contentType).toMatch(/text\/csv/); expect(filename).toBe('tax_report_2026-01-01_to_2026-03-31_CHF.csv'); const lines = content.split('\r\n'); - expect(lines[0]).toContain('Rechnung'); // de header for tax_col_invoice - expect(lines[0]).toContain('Kunde'); + // Unified ledger CSV: Typ / Referenz / Kunde-Lieferant columns replace the + // old Rechnung/Kunde split. + expect(lines[0]).toContain('Typ'); // de header for tax_col_type + expect(lines[0]).toContain('Referenz'); // de header for tax_col_reference + expect(lines[0]).toContain('Kunde'); // "Kunde / Lieferant" expect(lines[0]).toContain('Netto'); }); @@ -223,19 +237,17 @@ describe('renderTaxReportCsv', () => { expect(content).toMatch(/"161\.55"/); }); - it('marks cancelled rows with a 1 in the cancelled column', async () => { + it('flags a cancelled row with a "(Cancelled)" suffix on its Reference cell', async () => { invoiceRowsForRun = [ SAMPLE_ROW({ status: 'cancelled' }), ]; const { content } = await taxReportService.renderTaxReportCsv({ from: '2026-01-01', to: '2026-03-31', currency: 'CHF', locale: 'en', }); - // Migration 126 added a trailing Skonto column. The cancelled - // marker is now second-to-last; the Skonto cell is empty for - // non-Skonto rows. Asserting on a regex keeps the test stable - // against future trailing-column additions. + // Unified ledger CSV has no separate cancelled column — a cancelled row is + // flagged by appending the localised "(Cancelled)" tag to its Reference. const dataRow = content.split('\r\n')[1]; - expect(/"1","[^"]*"$/.test(dataRow)).toBe(true); + expect(dataRow).toContain('R-2026-0001 (Cancelled)'); }); it('uses CRLF line endings (RFC 4180) and BOM-free body', async () => { diff --git a/backend/__tests__/services/taxReportService.test.js b/backend/__tests__/services/taxReportService.test.js index a6fcb5b7..5a87fd39 100644 --- a/backend/__tests__/services/taxReportService.test.js +++ b/backend/__tests__/services/taxReportService.test.js @@ -22,6 +22,9 @@ let invoiceRowsForRun = []; let replacementsRowsForRun = []; +let inboundRowsForRun = []; +let expenseRowsForRun = []; +let costTablesPresent = false; let callCount = 0; function makeChain(initialRows) { @@ -32,24 +35,35 @@ function makeChain(initialRows) { }, leftJoin: jest.fn(function () { return this; }), where: jest.fn(function () { return this; }), + whereNot: jest.fn(function () { return this; }), whereIn: jest.fn(function () { return this; }), + whereNotIn: jest.fn(function () { return this; }), whereBetween: jest.fn(function () { return this; }), + whereRaw: jest.fn(function () { return this; }), orderBy: jest.fn(function () { return this; }), + orderByRaw: jest.fn(function () { return this; }), select: jest.fn(function () { return Promise.resolve(this._rows); }), }; return c; } const mockDbFn = jest.fn((tableName) => { - callCount += 1; // Migration 126 added a Skonto aggregate that hits // `invoice_payment_log` — route those explicitly to an empty list so // the test surface stays focused on the invoices/replacements flow. if (tableName === 'invoice_payment_log') return makeChain([]); - // First call: main listing. Second call: replacements lookup. + // Cost side (#4): incoming invoices + internal expenses. + if (tableName === 'inbound_documents') return makeChain(inboundRowsForRun); + if (tableName === 'expenses') return makeChain(expenseRowsForRun); + // `invoices` is queried for the main listing (call 1) and, when there + // are cancelled rows, the replacements lookup (call 2). + callCount += 1; if (callCount === 1) return makeChain(invoiceRowsForRun); return makeChain(replacementsRowsForRun); }); +// loadCosts (#4) schema-guards each cost table. Default off so the +// revenue-only tests are unaffected; cost-side tests flip it on. +mockDbFn.schema = { hasTable: jest.fn(async () => costTablesPresent) }; // `.raw()` is used in the .select() column list for the event_name // COALESCE (migration 123). The chain's select() ignores its // arguments and returns the mocked rows, so the raw() return value @@ -67,6 +81,9 @@ const { grossUpLateFee, computeReportedAmounts, buildCustomerLabel } = taxReport beforeEach(() => { invoiceRowsForRun = []; replacementsRowsForRun = []; + inboundRowsForRun = []; + expenseRowsForRun = []; + costTablesPresent = false; callCount = 0; mockDbFn.mockClear(); }); @@ -335,4 +352,180 @@ describe('getTaxReport', () => { expect(out.totalsByVatRate).toEqual([]); expect(out.cancelledCount).toBe(0); }); + + it('returns an empty cost side + zeroed summary when accounting tables are absent', async () => { + invoiceRowsForRun = [ + { + id: 1, invoice_number: 'R-2026-0001', issue_date: '2026-01-15', + currency: 'CHF', status: 'paid', vat_rate: 7.7, + net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770, + late_fee_amount_minor: 0, replaces_invoice_id: null, + customer_company_name: 'ACME', event_name: 'X', + }, + ]; + costTablesPresent = false; // no accounting migrations on this DB + const out = await taxReportService.getTaxReport({ + from: '2026-01-01', to: '2026-03-31', currency: 'CHF', + }); + expect(out.costs).toEqual({ rows: [], totalNet: 0, totalVat: 0, totalGross: 0, reclaimableVat: 0 }); + expect(out.summary).toMatchObject({ + incomeNetMinor: 10000, incomeVatMinor: 770, incomeGrossMinor: 10770, + costNetMinor: 0, costVatMinor: 0, costGrossMinor: 0, + resultNetMinor: 10000, resultGrossMinor: 10770, + // VAT registration unconfigured in the test DB → refuse to compute payable. + vatRegistrationConfigured: false, vatPayableMinor: null, + }); + }); + + it('aggregates incoming invoices + expenses into the cost side and nets the result', async () => { + invoiceRowsForRun = [ + { + id: 1, invoice_number: 'R-2026-0001', issue_date: '2026-01-15', + currency: 'CHF', status: 'paid', vat_rate: 7.7, + net_amount_minor: 100000, vat_amount_minor: 7700, total_amount_minor: 107700, + late_fee_amount_minor: 0, replaces_invoice_id: null, + customer_company_name: 'ACME', event_name: 'Wedding A', + }, + ]; + costTablesPresent = true; + // Incoming supplier invoice: net 20000 + vat 1540 = 21540. + inboundRowsForRun = [ + { + id: 5, invoice_date: '2026-01-20', created_at: '2026-01-21 09:00:00', + supplier_name: 'Lab AG', description: 'Prints', disposition: 'eigener_aufwand', + tax_treatment: 'domestic', status: 'categorized', event_id: 7, + net_amount_minor: 20000, vat_amount_minor: 1540, total_amount_minor: 21540, + event_name: 'Wedding A', + }, + ]; + // Internal expense (mileage, no VAT split): only a CHF base amount. + expenseRowsForRun = [ + { + id: 9, created_at: '2026-02-01 12:00:00', + supplier_name: null, description: 'Travel', disposition: 'eigener_aufwand', + tax_treatment: 'domestic', status: 'open', event_id: null, + original_currency: null, original_amount_minor: null, chf_amount_minor: 5000, + net_amount_minor: null, vat_amount_minor: null, gross_amount_minor: null, + event_name: null, + }, + ]; + const out = await taxReportService.getTaxReport({ + from: '2026-01-01', to: '2026-03-31', currency: 'CHF', + }); + + expect(out.costs.rows).toHaveLength(2); + // Incoming invoice mapped + booked to the event. + const incoming = out.costs.rows.find((r) => r.source === 'incoming'); + expect(incoming).toMatchObject({ + supplierLabel: 'Lab AG', eventName: 'Wedding A', + netMinor: 20000, vatMinor: 1540, totalMinor: 21540, + }); + // Expense: no net/vat/gross → falls back to the CHF base as total, + // and (company-booked) event name blank. + const expense = out.costs.rows.find((r) => r.source === 'expense'); + expect(expense).toMatchObject({ + eventName: '', netMinor: 5000, vatMinor: 0, totalMinor: 5000, + }); + + expect(out.costs.totalNet).toBe(25000); + expect(out.costs.totalVat).toBe(1540); + expect(out.costs.totalGross).toBe(26540); + + // Summary nets income against costs. + expect(out.summary).toMatchObject({ + incomeNetMinor: 100000, incomeVatMinor: 7700, incomeGrossMinor: 107700, + costNetMinor: 25000, costVatMinor: 1540, costGrossMinor: 26540, + resultNetMinor: 75000, resultGrossMinor: 81160, + vatRegistrationConfigured: false, vatPayableMinor: null, + }); + }); + + it('excludes declined/duplicate incoming invoices via the query filter (sanity on chain wiring)', async () => { + costTablesPresent = true; + inboundRowsForRun = []; // the whereNotIn filter is applied in SQL; here we assert empty → zeroed + expenseRowsForRun = []; + const out = await taxReportService.getTaxReport({ + from: '2026-01-01', to: '2026-03-31', currency: 'CHF', + }); + expect(out.costs.totalGross).toBe(0); + expect(out.summary.costGrossMinor).toBe(0); + }); +}); + +// ----- export scope (income/cost split) -------------------------------- +describe('export scope helpers', () => { + const { scopeLedger, normalizeScope } = taxReportService._internal; + const ledger = [ + { type: 'outgoing', reference: 'R-1' }, + { type: 'incoming', reference: 'IN-1' }, + { type: 'expense', reference: 'EXP-1' }, + ]; + + it('normalizeScope defaults unknown/empty to "all"', () => { + expect(normalizeScope('all')).toBe('all'); + expect(normalizeScope('income')).toBe('income'); + expect(normalizeScope('cost')).toBe('cost'); + expect(normalizeScope('bogus')).toBe('all'); + expect(normalizeScope(undefined)).toBe('all'); + }); + + it('scopeLedger income keeps only outgoing rows', () => { + expect(scopeLedger(ledger, 'income').map((r) => r.type)).toEqual(['outgoing']); + }); + + it('scopeLedger cost keeps incoming + expense rows', () => { + expect(scopeLedger(ledger, 'cost').map((r) => r.type)).toEqual(['incoming', 'expense']); + }); + + it('scopeLedger all keeps everything; null-safe', () => { + expect(scopeLedger(ledger, 'all')).toHaveLength(3); + expect(scopeLedger(null, 'income')).toEqual([]); + }); +}); + +describe('renderTaxReportCsv scope', () => { + beforeEach(() => { + costTablesPresent = true; + invoiceRowsForRun = [{ + id: 1, invoice_number: 'R-2026-0001', issue_date: '2026-01-15', + currency: 'CHF', status: 'paid', vat_rate: 8.1, + net_amount_minor: 100000, vat_amount_minor: 8100, total_amount_minor: 108100, + late_fee_amount_minor: 0, replaces_invoice_id: null, + customer_company_name: 'ACME', event_name: 'Wedding A', + }]; + inboundRowsForRun = [{ + id: 5, invoice_date: '2026-01-20', created_at: '2026-01-21 09:00:00', + supplier_name: 'Lab AG', description: 'Prints', disposition: 'eigener_aufwand', + tax_treatment: 'domestic', status: 'categorized', event_id: 7, + net_amount_minor: 20000, vat_amount_minor: 1620, total_amount_minor: 21620, + event_name: 'Wedding A', + }]; + }); + + it('income scope keeps the invoice row, drops the supplier cost row', async () => { + const { content, filename } = await taxReportService.renderTaxReportCsv({ + from: '2026-01-01', to: '2026-03-31', currency: 'CHF', scope: 'income', + }); + expect(content).toContain('R-2026-0001'); + expect(content).not.toContain('Lab AG'); + expect(filename).toContain('income_'); + }); + + it('cost scope keeps the supplier row, drops the invoice row', async () => { + const { content, filename } = await taxReportService.renderTaxReportCsv({ + from: '2026-01-01', to: '2026-03-31', currency: 'CHF', scope: 'cost', + }); + expect(content).toContain('Lab AG'); + expect(content).not.toContain('R-2026-0001'); + expect(filename).toContain('cost_'); + }); + + it('all scope (default) keeps both', async () => { + const { content, filename } = await taxReportService.renderTaxReportCsv({ + from: '2026-01-01', to: '2026-03-31', currency: 'CHF', + }); + expect(content).toContain('R-2026-0001'); + expect(content).toContain('Lab AG'); + expect(filename).not.toMatch(/income_|cost_/); + }); }); diff --git a/backend/__tests__/utils/dbErrors.test.js b/backend/__tests__/utils/dbErrors.test.js new file mode 100644 index 00000000..bbb8c382 --- /dev/null +++ b/backend/__tests__/utils/dbErrors.test.js @@ -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); + }); +}); diff --git a/backend/__tests__/utils/spreadsheetSafe.test.js b/backend/__tests__/utils/spreadsheetSafe.test.js new file mode 100644 index 00000000..3abe645d --- /dev/null +++ b/backend/__tests__/utils/spreadsheetSafe.test.js @@ -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"'); + }); +}); diff --git a/backend/migrations/core/122_seed_accounting_feature_flag.js b/backend/migrations/core/122_seed_accounting_feature_flag.js new file mode 100644 index 00000000..94ed6de6 --- /dev/null +++ b/backend/migrations/core/122_seed_accounting_feature_flag.js @@ -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(); +}; diff --git a/backend/migrations/core/123_seed_accounting_permissions.js b/backend/migrations/core/123_seed_accounting_permissions.js new file mode 100644 index 00000000..bdc73bad --- /dev/null +++ b/backend/migrations/core/123_seed_accounting_permissions.js @@ -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(); +}; diff --git a/backend/migrations/core/124_create_inbound_documents_and_expenses.js b/backend/migrations/core/124_create_inbound_documents_and_expenses.js new file mode 100644 index 00000000..88a600c5 --- /dev/null +++ b/backend/migrations/core/124_create_inbound_documents_and_expenses.js @@ -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'); +}; diff --git a/backend/migrations/core/125_add_contract_expense_surcharge.js b/backend/migrations/core/125_add_contract_expense_surcharge.js new file mode 100644 index 00000000..78666dcf --- /dev/null +++ b/backend/migrations/core/125_add_contract_expense_surcharge.js @@ -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)); + } + } +}; diff --git a/backend/migrations/core/126_accounting_split_incoming_vs_expenses.js b/backend/migrations/core/126_accounting_split_incoming_vs_expenses.js new file mode 100644 index 00000000..87ca88ff --- /dev/null +++ b/backend/migrations/core/126_accounting_split_incoming_vs_expenses.js @@ -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']); +}; diff --git a/backend/migrations/core/127_seed_expenses_flag_and_accounting_settings.js b/backend/migrations/core/127_seed_expenses_flag_and_accounting_settings.js new file mode 100644 index 00000000..f30b4b5c --- /dev/null +++ b/backend/migrations/core/127_seed_expenses_flag_and_accounting_settings.js @@ -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(); + } +}; diff --git a/backend/migrations/core/128_incoming_mail.js b/backend/migrations/core/128_incoming_mail.js new file mode 100644 index 00000000..a0c81b40 --- /dev/null +++ b/backend/migrations/core/128_incoming_mail.js @@ -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)); + } + } + } +}; diff --git a/backend/migrations/core/129_create_ledger_accounts_and_vat_codes.js b/backend/migrations/core/129_create_ledger_accounts_and_vat_codes.js new file mode 100644 index 00000000..4678cb63 --- /dev/null +++ b/backend/migrations/core/129_create_ledger_accounts_and_vat_codes.js @@ -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(); + } +}; diff --git a/backend/migrations/core/130_add_vat_code_snapshot.js b/backend/migrations/core/130_add_vat_code_snapshot.js new file mode 100644 index 00000000..b52d5529 --- /dev/null +++ b/backend/migrations/core/130_add_vat_code_snapshot.js @@ -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'); + }); + } + } +}; diff --git a/backend/migrations/core/131_add_feature_contracts_column.js b/backend/migrations/core/131_add_feature_contracts_column.js new file mode 100644 index 00000000..e48875bb --- /dev/null +++ b/backend/migrations/core/131_add_feature_contracts_column.js @@ -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'); + }); + } +}; diff --git a/backend/package-lock.json b/backend/package-lock.json index 47878f76..04c4e230 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "picpeak-backend", - "version": "3.47.2-beta.0", + "version": "3.60.6-beta.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "picpeak-backend", - "version": "3.47.2-beta.0", + "version": "3.60.6-beta.0", "dependencies": { "@aws-sdk/client-s3": "^3.850.0", "@aws-sdk/lib-storage": "^3.850.0", @@ -29,11 +29,13 @@ "i18next": "25.3.2", "i18next-browser-languagedetector": "^8.2.0", "i18next-http-backend": "^3.0.2", + "imapflow": "^1.4.0", "ipaddr.js": "^2.3.0", "joi": "^17.9.1", "js-yaml": "^4.1.1", "jsonwebtoken": "^9.0.0", "knex": "^2.4.2", + "mailparser": "^3.9.9", "mime-types": "^3.0.1", "multer": "^2.0.2", "node-cron": "^3.0.2", @@ -2725,6 +2727,12 @@ "pako": "^1.0.10" } }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, "node_modules/@scarf/scarf": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", @@ -2732,6 +2740,22 @@ "hasInstallScript": true, "license": "Apache-2.0" }, + "node_modules/@selderee/plugin-htmlparser2": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.12.0.tgz", + "integrity": "sha512-oELmoyA6ML9jDRMV3kgcMQFKxUfBU0yFVn6yTctVaLT5ygXnxH52I3TZEgV9EhXJC68/uFvE5Daj1/25c0Xa/A==", + "license": "MIT", + "dependencies": { + "domelementtype": "~2.3.0", + "domhandler": "~5.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/KillyMXI" + }, + "peerDependencies": { + "selderee": "~0.12.0" + } + }, "node_modules/@sideway/address": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", @@ -3722,6 +3746,17 @@ "dev": true, "license": "ISC" }, + "node_modules/@zone-eu/mailsplit": { + "version": "5.4.12", + "resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.12.tgz", + "integrity": "sha512-w7Gy+NvjZ0MiXm8F6zfjImAqcTONKDImgWVBjDKQVFUXWuz3VFM5levNArkL2M877ajql5+bkS2pDV56injlmg==", + "license": "(MIT OR EUPL-1.1+)", + "dependencies": { + "libbase64": "1.3.0", + "libmime": "5.3.8", + "libqp": "2.1.1" + } + }, "node_modules/abbrev": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", @@ -4047,6 +4082,15 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "license": "MIT" }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/axios": { "version": "1.15.2", "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz", @@ -5087,6 +5131,15 @@ "node": ">=0.10.0" } }, + "node_modules/deepmerge-ts": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", + "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -5329,6 +5382,15 @@ "node": ">= 0.8" } }, + "node_modules/encoding-japanese": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/encoding-japanese/-/encoding-japanese-2.2.0.tgz", + "integrity": "sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==", + "license": "MIT", + "engines": { + "node": ">=8.10.0" + } + }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -6531,6 +6593,15 @@ "node": ">= 0.4" } }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, "node_modules/helmet": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/helmet/-/helmet-7.2.0.tgz", @@ -6556,6 +6627,56 @@ "void-elements": "3.1.0" } }, + "node_modules/html-to-text": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-10.0.0.tgz", + "integrity": "sha512-2OH59Gtprdczel+7Rxgpz9hGVJREaf8Lt1H4kZwWHpEn70VQKRuMNGsb2eDbwaTzrYzb0hheiOG1P7Dim0B4dQ==", + "license": "MIT", + "dependencies": { + "@selderee/plugin-htmlparser2": "~0.12.0", + "deepmerge-ts": "^7.1.5", + "dom-serializer": "^2.0.0", + "htmlparser2": "^10.1.0", + "selderee": "~0.12.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/KillyMXI" + } + }, + "node_modules/html-to-text/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/html-to-text/node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, "node_modules/htmlparser2": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", @@ -6700,6 +6821,22 @@ "cross-fetch": "4.1.0" } }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -6737,6 +6874,23 @@ "dev": true, "license": "ISC" }, + "node_modules/imapflow": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.4.0.tgz", + "integrity": "sha512-bpNWv3AwzZryMMYoKiqPebcxmldCQwWxqhBQ5b/nTlJYAgexCzDIpN0LdhVJpJp6H25lAmwvShX+Fu/9AU5Spg==", + "license": "MIT", + "dependencies": { + "@zone-eu/mailsplit": "5.4.12", + "encoding-japanese": "2.2.0", + "iconv-lite": "0.7.2", + "libbase64": "1.3.0", + "libmime": "5.3.8", + "libqp": "2.1.1", + "nodemailer": "8.0.10", + "pino": "10.3.1", + "socks": "2.8.9" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -6827,7 +6981,6 @@ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", "license": "MIT", - "optional": true, "engines": { "node": ">= 12" } @@ -7972,6 +8125,15 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/leac": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/leac/-/leac-0.7.0.tgz", + "integrity": "sha512-qMrZeyEekgdRQ9o6a4NAB2EQZrv827GJdn1vnapwSJ90hWRB4TzUSunvacPkxQ2TnNqHNI1/zSt0hlo0crG8Jw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/KillyMXI" + } + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -7996,6 +8158,30 @@ "node": ">= 0.8.0" } }, + "node_modules/libbase64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/libbase64/-/libbase64-1.3.0.tgz", + "integrity": "sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==", + "license": "MIT" + }, + "node_modules/libmime": { + "version": "5.3.8", + "resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.8.tgz", + "integrity": "sha512-ZrCY+Q66mPvasAfjsQ/IgahzoBvfE1VdtGRpo1hwRB1oK3wJKxhKA3GOcd2a6j7AH5eMFccxK9fBoCpRZTf8ng==", + "license": "MIT", + "dependencies": { + "encoding-japanese": "2.2.0", + "iconv-lite": "0.7.2", + "libbase64": "1.3.0", + "libqp": "2.1.1" + } + }, + "node_modules/libqp": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/libqp/-/libqp-2.1.1.tgz", + "integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==", + "license": "MIT" + }, "node_modules/linebreak": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz", @@ -8022,6 +8208,25 @@ "dev": true, "license": "MIT" }, + "node_modules/linkify-it": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.1.tgz", + "integrity": "sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -8164,6 +8369,24 @@ "yallist": "^3.0.2" } }, + "node_modules/mailparser": { + "version": "3.9.9", + "resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.9.9.tgz", + "integrity": "sha512-ulZi7h1eKm8WQmXibIgj8dmMQGDQCUS/g+XHkxxjcLDq4Dwn2ppo+0hz5Fi+ltvu4eN7mh3ykIp5RcpiWWav1w==", + "license": "MIT", + "dependencies": { + "@zone-eu/mailsplit": "5.4.12", + "encoding-japanese": "2.2.0", + "he": "1.2.0", + "html-to-text": "10.0.0", + "iconv-lite": "0.7.2", + "libmime": "5.3.8", + "linkify-it": "5.0.1", + "nodemailer": "8.0.10", + "punycode.js": "2.3.1", + "tlds": "1.261.0" + } + }, "node_modules/make-dir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", @@ -8836,9 +9059,9 @@ "license": "MIT" }, "node_modules/nodemailer": { - "version": "8.0.7", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.7.tgz", - "integrity": "sha512-pkjE4mkBzQjdJT4/UmlKl3pX0rC9fZmjh7c6C9o7lv66Ac6w9WCnzPzhbPNxwZAzlF4mdq4CSWB5+FbK6FWCow==", + "version": "8.0.10", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.10.tgz", + "integrity": "sha512-BLFuSth7QtHOkBzyqTehWWyub0NTRDuK2Q2SQfnGLsrJnzyU+Yeh4WpV1eZGuARFj1xQJHIdnTuJZLP+b9R1GQ==", "license": "MIT-0", "engines": { "node": ">=6.0.0" @@ -9036,6 +9259,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -9214,6 +9446,19 @@ "integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==", "license": "MIT" }, + "node_modules/parseley": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/parseley/-/parseley-0.13.1.tgz", + "integrity": "sha512-uNBJZzmb60l6p6VWLTmevizNAGnE0xoSf1n0B4q3ntegDNzcS68NRCcBDZTcyXHxt2XhBChsCuqj4M+nChvE/A==", + "license": "MIT", + "dependencies": { + "leac": "^0.7.0", + "peberminta": "^0.10.0" + }, + "funding": { + "url": "https://github.com/sponsors/KillyMXI" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -9318,6 +9563,15 @@ "png-js": "^1.0.0" } }, + "node_modules/peberminta": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.10.0.tgz", + "integrity": "sha512-80B2AsU+I4Qdb0ZAPSfe9UwvGzwkM37IKIFEvdS3D/3Ndgv2bsuJ0bfG1+iEYO+l7Gfd4EUJmuRyq7efLgRMzQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/KillyMXI" + } + }, "node_modules/pg": { "version": "8.16.3", "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", @@ -9432,6 +9686,43 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, "node_modules/pirates": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", @@ -9665,6 +9956,22 @@ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "license": "MIT" }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/promise-inflight": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", @@ -9758,6 +10065,15 @@ "node": ">=6" } }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/pure-rand": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", @@ -9966,6 +10282,12 @@ ], "license": "MIT" }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -10078,6 +10400,15 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, "node_modules/rechoir": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz", @@ -10285,6 +10616,18 @@ "postcss": "^8.3.11" } }, + "node_modules/selderee": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/selderee/-/selderee-0.12.0.tgz", + "integrity": "sha512-b1YMh3+DHZp59DLna3qVwQ5iOla/nrI6mLBNW02XxU77M3046Df6VLkoaJyFz20VsGIG5kkp+FK0kg4K4HnUFw==", + "license": "MIT", + "dependencies": { + "parseley": "~0.13.1" + }, + "funding": { + "url": "https://github.com/sponsors/KillyMXI" + } + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -10628,20 +10971,18 @@ "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", "license": "MIT", - "optional": true, "engines": { "node": ">= 6.0.0", "npm": ">= 3.0.0" } }, "node_modules/socks": { - "version": "2.8.7", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", - "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", + "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", "license": "MIT", - "optional": true, "dependencies": { - "ip-address": "^10.0.1", + "ip-address": "^10.1.1", "smart-buffer": "^4.2.0" }, "engines": { @@ -10664,6 +11005,15 @@ "node": ">= 10" } }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -11284,6 +11634,24 @@ "dev": true, "license": "MIT" }, + "node_modules/thread-stream": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", + "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", + "license": "MIT", + "dependencies": { + "real-require": "^1.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/thread-stream/node_modules/real-require": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", + "license": "MIT" + }, "node_modules/tildify": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/tildify/-/tildify-2.0.0.tgz", @@ -11299,6 +11667,15 @@ "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", "license": "MIT" }, + "node_modules/tlds": { + "version": "1.261.0", + "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.261.0.tgz", + "integrity": "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==", + "license": "MIT", + "bin": { + "tlds": "bin.js" + } + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -11447,6 +11824,12 @@ "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", "license": "MIT" }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "license": "MIT" + }, "node_modules/undefsafe": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", diff --git a/backend/package.json b/backend/package.json index 6873904b..f737c258 100644 --- a/backend/package.json +++ b/backend/package.json @@ -35,11 +35,13 @@ "i18next": "25.3.2", "i18next-browser-languagedetector": "^8.2.0", "i18next-http-backend": "^3.0.2", + "imapflow": "^1.4.0", "ipaddr.js": "^2.3.0", "joi": "^17.9.1", "js-yaml": "^4.1.1", "jsonwebtoken": "^9.0.0", "knex": "^2.4.2", + "mailparser": "^3.9.9", "mime-types": "^3.0.1", "multer": "^2.0.2", "node-cron": "^3.0.2", diff --git a/backend/server.js b/backend/server.js index 2b19f3bb..1f1e0966 100644 --- a/backend/server.js +++ b/backend/server.js @@ -707,6 +707,11 @@ app.use('/api/admin/projects', require('./src/routes/adminProjects')); app.use('/api/admin/calendar', require('./src/routes/adminCalendar')); app.use('/api/admin/deals', require('./src/routes/adminDeals')); app.use('/api/admin/tax-report', require('./src/routes/adminTaxReport')); +app.use('/api/admin/expenses', require('./src/routes/adminExpenses')); +app.use('/api/admin/ledger', require('./src/routes/adminLedger')); +// Read-only VAT-code registry for the invoice/quote editors — un-gated by the +// accounting flag (management stays under /ledger). +app.use('/api/admin/vat-codes', require('./src/routes/adminVatCodes')); app.use('/api/admin/system-health', require('./src/routes/adminSystemHealth')); app.use('/api/admin/dev', require('./src/routes/adminDev')); app.use('/api/public/quotes', require('./src/routes/publicQuotes')); @@ -836,7 +841,16 @@ async function startServer() { logger.warn('Email template self-heal failed at boot:', err.message); } startEmailQueueProcessor(); - + + // Start incoming-mail (IMAP) poller — no-ops each minute unless the + // `incomingMail` flag is on and a mailbox is configured (migration 128). + try { + const { startIncomingMailPoller } = require('./src/services/emailIntakeService'); + startIncomingMailPoller(); + } catch (err) { + logger.warn('Incoming-mail poller failed to start:', err.message); + } + // Start webhook delivery worker (#327) const { startWebhookDeliveryWorker } = require('./src/services/webhookDeliveryWorker'); startWebhookDeliveryWorker(); diff --git a/backend/src/middleware/requireFeatureFlag.js b/backend/src/middleware/requireFeatureFlag.js new file mode 100644 index 00000000..c2637dae --- /dev/null +++ b/backend/src/middleware/requireFeatureFlag.js @@ -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 }; diff --git a/backend/src/routes/adminCustomers.js b/backend/src/routes/adminCustomers.js index 95c43ec5..7eec471b 100644 --- a/backend/src/routes/adminCustomers.js +++ b/backend/src/routes/adminCustomers.js @@ -10,6 +10,13 @@ const express = require('express'); const { body, param, query } = require('express-validator'); const { adminAuth } = require('../middleware/auth'); const { requirePermission } = require('../middleware/permissions'); +const { requireFeatureFlag } = require('../middleware/requireFeatureFlag'); + +// Hour-entry routes are gated by the hoursLogging master so a direct API hit +// can't read/edit/delete/bill logged hours while the feature is off (the +// frontend already hides the surface). Per-customer enforcement stays in +// customerHoursService.createEntry. +const requireHoursLogging = requireFeatureFlag('hoursLogging', 'HOURS_LOGGING_DISABLED'); const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers'); const customerAccountsService = require('../services/customerAccountsService'); const customerHoursService = require('../services/customerHoursService'); @@ -66,6 +73,9 @@ function transformCustomer(c) { // set one; the editor surfaces it as an empty input and forces a // per-entry override on every logged block. featureHoursLogging: c.feature_hours_logging === true || c.feature_hours_logging === 1, + // Contracts override (migration 131). Opt-out: absent column (older row / + // un-selected) reads as ON so existing customers keep the Contracts tab. + featureContracts: c.feature_contracts === undefined ? true : (c.feature_contracts === true || c.feature_contracts === 1), hourlyRateMinor: c.hourly_rate_minor != null ? Number(c.hourly_rate_minor) : null, // Per-customer Skonto opt-out (migration 112). When true, none of // this customer's invoices qualify for an early-payment discount, @@ -387,6 +397,7 @@ router.put('/:id', [ body('feature_calendar').optional().isBoolean(), body('feature_quotes').optional().isBoolean(), body('feature_bills').optional().isBoolean(), + body('feature_contracts').optional().isBoolean(), // Hours logging (migration 129). body('feature_hours_logging').optional().isBoolean(), body('hourly_rate_minor').optional({ nullable: true }).isInt({ min: 0 }), @@ -541,6 +552,7 @@ router.put('/:id/events', [ // can't collide with the int-validated :id pattern. router.get('/hour-entries/unbilled-summary', [ adminAuth, + requireHoursLogging, requirePermission('customers.view'), ], handleAsync(async (req, res) => { const summary = await customerHoursService.getUnbilledSummaryByCustomer(); @@ -549,6 +561,7 @@ router.get('/hour-entries/unbilled-summary', [ router.get('/:id/hour-entries', [ adminAuth, + requireHoursLogging, requirePermission('customers.view'), param('id').isInt({ min: 1 }), query('status').optional().isIn(['unbilled', 'billed', 'cancelled']), @@ -563,6 +576,7 @@ router.get('/:id/hour-entries', [ router.post('/:id/hour-entries', [ adminAuth, + requireHoursLogging, // Migration 134 — hour entries are customer-scoped writes; same scope // as customer record edits, narrower than invite/create. requirePermission('customers.edit'), @@ -585,6 +599,7 @@ router.post('/:id/hour-entries', [ router.put('/:id/hour-entries/:entryId', [ adminAuth, + requireHoursLogging, requirePermission('customers.edit'), param('id').isInt({ min: 1 }), param('entryId').isInt({ min: 1 }), @@ -605,6 +620,7 @@ router.put('/:id/hour-entries/:entryId', [ router.delete('/:id/hour-entries/:entryId', [ adminAuth, + requireHoursLogging, requirePermission('customers.edit'), param('id').isInt({ min: 1 }), param('entryId').isInt({ min: 1 }), @@ -619,6 +635,7 @@ router.delete('/:id/hour-entries/:entryId', [ router.post('/:id/hour-entries/bill', [ adminAuth, + requireHoursLogging, requirePermission('customers.edit'), param('id').isInt({ min: 1 }), ], handleAsync(async (req, res) => { diff --git a/backend/src/routes/adminDeals.js b/backend/src/routes/adminDeals.js index e4b8ccb8..8d0a9acf 100644 --- a/backend/src/routes/adminDeals.js +++ b/backend/src/routes/adminDeals.js @@ -21,6 +21,7 @@ const express = require('express'); const { param, body } = require('express-validator'); const { adminAuth } = require('../middleware/auth'); const { requirePermission } = require('../middleware/permissions'); +const { requireFeatureFlag } = require('../middleware/requireFeatureFlag'); const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers'); const dealsService = require('../services/dealsService'); const invoiceService = require('../services/invoiceService'); @@ -56,6 +57,9 @@ router.get( */ router.put( '/:uuid/installment-plan', + // Mutates invoices — gate on the bills flag like every other invoice + // write path, so installment plans can't be reshaped with Bills off. + requireFeatureFlag('bills', 'BILLS_DISABLED'), requirePermission('bills.manage'), [ param('uuid').isString().isLength({ min: 32, max: 36 }), diff --git a/backend/src/routes/adminEmail.js b/backend/src/routes/adminEmail.js index d880b884..23d3bffa 100644 --- a/backend/src/routes/adminEmail.js +++ b/backend/src/routes/adminEmail.js @@ -118,6 +118,161 @@ router.post('/config', [ } }); +// ── Incoming mail (IMAP) config — a second block alongside outgoing SMTP ── +router.get('/incoming-config', adminAuth, requirePermission('email.view'), async (req, res) => { + try { + const c = await db('email_configs').first(); + res.json({ + imap_host: c?.imap_host || '', + imap_port: c?.imap_port || 993, + imap_secure: c?.imap_secure !== false, + imap_user: c?.imap_user || '', + imap_pass: c?.imap_pass ? '********' : '', // never send the real password + imap_folder: c?.imap_folder || 'INBOX', + }); + } catch (error) { + console.error('Incoming mail config fetch error:', error); + res.status(500).json({ error: 'Failed to fetch incoming mail configuration' }); + } +}); + +router.post('/incoming-config', [ + adminAuth, + requirePermission('email.edit'), + body('imap_host').notEmpty().withMessage('IMAP host is required'), + body('imap_port').isInt({ min: 1, max: 65535 }).withMessage('Invalid port number'), + // IMAP always needs a login (unlike SMTP relay) — the poller's + // getImapConfig() returns null without a username, so require it. + body('imap_user').notEmpty().withMessage('IMAP username is required'), +], async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() }); + const { imap_host, imap_port, imap_secure, imap_user, imap_pass, imap_folder } = req.body; + const { isPrivateIP } = require('../utils/networkValidation'); + if (isPrivateIP(imap_host)) { + return res.status(400).json({ error: 'IMAP host cannot point to a private or internal network address' }); + } + const existing = await db('email_configs').first(); + const data = { + imap_host, + imap_port: parseInt(imap_port), + imap_secure: imap_secure || false, + imap_user: imap_user || '', + imap_folder: imap_folder || 'INBOX', + updated_at: new Date(), + }; + if (imap_pass && imap_pass !== '********') data.imap_pass = imap_pass; + if (existing) await db('email_configs').where('id', existing.id).update(data); + else await db('email_configs').insert(data); + await logActivity('incoming_mail_config_updated', { imap_host }, null, { type: 'admin', id: req.admin.id, name: req.admin.username }); + res.json({ message: 'Incoming mail configuration updated successfully' }); + } catch (error) { + console.error('Incoming mail config update error:', error); + res.status(500).json({ error: 'Failed to update incoming mail configuration' }); + } +}); + +// Received-emails log (the IMAP poller's audit trail) — "Received emails" tab. +// List IMAP folders so the UI can offer a dropdown (auto-detect) instead of a +// free-text path. Accepts optional creds in the body to detect before saving; +// falls back to the stored config (and stored password when masked). +router.post('/incoming-config/folders', adminAuth, requirePermission('email.view'), async (req, res) => { + try { + const { imap_host, imap_port, imap_secure, imap_user, imap_pass } = req.body || {}; + if (imap_host) { + const { isPrivateIP } = require('../utils/networkValidation'); + if (isPrivateIP(imap_host)) { + return res.status(400).json({ error: 'IMAP host cannot point to a private or internal network address' }); + } + } + const emailIntakeService = require('../services/emailIntakeService'); + const folders = await emailIntakeService.listFolders( + imap_host ? { host: imap_host, port: imap_port, secure: imap_secure, user: imap_user, pass: imap_pass } : undefined + ); + res.json({ folders }); + } catch (error) { + console.error('IMAP folder detection error:', error); + res.status(422).json({ error: `Could not connect to the mailbox (${error.message}). Check host, port (IMAP is usually 993) and credentials.` }); + } +}); + +// Test the incoming-mail connection: log in + open the configured folder and +// report message/unread counts. Accepts current form creds (test before save). +router.post('/incoming-config/test', adminAuth, requirePermission('email.view'), async (req, res) => { + try { + const { imap_host, imap_port, imap_secure, imap_user, imap_pass, imap_folder } = req.body || {}; + if (imap_host) { + const { isPrivateIP } = require('../utils/networkValidation'); + if (isPrivateIP(imap_host)) { + return res.status(400).json({ error: 'IMAP host cannot point to a private or internal network address' }); + } + } + const emailIntakeService = require('../services/emailIntakeService'); + const result = await emailIntakeService.testConnection( + imap_host ? { host: imap_host, port: imap_port, secure: imap_secure, user: imap_user, pass: imap_pass, folder: imap_folder } : undefined + ); + if (result && result.ok === false) { + return res.status(400).json({ error: 'Incoming mail is not configured yet — enter host, username and password first.' }); + } + res.json(result); + } catch (error) { + console.error('IMAP connection test error:', error); + res.status(422).json({ error: `Could not connect to the mailbox (${error.message}). Check host, port (IMAP is usually 993), credentials and folder.` }); + } +}); + +// End-to-end round-trip: send via SMTP to the IMAP mailbox, then confirm it +// arrives. Uses saved config for both sides (real passwords needed). +router.post('/incoming-config/roundtrip', adminAuth, requirePermission('email.send'), async (req, res) => { + try { + const emailIntakeService = require('../services/emailIntakeService'); + const result = await emailIntakeService.roundTripTest(); + if (result.ok) return res.json(result); + const map = { + smtp_unconfigured: 'Configure and save the outgoing SMTP settings first.', + imap_unconfigured: 'Configure and save the incoming IMAP settings first.', + recipient_not_email: `The IMAP username (“${result.recipient || ''}”) isn’t an email address, so the round-trip test can’t auto-address itself. Use a mailbox whose username is its email, or send a test email there manually and use “Test connection”.`, + send_failed: `Could not send the test email${result.error ? `: ${result.error}` : ''}.`, + not_received: 'The email was sent but did not arrive within 30s — possible delivery delay/greylisting. Check the Received emails tab in a moment.', + }; + return res.status(result.reason === 'not_received' ? 504 : 400) + .json({ error: map[result.reason] || 'Round-trip test failed.', sent: !!result.sent, recipient: result.recipient }); + } catch (error) { + console.error('Round-trip test error:', error); + res.status(422).json({ error: `Round-trip test failed (${error.message}) — check both SMTP and IMAP settings.` }); + } +}); + +// Run the incoming-mail poller on demand (instead of waiting for the 60s loop) +// so the admin can verify ingestion + see why nothing arrived. Respects the +// incomingMail flag — a manual run still won't ingest when the feature is off. +router.post('/incoming-config/poll', adminAuth, requirePermission('email.view'), async (req, res) => { + try { + const emailIntakeService = require('../services/emailIntakeService'); + const result = await emailIntakeService.pollOnce(); + res.json(result); // { processed } or { skipped: 'disabled'|'unconfigured'|'busy' } + } catch (error) { + console.error('Manual poll error:', error); + res.status(422).json({ error: `Mailbox poll failed (${error.message}).` }); + } +}); + +router.get('/received', adminAuth, requirePermission('email.view'), async (req, res) => { + try { + const page = Math.max(1, parseInt(req.query.page, 10) || 1); + const pageSize = Math.min(100, Math.max(1, parseInt(req.query.pageSize, 10) || 25)); + const base = db('received_emails'); + const countRow = await base.clone().count({ c: '*' }).first(); + const total = parseInt(countRow?.c || 0, 10); + const items = await base.clone().orderBy('received_at', 'desc').limit(pageSize).offset((page - 1) * pageSize); + res.json({ items, pagination: { page, pageSize, total, totalPages: Math.ceil(total / pageSize) } }); + } catch (error) { + console.error('Received emails fetch error:', error); + res.status(500).json({ error: 'Failed to fetch received emails' }); + } +}); + // Test email configuration router.post('/test', adminAuth, requirePermission('email.send'), async (req, res) => { try { diff --git a/backend/src/routes/adminExpenses.js b/backend/src/routes/adminExpenses.js new file mode 100644 index 00000000..4ef9eb1b --- /dev/null +++ b/backend/src/routes/adminExpenses.js @@ -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; diff --git a/backend/src/routes/adminFeatureFlags.js b/backend/src/routes/adminFeatureFlags.js index 04575e1d..8c447f24 100644 --- a/backend/src/routes/adminFeatureFlags.js +++ b/backend/src/routes/adminFeatureFlags.js @@ -18,6 +18,7 @@ const router = express.Router(); const { db, logActivity } = require('../database/db'); const { adminAuth } = require('../middleware/auth'); const { requirePermission } = require('../middleware/permissions'); +const { invalidateFeatureFlagCache } = require('../middleware/requireFeatureFlag'); const logger = require('../utils/logger'); // Canonical flag list. Keep in sync with frontend @@ -25,6 +26,9 @@ const logger = require('../utils/logger'); const KNOWN_FLAGS = [ 'galleries', 'reminderEmails', + // Incoming mail (migration 128) — IMAP polling of a dedicated mailbox into + // the incoming-invoices inbox. Standalone toggle. + 'incomingMail', 'calendar', 'calendarBooking', 'quotes', @@ -45,10 +49,9 @@ const KNOWN_FLAGS = [ // payment-check email flow without waiting 30 days, etc.). // Strictly opt-in. 'crmDevelopment', - // Tax / Steuer report sub-tab under Clients. Independent toggle so - // admins who use Bills but don't need the tax export (or aren't - // ready to enable it yet) can leave it off. Forced off when `bills` - // is off (no invoices → nothing to report). + // Tax / Steuer report — an Accounting sub-feature (moved out of CRM). + // Independent of `bills`; forced off when the `accounting` master is off + // (see applyDependencyRules below). 'taxReport', // Hours logging (migration 129). Master switch for the per-customer // Hours card + the auto-append into monthly draft / "Bill these @@ -62,6 +65,17 @@ const KNOWN_FLAGS = [ // upload). Seeded block bodies are EXAMPLES ONLY; admins must have a // lawyer review before sending. See docs/crm-disclaimers.md. 'contracts', + // Accounting (migration 122). Top-level Accounting area — inbound + // supplier invoices, expenses + re-bill, and the tax report (which + // relocates here from CRM when this is on). Strictly opt-in. + 'accounting', + // Incoming invoices (migration 124) — external supplier-invoice capture + + // re-bill. Accounting sub-feature; forced off when the `accounting` master + // is off. + 'incomingInvoices', + // Expenses (migration 127) — internal expenses (mileage / per-diem / cash). + // Separate Accounting sub-feature; forced off when `accounting` is off. + 'expenses', // Projects (migration 120). Admin-only grouping layer above events + // the Project Overview cockpit ("book to project" hours control, 360° // rollup feed). Lights up the Clients section. Customers never see it. @@ -72,6 +86,7 @@ const KNOWN_FLAGS = [ // new release that hasn't run its migration yet on this instance). const DEFAULT_FLAGS = { galleries: true, + incomingMail: false, // F.3 — reminderEmails is a placeholder card in the Features tab // (lockedReason: NOT_YET_AVAILABLE). Default FALSE so it matches // the locked-but-off visual state of messaging / calendarBooking @@ -88,6 +103,9 @@ const DEFAULT_FLAGS = { taxReport: false, hoursLogging: false, contracts: false, + accounting: false, + incomingInvoices: false, + expenses: false, projects: false, }; @@ -109,10 +127,14 @@ function applyDependencyRules(flags) { // Sub-features can't outlive their parents. if (out.quotes === false) out.bills = false; if (out.calendar === false) out.calendarBooking = false; - // Tax report only makes sense when bills are on — turning bills off - // implicitly turns the tax report off too. Admins enabling tax - // report must first enable bills. - if (out.bills === false) out.taxReport = false; + // Accounting is a top-level MASTER; its sub-features can't outlive it. + // Tax export is now independent of Bills — it relocated permanently + // into the Accounting section (its own master gate). + if (out.accounting === false) { + out.taxReport = false; + out.incomingInvoices = false; + out.expenses = false; + } // Clients parent flag is DERIVED from its children. Admins don't // toggle it directly in the Features tab — they enable a specific // sub-feature (Accounts today; Calendar/Quotes/Bills/Messaging @@ -125,9 +147,10 @@ function applyDependencyRules(flags) { || out.crmDevelopment || out.quotes || out.bills - || out.taxReport || out.hoursLogging || out.contracts + // NOTE: taxReport intentionally removed — Tax export moved to the + // Accounting section (its own master), no longer a CRM sub-feature. // Migration 120 — admin-only Project Overview cockpit lives under Clients. || out.projects // Migration 137 — admin calendar lights up the Clients section. @@ -204,6 +227,10 @@ router.put('/', adminAuth, requirePermission('settings.edit'), async (req, res) } }); + // Drop the requireFeatureFlag middleware's short-TTL cache so a toggle takes + // effect immediately instead of after ≤10s. + invalidateFeatureFlagCache(); + await logActivity( 'feature_flags_updated', { changed, actor: adminUsername }, diff --git a/backend/src/routes/adminLedger.js b/backend/src/routes/adminLedger.js new file mode 100644 index 00000000..3a948edb --- /dev/null +++ b/backend/src/routes/adminLedger.js @@ -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; diff --git a/backend/src/routes/adminSettings.js b/backend/src/routes/adminSettings.js index 66898e16..87040945 100644 --- a/backend/src/routes/adminSettings.js +++ b/backend/src/routes/adminSettings.js @@ -19,6 +19,7 @@ const { getRawPublicSiteSettings, } = require('../services/publicSiteService'); const { sanitizeCss } = require('../utils/cssSanitizer'); +const { upsertAppSetting } = require('../utils/appSettings'); const { clearShareLinkSettingsCache } = require('../services/shareLinkService'); const { resetSecurityConfigCache } = require('../utils/authSecurity'); const router = express.Router(); @@ -211,16 +212,7 @@ router.put('/customer-surface', adminAuth, requirePermission('settings.edit'), a } for (const u of updates) { - const existing = await db('app_settings').where('setting_key', u.setting_key).first(); - if (existing) { - await db('app_settings').where('setting_key', u.setting_key).update({ - setting_value: u.setting_value, - setting_type: u.setting_type, - updated_at: new Date(), - }); - } else { - await db('app_settings').insert({ ...u, created_at: new Date(), updated_at: new Date() }); - } + await upsertAppSetting(u.setting_key, u.setting_value, u.setting_type); } // Clear the public-site cache so any consumer relying on it @@ -234,6 +226,60 @@ router.put('/customer-surface', adminAuth, requirePermission('settings.edit'), a } }); +// Accounting settings (km rate, per-diem rate, require-proof). Read via the +// generic GET /:type ('accounting'); this is the typed write. Rates are +// integer minor units; verify legal/tax guidance with a Treuhaender. +router.put('/accounting', adminAuth, requirePermission('settings.edit'), async (req, res) => { + try { + const updates = []; + const setInt = (key) => { + if (Object.prototype.hasOwnProperty.call(req.body, key)) { + const n = Math.max(0, Math.round(Number(req.body[key]) || 0)); + updates.push({ setting_key: key, setting_value: JSON.stringify(n), setting_type: 'accounting' }); + } + }; + setInt('accounting_km_rate_minor'); + setInt('accounting_per_diem_rate_minor'); + if (Object.prototype.hasOwnProperty.call(req.body, 'accounting_require_proof')) { + updates.push({ + setting_key: 'accounting_require_proof', + setting_value: JSON.stringify(!!req.body.accounting_require_proof), + setting_type: 'accounting', + }); + } + // VAT registration + reclaim. `registered` drives whether output VAT applies + // + whether input VAT is deductible; `reclaim_countries` = the ISO-2 list of + // countries whose input VAT can be reclaimed (drives cost tax-treatment + + // the report's VAT-payable). + if (Object.prototype.hasOwnProperty.call(req.body, 'accounting_vat_registered')) { + updates.push({ + setting_key: 'accounting_vat_registered', + setting_value: JSON.stringify(!!req.body.accounting_vat_registered), + setting_type: 'accounting', + }); + } + if (Object.prototype.hasOwnProperty.call(req.body, 'accounting_vat_reclaim_countries')) { + const arr = Array.isArray(req.body.accounting_vat_reclaim_countries) + ? req.body.accounting_vat_reclaim_countries + .map((c) => String(c || '').toUpperCase().trim()) + .filter((c) => /^[A-Z]{2}$/.test(c)) + : []; + updates.push({ + setting_key: 'accounting_vat_reclaim_countries', + setting_value: JSON.stringify(arr), + setting_type: 'accounting', + }); + } + for (const u of updates) { + await upsertAppSetting(u.setting_key, u.setting_value, u.setting_type); + } + res.json({ message: 'Accounting settings updated', updated: updates.map((u) => u.setting_key) }); + } catch (error) { + console.error('Accounting settings save error:', error); + res.status(500).json({ error: 'Failed to save accounting settings' }); + } +}); + // Get settings by type router.get('/:type', adminAuth, requirePermission('settings.view'), async (req, res) => { try { diff --git a/backend/src/routes/adminTaxReport.js b/backend/src/routes/adminTaxReport.js index af3a29fd..a78f1542 100644 --- a/backend/src/routes/adminTaxReport.js +++ b/backend/src/routes/adminTaxReport.js @@ -8,9 +8,11 @@ * GET /pdf → landscape A4 PDF, Content-Disposition: attachment * GET /csv → RFC-4180 CSV, Content-Disposition: attachment * - * Reuses the existing `bills` feature flag + `bills.view` permission. - * Tax data is just a different lens on invoice data — admins who can - * read invoices can read the tax report; no new RBAC surface needed. + * Gated by the Accounting master flag + the `taxReport` sub-flag + * (independent of `bills` — Tax export was moved out of CRM into + * Accounting). Still uses the `bills.view` permission: tax data is just + * a different lens on invoice data, so admins who can read invoices can + * read the tax report; no new RBAC surface needed. */ const express = require('express'); @@ -23,19 +25,19 @@ const { db } = require('../database/db'); const router = express.Router(); -// The tax report has its own dedicated flag (taxReport) — independent -// from `bills` so admins can leave it off until they actually need to -// run the export. The frontend mirrors the dependency rule (bills off -// → taxReport off) but we re-check both server-side for defence in -// depth. +// The tax report now lives under the Accounting master flag and has its +// own dedicated `taxReport` sub-flag — it is INDEPENDENT of `bills` +// (Tax export was moved permanently out of CRM into Accounting). The +// frontend mirrors the dependency rule (accounting off → taxReport off) +// but we re-check both server-side for defence in depth. async function requireTaxReportFlag(req, res, next) { try { - const rows = await db('feature_flags').whereIn('key', ['bills', 'taxReport']).select('key', 'value'); + const rows = await db('feature_flags').whereIn('key', ['accounting', 'taxReport']).select('key', 'value'); const isOn = (row) => row && (row.value === true || row.value === 1 || row.value === '1'); - const bills = isOn(rows.find((r) => r.key === 'bills')); + const accounting = isOn(rows.find((r) => r.key === 'accounting')); const taxReport = isOn(rows.find((r) => r.key === 'taxReport')); - if (!bills) { - return res.status(403).json({ error: 'Bills feature is disabled', code: 'BILLS_DISABLED' }); + if (!accounting) { + return res.status(403).json({ error: 'Accounting feature is disabled', code: 'ACCOUNTING_DISABLED' }); } if (!taxReport) { return res.status(403).json({ error: 'Tax report feature is disabled', code: 'TAX_REPORT_DISABLED' }); @@ -63,11 +65,15 @@ const QUERY_VALIDATORS = [ ]; function parseParams(req) { + // scope (export only): all | income | cost. Anything else → 'all'. + const rawScope = String(req.query.scope || 'all'); + const scope = ['all', 'income', 'cost'].includes(rawScope) ? rawScope : 'all'; return { from: req.query.from, to: req.query.to, currency: String(req.query.currency || '').toUpperCase(), locale: req.query.locale || undefined, + scope, }; } @@ -92,7 +98,8 @@ router.get( validateRequest(req); const params = parseParams(req); const buffer = await taxReportService.renderTaxReportPdf(params); - const filename = `tax_report_${params.from}_to_${params.to}_${params.currency}.pdf`; + const scopeTag = params.scope && params.scope !== 'all' ? `${params.scope}_` : ''; + const filename = `tax_report_${scopeTag}${params.from}_to_${params.to}_${params.currency}.pdf`; res.set('Content-Type', 'application/pdf'); res.set('Content-Disposition', `attachment; filename="${filename}"`); res.set('Content-Length', String(buffer.length)); diff --git a/backend/src/routes/adminVatCodes.js b/backend/src/routes/adminVatCodes.js new file mode 100644 index 00000000..d513bc8b --- /dev/null +++ b/backend/src/routes/adminVatCodes.js @@ -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; diff --git a/backend/src/routes/customer.js b/backend/src/routes/customer.js index 1db49791..d2b827d9 100644 --- a/backend/src/routes/customer.js +++ b/backend/src/routes/customer.js @@ -24,6 +24,19 @@ const { customerAuth } = require('../middleware/customerAuth'); const { setGalleryAuthCookies } = require('../utils/tokenUtils'); const customerAccountsService = require('../services/customerAccountsService'); +// Gate a customer-facing route on BOTH the global master flag AND the +// per-customer override — getEffectiveFeaturesForCustomer combines them, so an +// admin disabling e.g. Bills globally is honoured even when feature_bills=true +// on the row. Sends the 403 and returns false on denial; true if allowed. +async function customerFeatureAllowed(req, res, featureKey, label) { + const eff = await customerAccountsService.getEffectiveFeaturesForCustomer(req.customer.id); + if (!eff || !eff[featureKey]) { + res.status(403).json({ error: `${label} are disabled for this account`, code: 'CUSTOMER_FEATURE_DISABLED' }); + return false; + } + return true; +} + /** * Customer-side password policy mirrors the one in customerAuth.js — kept * deliberately simple (8 chars, one uppercase, one digit) since a customer @@ -380,11 +393,8 @@ router.post('/profile/password', [ router.get('/quotes', customerAuth, async (req, res) => { try { const { db: dbi } = require('../database/db'); - // Customer-feature gate. is_active is enforced by customerAuth. - const customer = await dbi('customer_accounts').where({ id: req.customer.id }).first(); - if (!customer || customer.feature_quotes === false || customer.feature_quotes === 0) { - return res.status(403).json({ error: 'Quotes are disabled for this account', code: 'CUSTOMER_FEATURE_DISABLED' }); - } + // Customer-feature gate — master flag AND per-customer override. + if (!(await customerFeatureAllowed(req, res, 'quotes', 'Quotes'))) return; const rows = await dbi('quotes') .where({ customer_account_id: req.customer.id }) // Hide drafts — they're admin scratch work; nothing has been @@ -459,10 +469,8 @@ router.get('/quotes', customerAuth, async (req, res) => { router.get('/invoices', customerAuth, async (req, res) => { try { const { db: dbi } = require('../database/db'); - const customer = await dbi('customer_accounts').where({ id: req.customer.id }).first(); - if (!customer || customer.feature_bills === false || customer.feature_bills === 0) { - return res.status(403).json({ error: 'Invoices are disabled for this account', code: 'CUSTOMER_FEATURE_DISABLED' }); - } + // Customer-feature gate — master flag AND per-customer override. + if (!(await customerFeatureAllowed(req, res, 'bills', 'Invoices'))) return; // Visibility rules for the customer-facing list: // - Hide `scheduled` always (drafts the admin is still tweaking). // - Show `sent`, `overdue`, `paid` always (the customer's @@ -550,11 +558,9 @@ router.get('/invoices', customerAuth, async (req, res) => { */ router.get('/quotes/:id/pdf', customerAuth, async (req, res) => { try { - // Feature-gate identically to /quotes (list endpoint). - if (req.customer.feature_quotes === false || req.customer.feature_quotes === 0 || req.customer.feature_quotes === '0') { - return res.status(403).json({ error: 'Quotes are disabled for this account' }); - } const { db: dbi } = require('../database/db'); + // Feature-gate — master flag AND per-customer override. + if (!(await customerFeatureAllowed(req, res, 'quotes', 'Quotes'))) return; const quote = await dbi('quotes') .where({ id: parseInt(req.params.id, 10), customer_account_id: req.customer.id }) .first(); @@ -584,6 +590,8 @@ router.get('/quotes/:id/pdf', customerAuth, async (req, res) => { router.get('/invoices/:id/pdf', customerAuth, async (req, res) => { try { const { db: dbi } = require('../database/db'); + // Feature-gate — master flag AND per-customer override. + if (!(await customerFeatureAllowed(req, res, 'bills', 'Invoices'))) return; const invoice = await dbi('invoices') .where({ id: parseInt(req.params.id, 10), customer_account_id: req.customer.id }) .first(); @@ -623,6 +631,8 @@ router.get('/contracts', customerAuth, async (req, res) => { // Feature not migrated on this install yet. return res.json({ contracts: [] }); } + // Contracts gate — master flag AND per-customer override (migration 131). + if (!(await customerFeatureAllowed(req, res, 'contracts', 'Contracts'))) return; const rows = await dbi('contracts') .where({ customer_account_id: req.customer.id }) .whereNotIn('status', ['draft']) @@ -680,6 +690,8 @@ router.get('/contracts/:id/pdf', customerAuth, async (req, res) => { if (!(await dbi.schema.hasTable('contracts'))) { return res.status(404).json({ error: 'Contract not found' }); } + // Contracts gate — master flag AND per-customer override. + if (!(await customerFeatureAllowed(req, res, 'contracts', 'Contracts'))) return; const contract = await dbi('contracts') .where({ id: parseInt(req.params.id, 10), customer_account_id: req.customer.id }) .first(); diff --git a/backend/src/services/customerAccountsService.js b/backend/src/services/customerAccountsService.js index 5a11c6c7..db83b540 100644 --- a/backend/src/services/customerAccountsService.js +++ b/backend/src/services/customerAccountsService.js @@ -480,6 +480,7 @@ async function listCustomers({ search } = {}) { 'customer_accounts.feature_quotes', 'customer_accounts.feature_bills', 'customer_accounts.feature_hours_logging', + 'customer_accounts.feature_contracts', 'customer_accounts.hourly_rate_minor', 'customer_accounts.last_login', 'customer_accounts.created_at', @@ -548,6 +549,9 @@ async function updateCustomer(id, updates, updatedByAdminId) { // Per-customer feature flags (#354 follow-up). Booleans below are // coerced via formatBoolean for SQLite compatibility. 'feature_calendar', 'feature_quotes', 'feature_bills', 'feature_hours_logging', + // Per-customer contracts override (migration 131). Defaults TRUE so + // existing customers keep their Contracts tab. + 'feature_contracts', // CRM billing cadence (migration 102). 'per_event' (default) keeps // each invoice firing on its own schedule; monthly/quarterly snap // every scheduled invoice to billing_cycle_day of the next period. @@ -570,6 +574,7 @@ async function updateCustomer(id, updates, updatedByAdminId) { } else if ( f === 'feature_calendar' || f === 'feature_quotes' || f === 'feature_bills' || f === 'feature_hours_logging' + || f === 'feature_contracts' || f === 'skonto_disabled' ) { allowed[f] = formatBoolean(updates[f]); @@ -1267,9 +1272,9 @@ async function getEffectiveFeaturesForCustomer(customerOrId) { // for hours, so we skip the third gate the bills/quotes use. const hoursMaster = await db('feature_flags').where({ key: 'hoursLogging' }).first(); const hoursLoggingMaster = hoursMaster ? Boolean(hoursMaster.value) : true; - // Contracts (migration 130): no per-customer flag, just the global - // feature_flags row. When on, every customer with an active account - // sees the Contracts tab on their portal. + // Contracts: global feature_flags row AND the per-customer override + // (migration 131). feature_contracts defaults TRUE, so existing customers + // keep their Contracts tab; an admin can hide it per customer. const contractsMaster = await db('feature_flags').where({ key: 'contracts' }).first(); const contractsEnabled = contractsMaster ? Boolean(contractsMaster.value) : false; return { @@ -1277,7 +1282,7 @@ async function getEffectiveFeaturesForCustomer(customerOrId) { quotes: globals.quotesEnabled && truthy(customer.feature_quotes), bills: globals.billsEnabled && truthy(customer.feature_bills), hoursLogging: hoursLoggingMaster && truthy(customer.feature_hours_logging), - contracts: contractsEnabled, + contracts: contractsEnabled && truthy(customer.feature_contracts), }; } diff --git a/backend/src/services/emailIntakeService.js b/backend/src/services/emailIntakeService.js new file mode 100644 index 00000000..e96bff60 --- /dev/null +++ b/backend/src/services/emailIntakeService.js @@ -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 } }; diff --git a/backend/src/services/expenseCategoriesService.js b/backend/src/services/expenseCategoriesService.js new file mode 100644 index 00000000..262de3a9 --- /dev/null +++ b/backend/src/services/expenseCategoriesService.js @@ -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 }; diff --git a/backend/src/services/expenseService.js b/backend/src/services/expenseService.js new file mode 100644 index 00000000..d15b7cc8 --- /dev/null +++ b/backend/src/services/expenseService.js @@ -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 }, +}; diff --git a/backend/src/services/extractionService.js b/backend/src/services/extractionService.js new file mode 100644 index 00000000..7c15d0e5 --- /dev/null +++ b/backend/src/services/extractionService.js @@ -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 }; diff --git a/backend/src/services/invoiceService.js b/backend/src/services/invoiceService.js index 4f708097..b25400bd 100644 --- a/backend/src/services/invoiceService.js +++ b/backend/src/services/invoiceService.js @@ -43,6 +43,7 @@ function getHierarchyHelpers() { // D.2 — `ensureInt` + `ensureNumber` consolidated into utils/numericHelpers. const { ensureInt, ensureNumber } = require('../utils/numericHelpers'); +const { hasColumnCached } = require('../utils/schemaCache'); function formatNumberInTemplate(format, year, seq) { return format @@ -955,6 +956,11 @@ async function createInvoice(payload, adminId, trx = db) { created_at: new Date(), updated_at: new Date(), }; + // Migration 130 — snapshot the chosen output VAT code (immutable; the + // accounting export emits exactly this rather than re-deriving from the map). + if (payload.vatCode !== undefined && await hasColumnCached('invoices', 'vat_code')) { + row.vat_code = payload.vatCode ? String(payload.vatCode).slice(0, 16) : null; + } const inserted = await trx('invoices').insert(row).returning('id'); const invoiceId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0]; @@ -1774,6 +1780,8 @@ async function buildInvoiceRenderContext(invoice, lineItems) { totals: { netAmountMinor: invoice.net_amount_minor, vatRate: invoice.vat_rate, + // Migration 130 — VAT-code snapshot (so re-editing preserves it). + vatCode: invoice.vat_code ?? null, vatAmountMinor: invoice.vat_amount_minor, shippingAmountMinor: invoice.shipping_amount_minor, totalAmountMinor: invoice.total_amount_minor, @@ -2212,6 +2220,10 @@ async function createStorno(originalId, adminId, trx = db) { currency: original.currency, language: original.language, vat_rate: original.vat_rate, + // Migration 130 — carry the original's VAT-code snapshot onto the Storno so + // both documents export the same code. Conditional spread = safe on pre-130 + // DBs (undefined → omitted). + ...(original.vat_code ? { vat_code: original.vat_code } : {}), shipping_amount_minor: -ensureInt(original.shipping_amount_minor || 0), net_amount_minor: -ensureInt(original.net_amount_minor), vat_amount_minor: -ensureInt(original.vat_amount_minor), diff --git a/backend/src/services/ledgerService.js b/backend/src/services/ledgerService.js new file mode 100644 index 00000000..a1b873ea --- /dev/null +++ b/backend/src/services/ledgerService.js @@ -0,0 +1,507 @@ +/** + * ledgerService — Accounting Layer A: chart of accounts + VAT codes + a + * Treuhänder export. + * + * picpeak is NOT a double-entry ledger (that's Layer B). This service: + * 1. CRUD for `ledger_accounts` (Swiss/LI KMU-Kontenrahmen) + `vat_codes`, + * plus the category→account and tax_treatment→VAT-code mappings. + * 2. buildPostings(): turns the data we already capture (revenue invoices, + * incoming supplier invoices, internal expenses) into balanced + * "Buchungssätze" — accrual-dated, single-row Soll/Haben entries with a + * VAT code the target software expands. + * 3. Export formatters (generic / Banana / bexio) so a Treuhänder can import + * the collective journal. + * + * Accrual basis only — payment/bank postings are Layer B (bank reconciliation). + * Legal/financial output is a GUIDELINE: every surface must point the user at a + * Treuhänder ([[feedback_legal_financial_examples_only]]). + */ + +const { db, withRetry } = require('../database/db'); +const { getAppSetting } = require('../utils/appSettings'); +const { buildCustomerLabel } = require('./taxReportService')._internal; +const { ensureInt } = require('../utils/numericHelpers'); +const { neutralizeSpreadsheetFormula } = require('../utils/spreadsheetSafe'); + +const ACCOUNT_TYPES = ['asset', 'liability', 'equity', 'revenue', 'expense']; +const VAT_DIRECTIONS = ['output', 'input']; + +// Statuses we book. Mirrors taxReportService: cancelled originals are excluded +// (the storno reissue, a negative-total row, carries the reversal). +const REVENUE_STATUSES = ['sent', 'paid', 'overdue', 'pending_delivery']; + +// ── helpers ────────────────────────────────────────────────────────── +function rateKey(rate) { + // Normalise 8.10 → '8.1', 0 → '0' so it matches the seeded output-VAT map. + const n = Number(rate); + if (!Number.isFinite(n)) return '0'; + return String(Number(n.toFixed(2))); +} + +/** + * Resolve all the config the posting engine needs in one shot: account + * lookup maps + VAT-code lookup + the default-account / VAT-mapping settings. + */ +async function getConfig() { + const [accounts, vatCodes] = await Promise.all([ + db('ledger_accounts').select('id', 'number', 'name', 'type', 'active'), + db('vat_codes').select('id', 'code', 'name', 'rate', 'direction', 'account_id', 'active'), + ]); + const accountByNumber = new Map(accounts.map((a) => [a.number, a])); + const accountById = new Map(accounts.map((a) => [a.id, a])); + + const [ + debitoren, kreditoren, defaultRevenue, defaultExpense, mileage, perDiem, rebilled, + vatMap, outputVatMap, + ] = await Promise.all([ + getAppSetting('ledger_account_debitoren', '1100'), + getAppSetting('ledger_account_kreditoren', '2000'), + getAppSetting('ledger_account_default_revenue', '3400'), + getAppSetting('ledger_account_default_expense', '6700'), + getAppSetting('ledger_account_mileage', '6200'), + getAppSetting('ledger_account_per_diem', '6640'), + getAppSetting('ledger_account_rebilled_revenue', '3940'), + getAppSetting('ledger_vat_map', {}), + getAppSetting('ledger_output_vat_map', {}), + ]); + + return { + accounts, vatCodes, accountByNumber, accountById, + settings: { + debitoren, kreditoren, defaultRevenue, defaultExpense, mileage, perDiem, rebilled, + }, + vatMap: vatMap || {}, + outputVatMap: outputVatMap || {}, + }; +} + +// ── CRUD: accounts ─────────────────────────────────────────────────── +async function listAccounts() { + return db('ledger_accounts').orderBy('number', 'asc').select('*'); +} + +async function createAccount({ number, name, type }) { + if (!number || !name) throw httpError(400, 'number and name are required', 'VALIDATION'); + if (!ACCOUNT_TYPES.includes(type)) throw httpError(400, 'invalid account type', 'VALIDATION'); + const exists = await db('ledger_accounts').where({ number }).first(); + if (exists) throw httpError(409, 'an account with this number already exists', 'DUPLICATE'); + const [row] = await db('ledger_accounts') + .insert({ number, name, type, is_seed: false, active: true }) + .returning('*'); + return row || db('ledger_accounts').where({ number }).first(); +} + +async function updateAccount(id, { number, name, type, active }) { + const patch = { updated_at: new Date() }; + if (number !== undefined) patch.number = number; + if (name !== undefined) patch.name = name; + if (type !== undefined) { + if (!ACCOUNT_TYPES.includes(type)) throw httpError(400, 'invalid account type', 'VALIDATION'); + patch.type = type; + } + if (active !== undefined) patch.active = !!active; + if (patch.number) { + const clash = await db('ledger_accounts').where({ number: patch.number }).whereNot({ id }).first(); + if (clash) throw httpError(409, 'an account with this number already exists', 'DUPLICATE'); + } + await db('ledger_accounts').where({ id }).update(patch); + return db('ledger_accounts').where({ id }).first(); +} + +/** Hard-delete only when nothing references the account; otherwise tell the + * caller to deactivate instead (keeps mappings + exports stable). */ +async function deleteAccount(id) { + const acct = await db('ledger_accounts').where({ id }).first(); + if (!acct) throw httpError(404, 'account not found', 'NOT_FOUND'); + const refs = await accountReferences(acct); + if (refs.length) throw httpError(409, `account is in use (${refs.join(', ')}) — deactivate it instead`, 'IN_USE'); + await db('ledger_accounts').where({ id }).del(); + return { deleted: true }; +} + +async function accountReferences(acct) { + const refs = []; + const vat = await db('vat_codes').where({ account_id: acct.id }).first(); + if (vat) refs.push('VAT code'); + if (await db.schema.hasColumn('expense_categories', 'ledger_account_id')) { + const cat = await db('expense_categories').where({ ledger_account_id: acct.id }).first(); + if (cat) refs.push('expense category'); + } + // Default-account settings reference accounts by NUMBER. + const settingKeys = ['ledger_account_debitoren', 'ledger_account_kreditoren', 'ledger_account_bank', + 'ledger_account_cash', 'ledger_account_default_revenue', 'ledger_account_default_expense', + 'ledger_account_mileage', 'ledger_account_per_diem', 'ledger_account_rebilled_revenue']; + const settingRows = await db('app_settings').whereIn('setting_key', settingKeys).select('setting_value'); + if (settingRows.some((r) => safeParse(r.setting_value) === acct.number)) refs.push('default-account setting'); + return refs; +} + +// ── CRUD: VAT codes ────────────────────────────────────────────────── +async function listVatCodes() { + return db('vat_codes').orderBy('display_order', 'asc').select('*'); +} + +async function createVatCode({ code, name, rate, direction, accountId }) { + if (!code || !name) throw httpError(400, 'code and name are required', 'VALIDATION'); + if (!VAT_DIRECTIONS.includes(direction)) throw httpError(400, 'invalid direction', 'VALIDATION'); + const exists = await db('vat_codes').where({ code }).first(); + if (exists) throw httpError(409, 'a VAT code with this code already exists', 'DUPLICATE'); + const [row] = await db('vat_codes') + .insert({ code, name, rate: Number(rate) || 0, direction, account_id: accountId || null, is_seed: false, active: true }) + .returning('*'); + return row || db('vat_codes').where({ code }).first(); +} + +async function updateVatCode(id, { code, name, rate, direction, accountId, active }) { + const patch = { updated_at: new Date() }; + if (code !== undefined) patch.code = code; + if (name !== undefined) patch.name = name; + if (rate !== undefined) patch.rate = Number(rate) || 0; + if (direction !== undefined) { + if (!VAT_DIRECTIONS.includes(direction)) throw httpError(400, 'invalid direction', 'VALIDATION'); + patch.direction = direction; + } + if (accountId !== undefined) patch.account_id = accountId || null; + if (active !== undefined) patch.active = !!active; + if (patch.code) { + const clash = await db('vat_codes').where({ code: patch.code }).whereNot({ id }).first(); + if (clash) throw httpError(409, 'a VAT code with this code already exists', 'DUPLICATE'); + } + await db('vat_codes').where({ id }).update(patch); + return db('vat_codes').where({ id }).first(); +} + +async function deleteVatCode(id) { + const vat = await db('vat_codes').where({ id }).first(); + if (!vat) throw httpError(404, 'VAT code not found', 'NOT_FOUND'); + // Referenced by the tax_treatment / output-rate maps? + const [vatMap, outputVatMap] = await Promise.all([ + getAppSetting('ledger_vat_map', {}), getAppSetting('ledger_output_vat_map', {}), + ]); + const used = Object.values(vatMap || {}).includes(vat.code) || Object.values(outputVatMap || {}).includes(vat.code); + if (used) throw httpError(409, 'VAT code is referenced by a mapping — change the mapping first', 'IN_USE'); + await db('vat_codes').where({ id }).del(); + return { deleted: true }; +} + +// ── mappings (categories + settings) ───────────────────────────────── +async function getMappings() { + const hasCol = await db.schema.hasColumn('expense_categories', 'ledger_account_id'); + const categories = await db('expense_categories') + .orderBy('display_order', 'asc') + .select('id', 'name', 'color', hasCol ? 'ledger_account_id' : db.raw('NULL as ledger_account_id')); + const settingKeys = ['ledger_account_debitoren', 'ledger_account_kreditoren', 'ledger_account_bank', + 'ledger_account_cash', 'ledger_account_default_revenue', 'ledger_account_default_expense', + 'ledger_account_mileage', 'ledger_account_per_diem', 'ledger_account_rebilled_revenue', + 'ledger_vat_map', 'ledger_output_vat_map']; + const rows = await db('app_settings').whereIn('setting_key', settingKeys).select('setting_key', 'setting_value'); + const settings = {}; + for (const r of rows) settings[r.setting_key] = safeParse(r.setting_value); + return { categories, settings }; +} + +async function setCategoryAccount(categoryId, ledgerAccountId) { + if (!(await db.schema.hasColumn('expense_categories', 'ledger_account_id'))) { + throw httpError(409, 'category→account mapping column missing', 'SCHEMA'); + } + await db('expense_categories').where({ id: categoryId }).update({ ledger_account_id: ledgerAccountId || null }); + return db('expense_categories').where({ id: categoryId }).first(); +} + +/** Update the ledger_* app_settings (default accounts + VAT maps). Only + * whitelisted keys; values stored JSON-stringified (matching the store). */ +async function updateSettings(patch) { + const allowed = new Set(['ledger_account_debitoren', 'ledger_account_kreditoren', 'ledger_account_bank', + 'ledger_account_cash', 'ledger_account_default_revenue', 'ledger_account_default_expense', + 'ledger_account_mileage', 'ledger_account_per_diem', 'ledger_account_rebilled_revenue', + 'ledger_vat_map', 'ledger_output_vat_map']); + const updated = []; + for (const [key, value] of Object.entries(patch || {})) { + if (!allowed.has(key)) continue; + const existing = await db('app_settings').where({ setting_key: key }).first(); + if (existing) { + await db('app_settings').where({ setting_key: key }).update({ setting_value: JSON.stringify(value) }); + } else { + await db('app_settings').insert({ setting_key: key, setting_value: JSON.stringify(value), setting_type: 'accounting' }); + } + updated.push(key); + } + return { updated }; +} + +// ── posting engine ─────────────────────────────────────────────────── +/** + * Build the accrual collective journal for [from, to] in `cur`. + * + * Returns { postings, currency, period }. Each posting is a single + * Buchungssatz: + * { date, docNumber, description, debitAccount, debitName, creditAccount, + * creditName, grossMinor, netMinor, vatMinor, vatCode, vatRate, source, + * eventName } + * Amount is GROSS (the VAT code lets the target software expand net+VAT). + * `netMinor`/`vatMinor` are included for tooling that imports net amounts. + */ +async function buildPostings({ from, to, currency } = {}) { + if (!from || !to) throw httpError(400, '`from` and `to` are required (YYYY-MM-DD)', 'VALIDATION'); + if (!currency) throw httpError(400, '`currency` is required', 'VALIDATION'); + const cur = String(currency).toUpperCase(); + // Inclusive end-of-day bound; plain range comparison (no SQL date()) so it's + // valid on both Postgres and SQLite. + const toEnd = `${to} 23:59:59.999`; + + return withRetry(async () => { + const cfg = await getConfig(); + const nameOf = (number) => cfg.accountByNumber.get(number)?.name || ''; + const postings = []; + + // 1) Revenue invoices → Dr Debitoren / Cr Ertrag (gross, output VAT code). + // Migration 130 — prefer the snapshotted vat_code; guard for pre-130 DBs. + const hasInvVatCode = await db.schema.hasColumn('invoices', 'vat_code'); + const invoices = await db('invoices') + .leftJoin('customer_accounts', 'invoices.customer_account_id', 'customer_accounts.id') + .leftJoin('events', 'invoices.event_id', 'events.id') + .whereBetween('invoices.issue_date', [from, to]) + .where('invoices.currency', cur) + .whereIn('invoices.status', REVENUE_STATUSES) + .orderBy('invoices.issue_date', 'asc') + .select( + 'invoices.id', 'invoices.invoice_number', 'invoices.issue_date', 'invoices.vat_rate', + ...(hasInvVatCode ? ['invoices.vat_code'] : []), + 'invoices.net_amount_minor', 'invoices.vat_amount_minor', 'invoices.total_amount_minor', + 'customer_accounts.company_name as customer_company_name', + 'customer_accounts.first_name as customer_first_name', + 'customer_accounts.last_name as customer_last_name', + 'customer_accounts.display_name as customer_display_name', + 'customer_accounts.email as customer_email', + db.raw('COALESCE(invoices.event_name, events.event_name) AS event_name'), + ); + for (const inv of invoices) { + const revAcct = cfg.settings.defaultRevenue; + // Prefer the snapshot taken at issue time; legacy rows fall back to the + // (mutable) rate→code map. + const vatCode = inv.vat_code || cfg.outputVatMap[rateKey(inv.vat_rate)] || ''; + const label = buildCustomerLabel(inv); + postings.push({ + date: inv.issue_date, + docNumber: inv.invoice_number || '', + description: [inv.invoice_number, label].filter(Boolean).join(' · '), + debitAccount: cfg.settings.debitoren, debitName: nameOf(cfg.settings.debitoren), + creditAccount: revAcct, creditName: nameOf(revAcct), + grossMinor: ensureInt(inv.total_amount_minor), + netMinor: ensureInt(inv.net_amount_minor), + vatMinor: ensureInt(inv.vat_amount_minor), + vatCode, vatRate: Number(inv.vat_rate) || 0, + source: 'revenue', eventName: inv.event_name || '', + }); + } + + // 2) Incoming supplier invoices → Dr Aufwand / Cr Kreditoren (input VAT). + if (await db.schema.hasTable('inbound_documents')) { + const hasCatCol = await db.schema.hasColumn('expense_categories', 'ledger_account_id'); + const inbound = await db('inbound_documents') + .leftJoin('events', 'inbound_documents.event_id', 'events.id') + .modify((q) => { + if (hasCatCol) q.leftJoin('expense_categories', 'inbound_documents.category_id', 'expense_categories.id'); + }) + .where((qb) => { + qb.whereBetween('inbound_documents.invoice_date', [from, to]) + .orWhere((q2) => q2.whereNull('inbound_documents.invoice_date') + .andWhere('inbound_documents.created_at', '>=', from) + .andWhere('inbound_documents.created_at', '<=', toEnd)); + }) + .where((qb) => { qb.where('inbound_documents.currency', cur).orWhereNull('inbound_documents.currency'); }) + .whereNotIn('inbound_documents.status', ['declined', 'duplicate']) + .orderBy('inbound_documents.created_at', 'asc') + .select( + 'inbound_documents.id', 'inbound_documents.invoice_number', 'inbound_documents.invoice_date', + 'inbound_documents.created_at', 'inbound_documents.supplier_name', 'inbound_documents.tax_treatment', + 'inbound_documents.net_amount_minor', 'inbound_documents.vat_amount_minor', 'inbound_documents.total_amount_minor', + 'inbound_documents.event_id', + hasCatCol ? 'expense_categories.ledger_account_id as cat_account_id' : db.raw('NULL as cat_account_id'), + 'events.event_name as event_name', + ); + for (const d of inbound) { + const acctNumber = cfg.accountById.get(d.cat_account_id)?.number || cfg.settings.defaultExpense; + const vatCode = cfg.vatMap[d.tax_treatment || 'domestic'] || ''; + const gross = ensureInt(d.total_amount_minor) || (ensureInt(d.net_amount_minor) + ensureInt(d.vat_amount_minor)); + postings.push({ + date: d.invoice_date || d.created_at, + docNumber: d.invoice_number || '', + description: [d.supplier_name, d.invoice_number].filter(Boolean).join(' · '), + debitAccount: acctNumber, debitName: nameOf(acctNumber), + creditAccount: cfg.settings.kreditoren, creditName: nameOf(cfg.settings.kreditoren), + grossMinor: gross, + netMinor: ensureInt(d.net_amount_minor) || (gross - ensureInt(d.vat_amount_minor)), + vatMinor: ensureInt(d.vat_amount_minor), + vatCode, vatRate: 0, + source: 'incoming', eventName: d.event_id ? (d.event_name || '') : '', + }); + } + } + + // 3) Internal expenses → Dr Aufwand / Cr Kreditoren (input VAT). + if (await db.schema.hasTable('expenses')) { + const hasCatCol = await db.schema.hasColumn('expense_categories', 'ledger_account_id'); + const expenses = await db('expenses') + .leftJoin('events', 'expenses.event_id', 'events.id') + .modify((q) => { + if (hasCatCol) q.leftJoin('expense_categories', 'expenses.category_id', 'expense_categories.id'); + }) + .whereRaw('expenses.created_at >= ? AND expenses.created_at <= ?', [from, toEnd]) + .whereNot('expenses.status', 'declined') + .whereNotIn('expenses.disposition', ['duplikat', 'abgelehnt']) + .modify((q) => { if (cur !== 'CHF') q.where('expenses.original_currency', cur); }) + .orderBy('expenses.created_at', 'asc') + .select( + 'expenses.id', 'expenses.created_at', 'expenses.kind', 'expenses.supplier_name', 'expenses.description', + 'expenses.tax_treatment', 'expenses.event_id', + 'expenses.original_amount_minor', 'expenses.chf_amount_minor', + 'expenses.net_amount_minor', 'expenses.vat_amount_minor', 'expenses.gross_amount_minor', + hasCatCol ? 'expense_categories.ledger_account_id as cat_account_id' : db.raw('NULL as cat_account_id'), + 'events.event_name as event_name', + ); + const isChf = cur === 'CHF'; + for (const e of expenses) { + // Account: category mapping → kind default (mileage/per-diem) → default expense. + let acctNumber = cfg.accountById.get(e.cat_account_id)?.number; + if (!acctNumber && e.kind === 'mileage') acctNumber = cfg.settings.mileage; + if (!acctNumber && e.kind === 'per_diem') acctNumber = cfg.settings.perDiem; + if (!acctNumber) acctNumber = cfg.settings.defaultExpense; + const vatCode = cfg.vatMap[e.tax_treatment || 'domestic'] || ''; + const base = isChf ? ensureInt(e.chf_amount_minor) : ensureInt(e.original_amount_minor); + const gross = ensureInt(e.gross_amount_minor) || ((ensureInt(e.net_amount_minor) || ensureInt(e.vat_amount_minor)) ? ensureInt(e.net_amount_minor) + ensureInt(e.vat_amount_minor) : base); + postings.push({ + date: e.created_at, + docNumber: `EXP-${e.id}`, + description: e.description || e.supplier_name || `Expense #${e.id}`, + debitAccount: acctNumber, debitName: nameOf(acctNumber), + creditAccount: cfg.settings.kreditoren, creditName: nameOf(cfg.settings.kreditoren), + grossMinor: gross, + netMinor: ensureInt(e.net_amount_minor) || (gross - ensureInt(e.vat_amount_minor)), + vatMinor: ensureInt(e.vat_amount_minor), + vatCode, vatRate: 0, + source: 'expense', eventName: e.event_id ? (e.event_name || '') : '', + }); + } + } + + postings.sort((a, b) => String(a.date || '').localeCompare(String(b.date || ''))); + return { postings, currency: cur, period: { from, to } }; + }); +} + +// ── export formatters ──────────────────────────────────────────────── +function csvEscape(cell) { + // Formula-injection defence (Excel/Numbers/Banana) THEN RFC-4180 quote-wrap. + const s = neutralizeSpreadsheetFormula(cell === null || cell === undefined ? '' : String(cell)); + return `"${s.replace(/"/g, '""')}"`; +} +function minorToDecimal(m) { return ((Number(m) || 0) / 100).toFixed(2); } +// yyyy-mm-dd, robust to Postgres returning DATE/TIMESTAMP columns as JS Date +// objects (SQLite returns strings). String(dateObj).slice(0,10) yields +// "Thu Jan 15", which Banana / accounting tools reject — so format the calendar +// parts explicitly. Uses local parts (DATE columns come back at local midnight). +function dateOnly(d) { + if (!d) return ''; + if (d instanceof Date) { + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${y}-${m}-${day}`; + } + return String(d).slice(0, 10); +} + +const EXPORT_FORMATS = ['generic', 'banana', 'banana_ie', 'bexio']; + +/** + * Render the collective journal in the requested format. Returns + * { content, filename, contentType }. All are single-row Soll/Haben + * ("two-account") layouts with a VAT-code column — the universal Swiss + * import shape Banana + bexio both accept. + */ +async function exportPostings({ from, to, currency, format = 'generic' } = {}) { + const fmt = EXPORT_FORMATS.includes(format) ? format : 'generic'; + const { postings, currency: cur, period } = await buildPostings({ from, to, currency }); + const eol = '\r\n'; + let headers; let rowOf; + + if (fmt === 'banana') { + // Banana "Conti doppia" import (Actions → Import into accounting → "Text + // file with column headers"): Date, Doc, Description, AccountDebit, + // AccountCredit, Amount, VatCode. Amount = gross; VatCode expands VAT. + // Serialised TAB-separated + .txt below (Banana's required shape). + headers = ['Date', 'Doc', 'Description', 'AccountDebit', 'AccountCredit', 'Amount', 'VatCode']; + rowOf = (p) => [dateOnly(p.date), p.docNumber, p.description, p.debitAccount, p.creditAccount, minorToDecimal(p.grossMinor), p.vatCode]; + } else if (fmt === 'banana_ie') { + // Banana Income & Expense (cash-book / Einnahmen-Ausgaben) import — for a + // file that is NOT double-entry. Columns: Date, Doc, Description, Income, + // Expenses, Category (the income/expense category account), VatCode. NOTE: + // the category column's NameXml in an I&E file is "Category" (verified + // against a real Banana file) — NOT the double-entry "ContraAccount". + // Revenue → gross in Income + the revenue account; cost → gross in Expenses + // + the expense account. VatCode warns harmlessly on a non-VAT file. Same + // TAB-separated .txt shape as double-entry. + headers = ['Date', 'Doc', 'Description', 'Income', 'Expenses', 'Category', 'VatCode']; + rowOf = (p) => { + const isRevenue = p.source === 'revenue'; + const amount = minorToDecimal(p.grossMinor); + // Category = the P&L account: revenue account (credit side) for income, + // expense account (debit side) for costs. + const category = isRevenue ? p.creditAccount : p.debitAccount; + return [dateOnly(p.date), p.docNumber, p.description, + isRevenue ? amount : '', isRevenue ? '' : amount, category, p.vatCode]; + }; + } else if (fmt === 'bexio') { + // bexio manual-entry import. + headers = ['date', 'reference_nr', 'description', 'debit_account', 'credit_account', 'amount', 'tax_code', 'currency']; + rowOf = (p) => [dateOnly(p.date), p.docNumber, p.description, p.debitAccount, p.creditAccount, minorToDecimal(p.grossMinor), p.vatCode, cur]; + } else { + // Generic — every column a human or any tool could want. + headers = ['Date', 'DocNumber', 'Description', 'Source', 'Event', + 'DebitAccount', 'DebitAccountName', 'CreditAccount', 'CreditAccountName', + 'VatCode', 'Currency', 'GrossAmount', 'NetAmount', 'VatAmount']; + rowOf = (p) => [dateOnly(p.date), p.docNumber, p.description, p.source, p.eventName, + p.debitAccount, p.debitName, p.creditAccount, p.creditName, + p.vatCode, cur, minorToDecimal(p.grossMinor), minorToDecimal(p.netMinor), minorToDecimal(p.vatMinor)]; + } + + // Banana's "Text file with column headers" import (banana.ch doc 9946/9947) + // requires a TAB-separated .txt with UNQUOTED values — a comma .csv won't even + // appear in its *.txt file picker. Both Banana variants use it; generic / + // bexio stay comma-CSV (RFC 4180). + const isTab = fmt === 'banana' || fmt === 'banana_ie'; + const sep = isTab ? '\t' : ','; + // Tab layout: strip any tab/newline from a cell so it can't split the row; + // CSV cells go through the RFC-4180 quoter instead. + const fmtCell = isTab + ? (v) => neutralizeSpreadsheetFormula(String(v == null ? '' : v).replace(/[\t\r\n]+/g, ' ')) + : csvEscape; + const lines = [headers.map(fmtCell).join(sep)]; + for (const p of postings) lines.push(rowOf(p).map(fmtCell).join(sep)); + const content = lines.join(eol) + eol; + const ext = isTab ? 'txt' : 'csv'; + const filename = `journal_${period.from}_to_${period.to}_${cur}_${fmt}.${ext}`; + const contentType = isTab ? 'text/plain; charset=utf-8' : 'text/csv; charset=utf-8'; + return { content, filename, contentType, count: postings.length }; +} + +// ── small util ─────────────────────────────────────────────────────── +function httpError(status, message, code) { + const err = new Error(message); + err.status = status; err.statusCode = status; err.code = code; + return err; +} +function safeParse(v) { + if (v == null) return null; + try { return JSON.parse(v); } catch (_) { return v; } +} + +module.exports = { + ACCOUNT_TYPES, VAT_DIRECTIONS, EXPORT_FORMATS, + listAccounts, createAccount, updateAccount, deleteAccount, + listVatCodes, createVatCode, updateVatCode, deleteVatCode, + getMappings, setCategoryAccount, updateSettings, + getConfig, buildPostings, exportPostings, + _internal: { rateKey, csvEscape, neutralizeSpreadsheetFormula, minorToDecimal }, +}; diff --git a/backend/src/services/pdf-i18n.js b/backend/src/services/pdf-i18n.js index 3f241ab2..22ce8e78 100644 --- a/backend/src/services/pdf-i18n.js +++ b/backend/src/services/pdf-i18n.js @@ -82,6 +82,13 @@ const LABELS = { tax_col_total: 'Gross', tax_col_status: 'Status', tax_col_skonto: 'Skonto', + tax_col_type: 'Type', + tax_col_reference: 'Reference', + tax_col_party: 'Customer / supplier', + tax_col_tax: 'Tax', + tax_type_outgoing: 'Outgoing invoice', + tax_type_incoming: 'Incoming invoice', + tax_type_expense: 'Expense', tax_status_cancelled: 'Cancelled', tax_totals_by_rate: 'Totals by VAT rate', tax_grand_total_net: 'Total net', @@ -89,6 +96,18 @@ const LABELS = { tax_grand_total_gross: 'Total gross', tax_cancelled_footnote: '{count} cancelled invoice(s) — amounts excluded from totals (shown for audit-trail continuity).', tax_no_invoices: 'No invoices in this period.', + tax_costs_section: 'Costs (incoming invoices + expenses)', + tax_summary_section: 'Summary (income / costs)', + tax_cost_col_source: 'Type', + tax_cost_col_supplier: 'Supplier / description', + tax_cost_col_tax_treatment: 'Tax treatment', + tax_cost_source_incoming: 'Incoming invoice', + tax_cost_source_expense: 'Expense', + tax_cost_total: 'Total costs', + tax_summary_income: 'Income', + tax_summary_costs: 'Costs', + tax_summary_result: 'Result', + tax_summary_vat_payable: 'VAT payable (output − input)', // Contracts (migration 130). Section labels stay in sync with the // SECTIONS_ORDER enum in contractService. contract_title: 'Contract', @@ -190,6 +209,13 @@ const LABELS = { tax_col_total: 'Brutto', tax_col_status: 'Status', tax_col_skonto: 'Skonto', + tax_col_type: 'Typ', + tax_col_reference: 'Referenz', + tax_col_party: 'Kunde / Lieferant', + tax_col_tax: 'Steuer', + tax_type_outgoing: 'Ausgangsrechnung', + tax_type_incoming: 'Eingangsrechnung', + tax_type_expense: 'Aufwand', tax_status_cancelled: 'Storniert', tax_totals_by_rate: 'Summen nach MwSt-Satz', tax_grand_total_net: 'Gesamt Netto', @@ -197,6 +223,18 @@ const LABELS = { tax_grand_total_gross: 'Gesamt Brutto', tax_cancelled_footnote: '{count} stornierte Rechnung(en) — Beträge nicht in den Summen enthalten (für lückenlose Nummernfolge dargestellt).', tax_no_invoices: 'Keine Rechnungen in diesem Zeitraum.', + tax_costs_section: 'Kosten (Eingangsrechnungen + Spesen)', + tax_summary_section: 'Zusammenfassung (Einnahmen / Ausgaben)', + tax_cost_col_source: 'Art', + tax_cost_col_supplier: 'Lieferant / Beschreibung', + tax_cost_col_tax_treatment: 'Steuerliche Behandlung', + tax_cost_source_incoming: 'Eingangsrechnung', + tax_cost_source_expense: 'Spese', + tax_cost_total: 'Summe Kosten', + tax_summary_income: 'Einnahmen', + tax_summary_costs: 'Ausgaben', + tax_summary_result: 'Ergebnis', + tax_summary_vat_payable: 'MWST-Zahllast (Umsatz- − Vorsteuer)', contract_title: 'Vertrag', contract_number_label: 'Vertragsnummer', section_basics: 'Vertragsgrundlagen', @@ -291,6 +329,13 @@ const LABELS = { tax_col_total: 'Brut', tax_col_status: 'Statut', tax_col_skonto: 'Escompte', + tax_col_type: 'Type', + tax_col_reference: 'Reference', + tax_col_party: 'Customer / supplier', + tax_col_tax: 'Tax', + tax_type_outgoing: 'Outgoing invoice', + tax_type_incoming: 'Incoming invoice', + tax_type_expense: 'Expense', tax_status_cancelled: 'Annulée', tax_totals_by_rate: 'Totaux par taux de TVA', tax_grand_total_net: 'Total net', @@ -298,6 +343,18 @@ const LABELS = { tax_grand_total_gross: 'Total brut', tax_cancelled_footnote: '{count} facture(s) annulée(s) — montants exclus des totaux (affichés pour la continuité de la piste d\'audit).', tax_no_invoices: 'Aucune facture sur cette période.', + tax_costs_section: 'Charges (factures entrantes + frais)', + tax_summary_section: 'Résumé (revenus / charges)', + tax_cost_col_source: 'Type', + tax_cost_col_supplier: 'Fournisseur / description', + tax_cost_col_tax_treatment: 'Traitement fiscal', + tax_cost_source_incoming: 'Facture entrante', + tax_cost_source_expense: 'Frais', + tax_cost_total: 'Total des charges', + tax_summary_income: 'Revenus', + tax_summary_costs: 'Charges', + tax_summary_result: 'Résultat', + tax_summary_vat_payable: 'TVA à payer (collectée − déductible)', }, nl: { // Machine-translated, flagged for native review. @@ -361,6 +418,13 @@ const LABELS = { tax_col_total: 'Bruto', tax_col_status: 'Status', tax_col_skonto: 'Korting', + tax_col_type: 'Type', + tax_col_reference: 'Reference', + tax_col_party: 'Customer / supplier', + tax_col_tax: 'Tax', + tax_type_outgoing: 'Outgoing invoice', + tax_type_incoming: 'Incoming invoice', + tax_type_expense: 'Expense', tax_status_cancelled: 'Geannuleerd', tax_totals_by_rate: 'Totalen per btw-tarief', tax_grand_total_net: 'Totaal netto', @@ -368,6 +432,18 @@ const LABELS = { tax_grand_total_gross: 'Totaal bruto', tax_cancelled_footnote: '{count} geannuleerde factu(u)r(en) — bedragen uitgesloten van totalen (getoond voor continuïteit van het audit-spoor).', tax_no_invoices: 'Geen facturen in deze periode.', + tax_costs_section: 'Kosten (inkomende facturen + onkosten)', + tax_summary_section: 'Samenvatting (inkomsten / kosten)', + tax_cost_col_source: 'Type', + tax_cost_col_supplier: 'Leverancier / omschrijving', + tax_cost_col_tax_treatment: 'Fiscale behandeling', + tax_cost_source_incoming: 'Inkomende factuur', + tax_cost_source_expense: 'Onkosten', + tax_cost_total: 'Totale kosten', + tax_summary_income: 'Inkomsten', + tax_summary_costs: 'Kosten', + tax_summary_result: 'Resultaat', + tax_summary_vat_payable: 'Te betalen btw (af − voor)', }, pt: { // Machine-translated, flagged for native review. @@ -431,6 +507,13 @@ const LABELS = { tax_col_total: 'Bruto', tax_col_status: 'Estado', tax_col_skonto: 'Desconto', + tax_col_type: 'Type', + tax_col_reference: 'Reference', + tax_col_party: 'Customer / supplier', + tax_col_tax: 'Tax', + tax_type_outgoing: 'Outgoing invoice', + tax_type_incoming: 'Incoming invoice', + tax_type_expense: 'Expense', tax_status_cancelled: 'Cancelada', tax_totals_by_rate: 'Totais por taxa de IVA', tax_grand_total_net: 'Total líquido', @@ -438,6 +521,18 @@ const LABELS = { tax_grand_total_gross: 'Total bruto', tax_cancelled_footnote: '{count} fatura(s) cancelada(s) — valores excluídos dos totais (apresentados para continuidade do rastro de auditoria).', tax_no_invoices: 'Sem faturas neste período.', + tax_costs_section: 'Custos (faturas recebidas + despesas)', + tax_summary_section: 'Resumo (receitas / custos)', + tax_cost_col_source: 'Tipo', + tax_cost_col_supplier: 'Fornecedor / descrição', + tax_cost_col_tax_treatment: 'Tratamento fiscal', + tax_cost_source_incoming: 'Fatura recebida', + tax_cost_source_expense: 'Despesa', + tax_cost_total: 'Total de custos', + tax_summary_income: 'Receitas', + tax_summary_costs: 'Custos', + tax_summary_result: 'Resultado', + tax_summary_vat_payable: 'IVA a pagar (cobrado − dedutível)', }, ru: { // Machine-translated, flagged for native review. @@ -501,6 +596,13 @@ const LABELS = { tax_col_total: 'Брутто', tax_col_status: 'Статус', tax_col_skonto: 'Скидка', + tax_col_type: 'Type', + tax_col_reference: 'Reference', + tax_col_party: 'Customer / supplier', + tax_col_tax: 'Tax', + tax_type_outgoing: 'Outgoing invoice', + tax_type_incoming: 'Incoming invoice', + tax_type_expense: 'Expense', tax_status_cancelled: 'Аннулирован', tax_totals_by_rate: 'Итоги по ставкам НДС', tax_grand_total_net: 'Итого нетто', @@ -508,6 +610,18 @@ const LABELS = { tax_grand_total_gross: 'Итого брутто', tax_cancelled_footnote: '{count} аннулированных счёт(а/ов) — суммы исключены из итогов (показаны для непрерывности аудиторской цепочки).', tax_no_invoices: 'Нет счетов за этот период.', + tax_costs_section: 'Расходы (входящие счета + издержки)', + tax_summary_section: 'Итоги (доходы / расходы)', + tax_cost_col_source: 'Тип', + tax_cost_col_supplier: 'Поставщик / описание', + tax_cost_col_tax_treatment: 'Налоговый режим', + tax_cost_source_incoming: 'Входящий счёт', + tax_cost_source_expense: 'Расход', + tax_cost_total: 'Итого расходы', + tax_summary_income: 'Доходы', + tax_summary_costs: 'Расходы', + tax_summary_result: 'Результат', + tax_summary_vat_payable: 'НДС к уплате (исходящий − входящий)', }, }; diff --git a/backend/src/services/quoteService.js b/backend/src/services/quoteService.js index 514864fa..8c83686e 100644 --- a/backend/src/services/quoteService.js +++ b/backend/src/services/quoteService.js @@ -558,6 +558,11 @@ async function createQuote(payload, adminId) { if (payload.projectId !== undefined && await hasColumnCached('quotes', 'project_id')) { row.project_id = payload.projectId || null; } + // Migration 130 — snapshot the chosen output VAT code (immutable; the export + // emits exactly this rather than re-deriving from the mutable rate→code map). + if (payload.vatCode !== undefined && await hasColumnCached('quotes', 'vat_code')) { + row.vat_code = payload.vatCode ? String(payload.vatCode).slice(0, 16) : null; + } const inserted = await trx('quotes').insert(row).returning('id'); const quoteId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0]; @@ -682,6 +687,10 @@ async function updateQuote(id, payload, adminId) { if (Object.prototype.hasOwnProperty.call(payload, 'projectId') && await hasColumnCached('quotes', 'project_id')) { updates.project_id = payload.projectId || null; } + // Migration 130 — VAT code snapshot. + if (Object.prototype.hasOwnProperty.call(payload, 'vatCode') && await hasColumnCached('quotes', 'vat_code')) { + updates.vat_code = payload.vatCode ? String(payload.vatCode).slice(0, 16) : null; + } await trx('quotes').where({ id }).update(updates); // When linked to a project, cascade across the deal lineage so the linked @@ -1553,6 +1562,8 @@ async function duplicateQuote(id, adminId) { expectedDurationHours: quote.expected_duration_hours, paymentTermTemplateId: quote.payment_term_template_id, vatRate: quote.vat_rate, + // Migration 130 — VAT-code snapshot (so re-editing preserves it). + vatCode: quote.vat_code ?? null, shippingAmountMinor: quote.shipping_amount_minor, introText: quote.intro_text, outroText: quote.outro_text, diff --git a/backend/src/services/rasterizeService.js b/backend/src/services/rasterizeService.js new file mode 100644 index 00000000..a7c8f3cb --- /dev/null +++ b/backend/src/services/rasterizeService.js @@ -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//page-.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 }; diff --git a/backend/src/services/taxReportService.js b/backend/src/services/taxReportService.js index 7fd33efe..60f2fadb 100644 --- a/backend/src/services/taxReportService.js +++ b/backend/src/services/taxReportService.js @@ -49,6 +49,8 @@ const REPORTABLE_STATUSES = ['sent', 'paid', 'overdue', 'pending_delivery', 'can // D.2 — `ensureInt` consolidated into utils/numericHelpers. const { ensureInt } = require('../utils/numericHelpers'); +const { neutralizeSpreadsheetFormula } = require('../utils/spreadsheetSafe'); +const logger = require('../utils/logger'); function ensureRate(v) { if (v === null || v === undefined || v === '') return 0; @@ -157,6 +159,198 @@ async function loadSkontoMap(invoiceIds) { return map; } +/** + * Cost side of the Milchbüchlein view (Einnahmen-Ausgaben-Rechnung). + * + * Aggregates the two cost entities the Accounting feature tracks, both + * keyed on an accrual date inside [from, to] and scoped to `cur`: + * + * 1. incoming invoices (`inbound_documents`) — external supplier + * payables. Accrual date = invoice_date, falling back to created_at. + * Excludes declined + duplicate rows (not real costs). + * 2. expenses (`expenses`) — internal own-costs (mileage / per-diem / + * amount). Accrual date = created_at (no separate invoice date on + * internal expenses). Excludes declined status + duplikat/abgelehnt + * disposition. + * + * Both book to an event OR the company (event_id NULL = company); the + * report surfaces every cost regardless so the total is the full + * outflow for the period. Re-billed costs intentionally stay IN — the + * matching re-bill revenue is already counted on the income side, so + * keeping both sides nets correctly (a pure pass-through cancels out). + * + * Currency: the report is single-currency. Incoming invoices match on + * their own `currency`. Internal expenses are stored in CHF base + * (chf_amount_minor) plus an optional original-currency amount — for a + * CHF report we use the CHF base; for a foreign-currency report we match + * the expense's original_currency and use original_amount_minor. + * + * Tables are schema-guarded: a DB without the accounting migrations + * yields an empty cost side rather than throwing. + * + * Returns { rows, totalNet, totalVat, totalGross } in minor units. + */ +function normMinor(v) { return ensureInt(v); } + +async function loadCosts({ from, to, cur }) { + const rows = []; + let totalNet = 0; + let totalVat = 0; + let totalGross = 0; + // Input VAT is only reclaimable for domestic-style treatments; foreign + // non-reclaimable VAT is a cost, not a deduction. Feeds the report's + // VAT-payable (output − reclaimable input). + let reclaimableVat = 0; + // Inclusive upper bound covering the whole `to` day. Plain range comparison + // (no SQL date() function) so it's valid on both Postgres and SQLite — the + // mocked unit tests can't catch a PG-only function error. invoice_date is a + // DATE, created_at a TIMESTAMP; both compare correctly against ISO literals. + const toEnd = `${to} 23:59:59.999`; + + const push = (r) => { + rows.push(r); + totalNet += r.netMinor; + totalVat += r.vatMinor; + totalGross += r.totalMinor; + if (r.taxTreatment !== 'foreign_vat_non_reclaimable') reclaimableVat += r.vatMinor; + }; + + // 1) Incoming invoices (external supplier payables). + if (await db.schema.hasTable('inbound_documents')) { + const inbound = await db('inbound_documents') + .leftJoin('events', 'inbound_documents.event_id', 'events.id') + // Date in range: invoice_date (a DATE) when set, else created_at (a + // TIMESTAMP). Split instead of COALESCE so we never compare mixed + // date/timestamp types (a Postgres error the mocked tests can't see). + .where((qb) => { + qb.whereBetween('inbound_documents.invoice_date', [from, to]) + .orWhere((q2) => q2.whereNull('inbound_documents.invoice_date') + .andWhere('inbound_documents.created_at', '>=', from) + .andWhere('inbound_documents.created_at', '<=', toEnd)); + }) + // Currency match, but INCLUDE rows with no currency set — captured + // invoices (email/upload) often have a null currency; treat them as the + // report currency rather than silently dropping them from the cost side. + .where((qb) => { qb.where('inbound_documents.currency', cur).orWhereNull('inbound_documents.currency'); }) + .whereNotIn('inbound_documents.status', ['declined', 'duplicate']) + .orderBy('inbound_documents.created_at', 'asc') + .select( + 'inbound_documents.id', + 'inbound_documents.invoice_date', + 'inbound_documents.created_at', + 'inbound_documents.supplier_name', + // inbound_documents has no free-text `description` column (that lives on + // `expenses`); use the supplier invoice number as the row descriptor so + // the cost side aligns with the expense rows without a phantom column. + 'inbound_documents.invoice_number', + 'inbound_documents.disposition', + 'inbound_documents.tax_treatment', + 'inbound_documents.status', + 'inbound_documents.event_id', + 'inbound_documents.net_amount_minor', + 'inbound_documents.vat_amount_minor', + 'inbound_documents.total_amount_minor', + 'events.event_name as event_name', + ); + for (const r of inbound) { + const vat = normMinor(r.vat_amount_minor); + let total = normMinor(r.total_amount_minor); + let net = normMinor(r.net_amount_minor); + if (!total && (net || vat)) total = net + vat; + if (!net && total) net = total - vat; + push({ + id: r.id, + source: 'incoming', + date: r.invoice_date || r.created_at, + supplierLabel: (r.supplier_name && String(r.supplier_name).trim()) || '', + description: r.invoice_number || '', + eventName: r.event_id ? (r.event_name || '') : '', + disposition: r.disposition || '', + taxTreatment: r.tax_treatment || 'domestic', + status: r.status || '', + netMinor: net, + vatMinor: vat, + totalMinor: total, + }); + } + } + + // 2) Internal expenses (own-costs). + if (await db.schema.hasTable('expenses')) { + const isChf = cur === 'CHF'; + const q = db('expenses') + .leftJoin('events', 'expenses.event_id', 'events.id') + .whereRaw('expenses.created_at >= ? AND expenses.created_at <= ?', [from, toEnd]) + .whereNot('expenses.status', 'declined') + .whereNotIn('expenses.disposition', ['duplikat', 'abgelehnt']); + // CHF report includes every expense (all carry a CHF base). A + // foreign-currency report matches the expense's original currency. + if (!isChf) q.where('expenses.original_currency', cur); + const expenses = await q + .orderBy('expenses.created_at', 'asc') + .select( + 'expenses.id', + 'expenses.created_at', + 'expenses.supplier_name', + 'expenses.description', + 'expenses.disposition', + 'expenses.tax_treatment', + 'expenses.status', + 'expenses.event_id', + 'expenses.original_currency', + 'expenses.original_amount_minor', + 'expenses.chf_amount_minor', + 'expenses.net_amount_minor', + 'expenses.vat_amount_minor', + 'expenses.gross_amount_minor', + 'events.event_name as event_name', + ); + for (const r of expenses) { + const vat = normMinor(r.vat_amount_minor); + let net = normMinor(r.net_amount_minor); + let total = normMinor(r.gross_amount_minor); + // Fallback to the single stored amount when net/vat/gross are not + // broken out (internal mileage/per-diem expenses carry only a base + // amount, no VAT split). + const base = isChf ? normMinor(r.chf_amount_minor) : normMinor(r.original_amount_minor); + if (!total) total = (net || vat) ? net + vat : base; + if (!net) net = total - vat; + push({ + id: r.id, + source: 'expense', + date: r.created_at, + supplierLabel: (r.supplier_name && String(r.supplier_name).trim()) || '', + description: r.description || '', + eventName: r.event_id ? (r.event_name || '') : '', + disposition: r.disposition || '', + taxTreatment: r.tax_treatment || 'domestic', + status: r.status || '', + netMinor: net, + vatMinor: vat, + totalMinor: total, + }); + } + } + + // Stable chronological order across both sources. + rows.sort((a, b) => String(a.date || '').localeCompare(String(b.date || ''))); + + return { rows, totalNet, totalVat, totalGross, reclaimableVat }; +} + +// Is the business VAT-registered? (Settings → Accounting). Returns null when the +// setting was never set, so the caller can fall back to a behaviour-preserving +// heuristic (charged output VAT this period ⇒ treat as registered). +async function getVatRegisteredSetting() { + try { + const v = await getAppSetting('accounting_vat_registered'); + if (v === undefined || v === null) return null; + return v === true || v === 1 || v === '1' || v === 'true'; + } catch (_) { + return null; + } +} + /** * The main entry point. * @@ -166,8 +360,13 @@ async function loadSkontoMap(invoiceIds) { * required and must match `invoices.currency` exactly — mixing * currencies in one report is unsound for tax filing, so the API * forces a single-currency view. + * + * `includeCosts` (default true) adds the Einnahmen-Ausgaben cost side + * (incoming invoices + expenses) plus a `summary` block (income vs cost + * vs result, and VAT payable = output VAT − input VAT). Pass false to + * get the legacy revenue-only shape. */ -async function getTaxReport({ from, to, currency } = {}) { +async function getTaxReport({ from, to, currency, includeCosts = true } = {}) { if (!from || !to) { throw new Error('getTaxReport: `from` and `to` are required (YYYY-MM-DD)'); } @@ -283,6 +482,104 @@ async function getTaxReport({ from, to, currency } = {}) { const totalsByVatRate = Array.from(byRate.values()).sort((a, b) => a.vatRate - b.vatRate); + // Cost side (Einnahmen-Ausgaben). Optional so legacy callers that + // only want the revenue listing can opt out. The cost side is + // SUPPLEMENTARY — if it fails (e.g. an accounting table/column missing + // on an older install) it must NOT take down the core revenue report. + // Degrade to empty costs + log the real error for diagnosis. + let costs = { rows: [], totalNet: 0, totalVat: 0, totalGross: 0, reclaimableVat: 0 }; + let costsError = null; + if (includeCosts) { + try { + costs = await loadCosts({ from, to, cur }); + } catch (err) { + costsError = err.message; + logger.error?.(`taxReport: cost side failed (revenue still returned): ${err.message}`); + } + } + + // VAT-payable honours the accounting settings: when NOT VAT-registered the + // business doesn't file VAT (payable = 0); when registered it's output VAT + // minus the RECLAIMABLE input VAT only (foreign non-reclaimable cost VAT is + // not deducted). Guideline figure — verify with your Treuhänder. + // PR #622 concern 4: when VAT registration is UNSET we must NOT guess from + // `grandTotalVat > 0` — a quarter with all-exempt cross-border sales has zero + // output VAT and would silently flip to "not registered", hiding the reclaim. + // Treat null as "not configured": refuse to compute a payable, surface a + // warning in the UI instead. + const vatRegisteredSetting = await getVatRegisteredSetting(); + const vatRegistrationConfigured = vatRegisteredSetting !== null; + const vatRegistered = vatRegisteredSetting === true; + const reclaimableInputVat = costs.reclaimableVat != null ? costs.reclaimableVat : costs.totalVat; + + // Summary: income vs cost vs result. Result = a simplified + // Einnahmen-Ausgaben surplus (net basis). + const summary = { + incomeNetMinor: grandTotalNet, + incomeVatMinor: grandTotalVat, + incomeGrossMinor: grandTotal, + costNetMinor: costs.totalNet, + costVatMinor: costs.totalVat, + costGrossMinor: costs.totalGross, + resultNetMinor: grandTotalNet - costs.totalNet, + resultGrossMinor: grandTotal - costs.totalGross, + vatRegistered, + vatRegistrationConfigured, + // null (not 0) when registration is unconfigured — the UI renders "—" + a + // "configure VAT registration" warning rather than a misleading number. + vatPayableMinor: !vatRegistrationConfigured + ? null + : (vatRegistered ? (grandTotalVat - reclaimableInputVat) : 0), + }; + + // Unified ledger (#5 — one typed, signed, sortable list). Outgoing + // invoices carry POSITIVE amounts; incoming invoices + expenses are + // NEGATIVE so sorting by value runs income → costs and the column + // nets toward the Result. The legacy `rows` / `costs` shapes are + // kept above for back-compat; this is the new canonical surface for + // the on-screen table + PDF/CSV exports. + const ledger = [ + ...rows.map((r) => ({ + key: `out-${r.id}`, + type: 'outgoing', + date: r.issueDate, + reference: r.invoiceNumber, + party: r.customerLabel || '', + eventName: r.eventName || '', + vatRate: r.vatRate, + taxTreatment: null, + status: r.status, + isCancelled: r.isCancelled, + isReissue: r.isReissue, + kind: r.kind, + skontoApplied: r.skontoApplied, + skontoAmountMinor: r.skontoAmountMinor, + netMinor: r.netMinor, + vatMinor: r.vatMinor, + totalMinor: r.totalMinor, + })), + ...costs.rows.map((c) => ({ + key: `${c.source}-${c.id}`, + type: c.source === 'incoming' ? 'incoming' : 'expense', + date: c.date, + reference: c.description || '', + party: c.supplierLabel || '', + eventName: c.eventName || '', + vatRate: null, + taxTreatment: c.taxTreatment || 'domestic', + status: c.status, + isCancelled: false, + isReissue: false, + kind: null, + skontoApplied: false, + skontoAmountMinor: 0, + netMinor: -Math.abs(c.netMinor), + vatMinor: -Math.abs(c.vatMinor), + totalMinor: -Math.abs(c.totalMinor), + })), + ]; + ledger.sort((a, b) => String(a.date || '').localeCompare(String(b.date || ''))); + return { rows, totalsByVatRate, @@ -290,6 +587,10 @@ async function getTaxReport({ from, to, currency } = {}) { grandTotalVat, grandTotal, cancelledCount, + costs, + costsError, + summary, + ledger, currency: cur, period: { from, to }, }; @@ -351,21 +652,19 @@ async function loadRenderContext(locale) { // uncluttered with just "R-2026-0001" — easier to scan for an // auditor looking at the sequence. const TAX_TABLE_COLS = [ - { key: 'idx', labelKey: 'tax_col_no', width: 26, align: 'right' }, - { key: 'date', labelKey: 'tax_col_date', width: 60, align: 'left' }, - { key: 'invoice', labelKey: 'tax_col_invoice', width: 100, align: 'left' }, - { key: 'customer', labelKey: 'tax_col_customer', width: 132, align: 'left' }, - { key: 'event', labelKey: 'tax_col_event', width: 95, align: 'left' }, - { key: 'vatRate', labelKey: 'tax_col_vat_rate', width: 42, align: 'right' }, - { key: 'net', labelKey: 'tax_col_net', width: 70, align: 'right' }, - { key: 'vat', labelKey: 'tax_col_vat', width: 60, align: 'right' }, - { key: 'total', labelKey: 'tax_col_total', width: 80, align: 'right' }, + { key: 'idx', labelKey: 'tax_col_no', width: 22, align: 'right' }, + { key: 'type', labelKey: 'tax_col_type', width: 58, align: 'left' }, + { key: 'date', labelKey: 'tax_col_date', width: 56, align: 'left' }, + { key: 'reference', labelKey: 'tax_col_reference', width: 88, align: 'left' }, + { key: 'party', labelKey: 'tax_col_party', width: 116, align: 'left' }, + { key: 'event', labelKey: 'tax_col_event', width: 86, align: 'left' }, + { key: 'tax', labelKey: 'tax_col_tax', width: 64, align: 'left' }, + { key: 'net', labelKey: 'tax_col_net', width: 70, align: 'right' }, + { key: 'vat', labelKey: 'tax_col_vat', width: 58, align: 'right' }, + { key: 'total', labelKey: 'tax_col_total', width: 80, align: 'right' }, // Skonto column (migration 126) — blank for non-Skonto rows so the - // column reads quietly until it has data. Shrunk neighbouring text - // columns slightly to make space without going over the landscape - // content width. - { key: 'skonto', labelKey: 'tax_col_skonto', width: 56, align: 'right' }, - { key: 'status', labelKey: 'tax_col_status', width: 58, align: 'left' }, + // column reads quietly until it has data. + { key: 'skonto', labelKey: 'tax_col_skonto', width: 50, align: 'right' }, ]; function colX(leftMargin, index) { @@ -400,22 +699,31 @@ function formatVatRate(rate, locale) { return `${formatted} %`; } -function rowCellValues(row, idx, locale, dateFormat) { +function rowCellValues(row, idx, locale, dateFormat, currency) { const intlLocale = locale === 'de' ? 'de-CH' : 'en-GB'; + const typeLabel = t( + locale, + row.type === 'outgoing' ? 'tax_type_outgoing' + : row.type === 'incoming' ? 'tax_type_incoming' + : 'tax_type_expense', + ); + const reference = row.isCancelled + ? `${row.reference || ''} (${t(locale, 'tax_status_cancelled')})` + : (row.reference || ''); return { idx: String(idx), - date: formatDate(row.issueDate, dateFormat), - invoice: row.invoiceNumber, // no inline "(Cancelled)" — keep the column tidy; status is its own column - customer: row.customerLabel || '', + type: typeLabel, + date: formatDate(row.date, dateFormat), + reference, + party: row.party || '', event: row.eventName || '', - vatRate: formatVatRate(row.vatRate, locale), - net: formatMinor(row.netMinor, row.currency, intlLocale), - vat: formatMinor(row.vatMinor, row.currency, intlLocale), - total: formatMinor(row.totalMinor, row.currency, intlLocale), + tax: row.type === 'outgoing' ? formatVatRate(row.vatRate, locale) : (row.taxTreatment || ''), + net: formatMinor(row.netMinor, currency, intlLocale), + vat: formatMinor(row.vatMinor, currency, intlLocale), + total: formatMinor(row.totalMinor, currency, intlLocale), skonto: row.skontoApplied - ? formatMinor(row.skontoAmountMinor, row.currency, intlLocale) + ? formatMinor(row.skontoAmountMinor, currency, intlLocale) : '', - status: row.isCancelled ? t(locale, 'tax_status_cancelled') : '', }; } @@ -427,8 +735,29 @@ function rowCellValues(row, idx, locale, dateFormat) { * Currency is required and used to scope the data (same contract as * getTaxReport). Locale defaults to the business profile's default. */ -async function renderTaxReportPdf({ from, to, currency, locale } = {}) { +// Export scope (PR/Liechtenstein follow-up): the readable PDF/CSV exports can be +// limited to the income or the cost side — handy when the Treuhänder only needs +// one basis (e.g. income for the 20%-Gewinnungskosten flat deduction). The +// on-screen report is unaffected; only the exports filter. +const TAX_EXPORT_SCOPES = ['all', 'income', 'cost']; +function normalizeScope(scope) { + return TAX_EXPORT_SCOPES.includes(scope) ? scope : 'all'; +} +function scopeLedger(ledger, scope) { + if (scope === 'income') return (ledger || []).filter((r) => r.type === 'outgoing'); + if (scope === 'cost') return (ledger || []).filter((r) => r.type === 'incoming' || r.type === 'expense'); + return ledger || []; +} + +async function renderTaxReportPdf({ from, to, currency, locale, scope } = {}) { const report = await getTaxReport({ from, to, currency }); + const xScope = normalizeScope(scope); + report.ledger = scopeLedger(report.ledger, xScope); + // The per-rate breakdown is income-only — drop it from a cost-only export. + if (xScope === 'cost') report.totalsByVatRate = []; + const showIncome = xScope !== 'cost'; + const showCosts = xScope !== 'income'; + const showResult = xScope === 'all'; const renderCtx = await loadRenderContext(locale); const useLocale = renderCtx.locale; const intlLocale = useLocale === 'de' ? 'de-CH' : 'en-GB'; @@ -484,7 +813,7 @@ async function renderTaxReportPdf({ from, to, currency, locale } = {}) { const tableBottomLimit = page.height - page.marginBottom - 110; // leave room for totals const tableWidth = TAX_TABLE_COLS.reduce((s, c) => s + c.width, 0); - if (report.rows.length === 0) { + if (report.ledger.length === 0) { doc.font(fonts.body).fontSize(10).fillColor('#555') .text(t(useLocale, 'tax_no_invoices'), leftMargin, y + 6, { width: tableWidth, align: 'center', @@ -507,7 +836,7 @@ async function renderTaxReportPdf({ from, to, currency, locale } = {}) { // wrap (they're either ints or money strings whose width we // budget for) — only text cells (customer, event, invoice, // status) opt into natural wrapping. - const isWrappable = (col) => ['invoice', 'customer', 'event', 'status'].includes(col.key); + const isWrappable = (col) => ['type', 'reference', 'party', 'event', 'tax'].includes(col.key); const measureCellHeight = (value, col) => { const s = safeStr(value); if (!s) return 0; @@ -520,9 +849,9 @@ async function renderTaxReportPdf({ from, to, currency, locale } = {}) { return doc.heightOfString(s, opts); }; - for (let i = 0; i < report.rows.length; i += 1) { - const row = report.rows[i]; - const cells = rowCellValues(row, i + 1, useLocale, renderCtx.dateFormat); + for (let i = 0; i < report.ledger.length; i += 1) { + const row = report.ledger[i]; + const cells = rowCellValues(row, i + 1, useLocale, renderCtx.dateFormat, report.currency); // Set the font BEFORE measuring so heightOfString reads the // exact rendering state we'll use for doc.text below. @@ -585,7 +914,10 @@ async function renderTaxReportPdf({ from, to, currency, locale } = {}) { // otherwise PDFKit auto-paginates mid-totals, creating phantom // pages whose footer ends up at unexpected Y positions on the // subsequent bufferedPageRange loop. - const totalsHeightEstimate = 16 + (report.totalsByVatRate.length * 13) + 8 + 39 + 12; + // Header (16) + one line per VAT bucket (13) + divider (8) + + // three income/costs/result summary rows (39) + a 12pt cushion. + const summaryHeight = 8 + (3 * 13); + const totalsHeightEstimate = 16 + (report.totalsByVatRate.length * 13) + 12 + summaryHeight; const footerReserve = 24; // 12 above + 12 of page-number text room if (y + 12 + totalsHeightEstimate + footerReserve > page.height - page.marginBottom) { doc.addPage({ @@ -619,22 +951,25 @@ async function renderTaxReportPdf({ from, to, currency, locale } = {}) { totalsX + 270, ty, { width: 90, align: 'right' }); ty += 13; } - // Divider above grand totals. + // Divider above the income / costs / result summary. doc.moveTo(totalsX, ty + 2).lineTo(totalsX + totalsBoxWidth, ty + 2) .lineWidth(0.6).strokeColor('#000').stroke(); ty += 6; - doc.font(fonts.bold); - doc.text(t(useLocale, 'tax_grand_total_net'), totalsX, ty, { width: 170, align: 'left' }); - doc.text(formatMinor(report.grandTotalNet, report.currency, intlLocale), - totalsX + 175, ty, { width: 90, align: 'right' }); - ty += 13; - doc.text(t(useLocale, 'tax_grand_total_vat'), totalsX, ty, { width: 170, align: 'left' }); - doc.text(formatMinor(report.grandTotalVat, report.currency, intlLocale), - totalsX + 175, ty, { width: 90, align: 'right' }); - ty += 13; - doc.text(t(useLocale, 'tax_grand_total_gross'), totalsX, ty, { width: 170, align: 'left' }); - doc.text(formatMinor(report.grandTotal, report.currency, intlLocale), - totalsX + 270, ty, { width: 90, align: 'right' }); + // Income / Costs / Result summary (mirrors the on-screen summary + // box). Costs are shown NEGATIVE so the Result reads as a plain + // sum of the column. Net / VAT / Gross across the three lines. + const s = report.summary; + const summaryLine = (labelKey, netMinor, vatMinor, grossMinor, bold) => { + doc.font(bold ? fonts.bold : fonts.body).fontSize(9).fillColor('#000'); + doc.text(t(useLocale, labelKey), totalsX, ty, { width: 80, align: 'left' }); + doc.text(formatMinor(netMinor, report.currency, intlLocale), totalsX + 80, ty, { width: 90, align: 'right' }); + doc.text(formatMinor(vatMinor, report.currency, intlLocale), totalsX + 175, ty, { width: 90, align: 'right' }); + doc.text(formatMinor(grossMinor, report.currency, intlLocale), totalsX + 270, ty, { width: 90, align: 'right' }); + ty += 13; + }; + if (showIncome) summaryLine('tax_summary_income', s.incomeNetMinor, s.incomeVatMinor, s.incomeGrossMinor, !showResult); + if (showCosts) summaryLine('tax_summary_costs', -Math.abs(s.costNetMinor), -Math.abs(s.costVatMinor), -Math.abs(s.costGrossMinor), !showResult); + if (showResult) summaryLine('tax_summary_result', s.resultNetMinor, s.vatPayableMinor, s.resultGrossMinor, true); // Cancelled footnote (bottom-left). Only when there are any. if (report.cancelledCount > 0) { @@ -688,66 +1023,96 @@ async function renderTaxReportPdf({ from, to, currency, locale } = {}) { * renderTaxReportCsv({ from, to, currency, locale }) * → Promise<{ content, filename, contentType }> */ -async function renderTaxReportCsv({ from, to, currency, locale } = {}) { +async function renderTaxReportCsv({ from, to, currency, locale, scope } = {}) { const report = await getTaxReport({ from, to, currency }); + const xScope = normalizeScope(scope); + report.ledger = scopeLedger(report.ledger, xScope); const useLocale = locale || 'en'; - const headers = [ - t(useLocale, 'tax_col_no'), - t(useLocale, 'tax_col_date'), - t(useLocale, 'tax_col_invoice'), - t(useLocale, 'tax_col_customer'), - t(useLocale, 'tax_col_event'), - t(useLocale, 'tax_col_vat_rate'), - `${t(useLocale, 'tax_col_net')} (${report.currency})`, - `${t(useLocale, 'tax_col_vat')} (${report.currency})`, - `${t(useLocale, 'tax_col_total')} (${report.currency})`, - t(useLocale, 'tax_status_cancelled'), - // Migration 126 — Skonto export. `tax_col_skonto` is the discount - // amount in major units; admin's accountant reconciles the line. - `${t(useLocale, 'tax_col_skonto')} (${report.currency})`, - ]; - const escape = (cell) => { - const s = cell === null || cell === undefined ? '' : String(cell); - // RFC 4180: wrap in quotes when the value contains comma, quote, - // or newline. We always wrap, simpler + bulletproof for Excel. + // Formula-injection defence (Excel/Numbers) THEN RFC-4180 quote-wrap. The + // quote wrap alone does NOT stop formula evaluation — only the leading + // single-quote prefix does. + const s = neutralizeSpreadsheetFormula(cell === null || cell === undefined ? '' : String(cell)); return `"${s.replace(/"/g, '""')}"`; }; const minorToDotDecimal = (m) => ((Number(m) || 0) / 100).toFixed(2); + // yyyy-mm-dd, robust to Postgres returning dates as JS Date objects (SQLite + // returns strings) — raw String(dateObj) is "Thu Jan 15", not an ISO date. + const isoDate = (d) => { + if (!d) return ''; + if (d instanceof Date) { + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + } + return String(d).slice(0, 10); + }; + + const typeLabelKey = (type) => ( + type === 'outgoing' ? 'tax_type_outgoing' + : type === 'incoming' ? 'tax_type_incoming' + : 'tax_type_expense' + ); + + // ONE unified ledger table. Amounts are already signed in the ledger + // (outgoing positive, costs negative) — emitted as-is. + const headers = [ + t(useLocale, 'tax_col_no'), + t(useLocale, 'tax_col_type'), + t(useLocale, 'tax_col_date'), + t(useLocale, 'tax_col_reference'), + t(useLocale, 'tax_col_party'), + t(useLocale, 'tax_col_event'), + t(useLocale, 'tax_col_tax'), + `${t(useLocale, 'tax_col_net')} (${report.currency})`, + `${t(useLocale, 'tax_col_vat')} (${report.currency})`, + `${t(useLocale, 'tax_col_total')} (${report.currency})`, + // Migration 126 — Skonto export. `tax_col_skonto` is the discount + // amount in major units; admin's accountant reconciles the line. + `${t(useLocale, 'tax_col_skonto')} (${report.currency})`, + ]; + const lines = [headers.map(escape).join(',')]; - report.rows.forEach((row, i) => { + report.ledger.forEach((row, i) => { + const reference = row.isCancelled + ? `${row.reference || ''} (${t(useLocale, 'tax_status_cancelled')})` + : (row.reference || ''); + const tax = row.type === 'outgoing' + ? Number(row.vatRate).toFixed(2) + : (row.taxTreatment || ''); lines.push([ i + 1, - row.issueDate, - row.invoiceNumber, - row.customerLabel, + t(useLocale, typeLabelKey(row.type)), + isoDate(row.date), + reference, + row.party, row.eventName, - Number(row.vatRate).toFixed(2), + tax, minorToDotDecimal(row.netMinor), minorToDotDecimal(row.vatMinor), minorToDotDecimal(row.totalMinor), - row.isCancelled ? '1' : '0', row.skontoApplied ? minorToDotDecimal(row.skontoAmountMinor) : '', ].map(escape).join(',')); }); - // Trailing totals row: blank cells + grand totals at the end so - // the column alignment matches the data rows when opened in Excel. - lines.push(''); - lines.push([ - '', '', '', - t(useLocale, 'tax_grand_total_gross'), - '', '', - minorToDotDecimal(report.grandTotalNet), - minorToDotDecimal(report.grandTotalVat), - minorToDotDecimal(report.grandTotal), - '', '', - ].map(escape).join(',')); + + // Trailing blank line, then the income / costs / result summary block. + const summary = report.summary; + if (summary) { + lines.push(''); + lines.push(escape(t(useLocale, 'tax_summary_section'))); + const sline = (labelKey, net, vat, gross) => lines.push([ + '', '', '', t(useLocale, labelKey), '', '', + minorToDotDecimal(net), minorToDotDecimal(vat), minorToDotDecimal(gross), + ].map(escape).join(',')); + if (xScope !== 'cost') sline('tax_summary_income', summary.incomeNetMinor, summary.incomeVatMinor, summary.incomeGrossMinor); + if (xScope !== 'income') sline('tax_summary_costs', summary.costNetMinor, summary.costVatMinor, summary.costGrossMinor); + if (xScope === 'all') sline('tax_summary_result', summary.resultNetMinor, summary.vatPayableMinor, summary.resultGrossMinor); + } const content = lines.join('\r\n') + '\r\n'; - const filename = `tax_report_${report.period.from}_to_${report.period.to}_${report.currency}.csv`; + const scopeTag = xScope === 'all' ? '' : `${xScope}_`; + const filename = `tax_report_${scopeTag}${report.period.from}_to_${report.period.to}_${report.currency}.csv`; return { content, filename, contentType: 'text/csv; charset=utf-8' }; } @@ -756,5 +1121,5 @@ module.exports = { renderTaxReportPdf, renderTaxReportCsv, // Exposed for unit tests. - _internal: { grossUpLateFee, computeReportedAmounts, buildCustomerLabel, formatVatRate }, + _internal: { grossUpLateFee, computeReportedAmounts, buildCustomerLabel, formatVatRate, loadCosts, scopeLedger, normalizeScope }, }; diff --git a/backend/src/utils/appSettings.js b/backend/src/utils/appSettings.js index 354eaa05..29a989d6 100644 --- a/backend/src/utils/appSettings.js +++ b/backend/src/utils/appSettings.js @@ -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 }; diff --git a/backend/src/utils/dbErrors.js b/backend/src/utils/dbErrors.js new file mode 100644 index 00000000..d4424598 --- /dev/null +++ b/backend/src/utils/dbErrors.js @@ -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 }; diff --git a/backend/src/utils/spreadsheetSafe.js b/backend/src/utils/spreadsheetSafe.js new file mode 100644 index 00000000..b8f7fcb5 --- /dev/null +++ b/backend/src/utils/spreadsheetSafe.js @@ -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 }; diff --git a/backend/storage/business-docs/quote/2026/Q-2026-0001.pdf b/backend/storage/business-docs/quote/2026/Q-2026-0001.pdf deleted file mode 100644 index 6089659d..00000000 --- a/backend/storage/business-docs/quote/2026/Q-2026-0001.pdf +++ /dev/null @@ -1 +0,0 @@ -pdf \ No newline at end of file diff --git a/docs/accounting-inbound-invoices.md b/docs/accounting-inbound-invoices.md new file mode 100644 index 00000000..8a1625c8 --- /dev/null +++ b/docs/accounting-inbound-invoices.md @@ -0,0 +1,65 @@ +# Accounting — Inbound supplier invoices, expenses & re-bill (MVP) + +> **Status:** new feature, in development on `feat/accounting-inbound-invoices` (based on `upstream/beta`). +> **Maintainer scope decision required** before merge — this introduces a new top-level **Accounting** area, separate from CRM (see "Scope decisions" below). +> **Legal:** every VAT / tax-treatment surface is an *example only* and must be reviewed with a Treuhänder before relying on it. Jurisdiction scope is **Liechtenstein-first** (Swiss/LI rails — QR-bill, LI MWST), not German DATEV/ELSTER. + +## Why +The studio receives supplier invoices/receipts (hotels, equipment, fremdleistungen). Today they live in email/paper and are re-typed. This feature lets an admin **capture an incoming invoice** (upload, or **phone/tablet camera**), have its fields **best-effort extracted**, then give it a **disposition** — most importantly **re-bill it to a client** ("Weiterverrechnung") onto the relevant event's invoice with a contract-driven markup. + +This mirrors the existing **billable-hours** model (`customerHoursService`): an item is parked against a customer/event and folded into an invoice as a line item. + +## Scope decisions (maintainer) +1. **New top-level "Accounting" area**, gated behind a new `accounting` feature flag (default OFF) and `accounting.view` / `accounting.manage` permissions — *not* bolted onto CRM. The existing tax-export page is a candidate to move here later (not in this MVP). +2. **picpeak owns documents + books up to the export boundary**; certified external systems (Treuhänder / Abacus / Bexio) own statutory filing. +3. **No paperless-ngx sidecar** — picpeak is the system of record; files live under `storage/` and are covered by the existing `backup_paths` walker. + +## MVP scope (this branch) +- **Intake**: file upload **and camera capture** (phone/tablet) → `POST /api/admin/expenses/inbound` (accepts PDF + JPEG/PNG). Stored as the system of record; deduped by SHA-256. +- **Best-effort extraction** (`extractionService`): ladder of Swiss-QR decode → PDF text layer → OCR. *Scaffolded with the interface in place; the heavy extractors (Tesseract OS package, QR decoder, isolated rasterise worker) are a follow-up — see "Deferred".* +- **Inbox**: list documents as **„Neu / Unsortiert"**; parsed fields are editable/confirmable (parsing is assist, never blind trust). The **QR-encoded amount is stored separately** and surfaced for tamper cross-check — the **authoritative total is the text/line-item value**. +- **5 dispositions**: `rebill` (Weiterverrechnen) · `durchlaufend` (Durchlaufender Posten) · `eigener_aufwand` (company expense) · `duplikat` · `abgelehnt` (with reason). +- **Re-bill flow**: event-scoped (one event → one customer). Markup resolved **expense override → contract `Spesen-Zuschlag` clause → 0%** (percent or flat). Mints an editable **scheduled** invoice (admin can add more lines) — same pattern as `billUnbilledEntries`. +- **Supplier-payment status** (decoupled from categorisation): „Zu zahlen / Bezahlt" with `payment_method` (unified with the outgoing list incl. **bank_transfer**). +- **Expense categories**: seeded + admin-editable (colored label) — feed the future Erfolgsrechnung. +- **`tax_treatment` captured from day 1** (`domestic` default) — stored for the books; reclaim/Bezugsteuer math is future (switches on when `business_profile.vat_id` is set). + +## Data model (migrations 122–125) +Numbered from **122** to avoid colliding with the in-flight `feat/crm-improvements` migrations **117–121** (which are expected to merge first). If this lands before that branch, renumber to 117+. + +- **122** — seed `accounting` feature flag (default OFF). +- **123** — seed `accounting.view` / `accounting.manage` permissions + grant to super_admin/admin. +- **124** — `inbound_documents`, `expenses`, `expense_categories` (+ seed categories). +- **125** — `contracts.expense_markup_type|_percent|_flat_minor` (the Spesen-Zuschlag clause). + +Key tables (all money in integer minor units, `*_amount_minor`): +- `inbound_documents` — raw received doc + parsed/confirmable fields + `qr_amount_minor` (separate, untrusted) + `status` (unsorted/categorized/declined/duplicate). +- `expenses` — the booking: `disposition`, `tax_treatment`, `event_id`, `customer_account_id`, FX (`original_*` + `chf_amount_minor` + `fx_locked`), `markup_type/_percent/_flat_minor`, `category_id`, `billed_invoice_id`, supplier-payment fields, `status`. +- `expense_categories` — seeded colored labels. + +## API (`/api/admin/expenses`, gated by `accounting` flag + `accounting.*`) +- `POST /inbound` (multipart) — capture an inbound doc (upload/camera). +- `GET /inbound` — list (filter by status, paginated). +- `GET /inbound/:id` — one doc. +- `PATCH /inbound/:id` — confirm/edit parsed fields. +- `POST /inbound/:id/categorize` — create an expense with a disposition. +- `POST / ` — create a manual expense (no document). +- `GET / ` — list expenses (filter by status/disposition/customer/event). +- `GET /:id` — one expense. +- `PATCH /:id` — edit (locked once billed). +- `POST /:id/rebill` — re-bill to a client (event-scoped, contract markup) → scheduled invoice. +- `POST /:id/supplier-payment` — toggle supplier paid + method. +- `GET/POST/PATCH/DELETE /categories` — manage expense categories. + +## Camera capture (step 3) +The `POST /inbound` endpoint accepts images, so a **mobile web** widget using +`` 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. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 14e85596..95cd90cb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -66,6 +66,9 @@ import { import { CustomerAuthProvider } from './contexts/CustomerAuthContext'; import { AdminLayout, AdminAuthWrapper } from './components/admin'; import { ClientsLayout } from './components/admin/ClientsLayout'; +import { AccountingLayout, AccountingIndex } from './components/admin/AccountingLayout'; +import { AccountingInboxPage } from './pages/admin/accounting/AccountingInboxPage'; +import { ExpensesLedgerPage } from './pages/admin/accounting/ExpensesLedgerPage'; import { RequireFeature } from './components/admin/RequireFeature'; import { PageErrorBoundary, OfflineIndicator, SkipLink, DynamicFavicon, RobotsMetaTags, CMSContentBlock, Loading } from './components/common'; import { MaintenanceWrapper } from './components/MaintenanceWrapper'; @@ -251,10 +254,10 @@ function App() { /> - {/* Tax / Steuer report — gated by `taxReport`. */} - }> - } /> - + {/* Tax export moved permanently to the Accounting + section. Keep this path as a redirect so old + bookmarks / links don't 404. */} + } /> {/* Developer tools — gated by `crmDevelopment`. */} }> } /> @@ -268,6 +271,32 @@ function App() { + {/* 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. */} + }> + }> + }> + } /> + + }> + } /> + + }> + } /> + {/* Treuhänder export moved onto the Tax page; keep + the old path working for bookmarks. */} + } /> + + {/* Chart of accounts (Layer A) moved into Settings → + Accounting; keep the old path working for bookmarks. */} + } /> + } /> + + + {/* Old /admin/customers paths now live under /admin/clients/accounts. Kept indefinitely as redirects so existing bookmarks and email links diff --git a/frontend/src/components/admin/AccountingLayout.tsx b/frontend/src/components/admin/AccountingLayout.tsx new file mode 100644 index 00000000..0761337e --- /dev/null +++ b/frontend/src/components/admin/AccountingLayout.tsx @@ -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 = ( +
+

+ {t('accounting.title', 'Accounting')} +

+

+ {t('accounting.subtitle', 'Inbound supplier invoices, expenses and reporting.')} +

+
+ ); + + if (enabledItems.length === 0) { + return ( +
+ {header} +
+ +

+ {t('accounting.empty.title', 'No accounting features enabled')} +

+

+ {t('accounting.empty.body', 'Enable the Tax report (or another accounting sub-feature) under Settings → Features to get started.')} +

+
+
+ ); + } + + return ( +
+ {header} + +
+ {/* Mobile: native select dropdown */} +
+ + +
+ + {/* Desktop: sticky left rail */} + + +
+ +
+
+
+ ); +}; + +/** + * 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 ; + if (flags.expenses) return ; + if (flags.taxReport) return ; + return null; +}; diff --git a/frontend/src/components/admin/AdminSidebar.tsx b/frontend/src/components/admin/AdminSidebar.tsx index 79aefddf..2dbd26d0 100644 --- a/frontend/src/components/admin/AdminSidebar.tsx +++ b/frontend/src/components/admin/AdminSidebar.tsx @@ -10,6 +10,7 @@ import { X, Users, Briefcase, + Landmark, PanelLeftClose, PanelLeftOpen, } from 'lucide-react'; @@ -91,11 +92,24 @@ const navigation: NavItem[] = [ // can't disagree: any sub-feature on lights up the entry, all off // hides it. Future siblings (e.g. `messaging`) get appended here // AND in the context derivation. + // taxReport intentionally excluded — Tax moved to the Accounting section + // and is not a Clients sub-nav item, so it must not reveal Clients (would + // open an empty ClientsLayout). Mirrors the context's `clients` derivation. featureFlagsAny: [ 'customerPortal', 'crmDevelopment', 'quotes', 'bills', - 'taxReport', 'hoursLogging', 'contracts', 'calendar', + 'hoursLogging', 'contracts', 'calendar', 'projects', ], }, + // Accounting section (migration 122) — inbound supplier invoices, + // expenses + re-bill, and the tax report (which relocates here from + // the CRM sub-nav when `accounting` is on). Gated by the `accounting` + // master flag; the sub-pages inside AccountingLayout are each + // independently feature-gated. + { + nameKey: 'navigation.accounting', href: '/admin/accounting', icon: Landmark, + permission: 'accounting.view', + featureFlag: 'accounting', + }, ]; export const AdminSidebar: React.FC = ({ isOpen, onClose, collapsed = false, onToggleCollapse }) => { diff --git a/frontend/src/components/admin/ChartOfAccountsManager.tsx b/frontend/src/components/admin/ChartOfAccountsManager.tsx new file mode 100644 index 00000000..562ee617 --- /dev/null +++ b/frontend/src/components/admin/ChartOfAccountsManager.tsx @@ -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(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 ( +
+
+
+

{isEdit ? t('ledger.account.editTitle', 'Edit account') : t('ledger.account.addTitle', 'Add account')}

+ +
+
+
setNumber(e.target.value)} placeholder="6700" />
+
setName(e.target.value)} />
+
+ +
+
+
+ + +
+
+
+ ); +}; + +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({}); + 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 = {}; + 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 ; + + return ( +
+

+ + {t('ledger.intro', 'Used only to produce the Treuhänder export — picpeak does not keep double-entry books. The seeded chart + VAT codes follow the Swiss/LI KMU-Kontenrahmen; adjust them to match your Treuhänder’s setup.')} +

+ + {/* Default + system accounts */} + + +

{t('ledger.defaults.title', 'Default & system accounts')}

+
+ {SETTING_ACCOUNT_KEYS.map((key) => ( +
+ + +
+ ))} +
+ +
+ +
+
+
+ + {/* Category → account */} + + +

{t('ledger.categoryMap.title', 'Expense category → account')}

+
+ {(mappings?.categories ?? []).map((c) => ( +
+ {categoryLabel(c as any, t)} + +
+ ))} +
+
+
+ + {/* Chart of accounts */} + + +
+

{t('ledger.accounts.title', 'Chart of accounts')}

+ +
+
+ + + + + + + + + + + {(accounts ?? []).map((a) => ( + + + + + + + ))} + +
{t('ledger.account.number', 'No.')}{t('ledger.account.name', 'Name')}{t('ledger.account.type', 'Type')}{t('common.actions', 'Actions')}
{a.number}{a.name}{t(`ledger.accountType.${a.type}`, a.type)} +
+ + +
+
+
+
+
+ + {accountModal && setAccountModal(null)} onDone={() => { setAccountModal(null); refetchAll(); }} />} +
+ ); +}; + +export default ChartOfAccountsManager; diff --git a/frontend/src/components/admin/ClientsLayout.tsx b/frontend/src/components/admin/ClientsLayout.tsx index 811c350d..f09115cd 100644 --- a/frontend/src/components/admin/ClientsLayout.tsx +++ b/frontend/src/components/admin/ClientsLayout.tsx @@ -88,13 +88,8 @@ export const ClientsLayout: React.FC = () => { icon: Receipt, featureFlag: 'bills', }, - { - key: 'tax-report', - to: '/admin/clients/tax-report', - label: t('clients.subnav.taxReport', 'Tax'), - icon: Calculator, - featureFlag: 'taxReport', - }, + // Tax export moved permanently to the Accounting section (it is no + // longer a CRM sub-feature). See AccountingLayout. // Future sub-features: // { key: 'messaging', ... featureFlag: 'messaging' } { diff --git a/frontend/src/components/admin/CustomerAccountPicker.tsx b/frontend/src/components/admin/CustomerAccountPicker.tsx index 9dea5bce..9d2f710a 100644 --- a/frontend/src/components/admin/CustomerAccountPicker.tsx +++ b/frontend/src/components/admin/CustomerAccountPicker.tsx @@ -120,10 +120,10 @@ export const CustomerAccountPicker: React.FC = ({ value, onChange, disabl return (
-