Add/api and invoice (#911)
* feat(salary): validate employee clearing/kontonummer at entry Bank details on the "Anställda" form had no structural validation, so a typo in clearing/kontonummer was saved silently and only surfaced at Bankgirot LB generation (or never, on the SEPA path). Adds a shared validator (lib/salary/payment/bank-account.ts) wired into the create dialog, edit page, CreateEmployeeSchema, and the PATCH route: 4-digit clearing or 5-digit Swedbank (8xxxx), 5-11 digit account, both-or-neither. Mirrors encodeReceiverAccount so entry-time validation matches what the payout layer can encode. Update validates only when a bank field actually changes, so legacy free-text data stays editable. Includes a conservative clearing to bank-name hint (null for unknown ranges). Per-bank mod10/mod11 checksum deferred to a soft-warning follow-up. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(chart-of-accounts): styled delete warnings and bulk select-all Replace the native window.confirm() on single-account delete with the styled DestructiveConfirmDialog, and add to the prune dialog a master 'select all unused accounts' checkbox plus an explicit confirmation step before bulk deletion. New sv/en strings for the confirm titles and actions. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(salary): encrypt personnummer on v1 employee create; tolerate legacy plaintext on read The v1 REST create route stored personnummer unencrypted, which then threw ERR_CRYPTO_INVALID_AUTH_TAG on every decrypt-on-read path and 500'd the employees roster. Encrypt on write in v1 create, decrypt on read in the v1 list/detail/patch responses, and make decryptPersonnummer pass a raw 12-digit value through with a warn so a legacy plaintext row can't take the roster down. Encrypt seeded personnummer. Add a gated, idempotent backfill for existing rows. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(bookkeeping): save a manual entry as a reusable template Add a "Spara som mall" action to the manual journal-entry form next to the existing "Anvand mall" picker, so users can capture a booking pattern the moment they work it out. Opens the shared TemplateForm (create mode) pre-seeded from the current lines via deriveTemplateLinesFromBooking, and saves through the existing POST /api/settings/booking-templates. Rendered in both the mobile and desktop layouts and in create + edit modes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(pending): label all staged operation types The Granskning list rendered the raw snake_case operation_type (e.g. create_supplier_invoice_from_inbox) for any type missing from the label map, which hogs the meta row and wraps awkwardly on mobile. Add short sv/en labels for all operation types in OPERATION_RISK_TIERS, plus a humanized fallback for future ones, and simplify the label map to a plain operation_type -> i18n-key record (the icon/variant fields were dead). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(reports): let users file moms without a Skatteverket connection The momsdeklaration was never gated on the Skatteverket connection (it renders from the bookkeeping), but the not-connected "Anslut med BankID" card read as a wall. Make manual filing a first-class path: - Add a "Lämna in din momsdeklaration" card under the report with a PDF download (SKV 4700 layout, hela kronor) and a skatteverket.se link. - Add a momsdeklaration PDF route + template; buildManualFilingRows() rounds each ruta to whole kronor and recomputes ruta 49 per the SKV 4700 formula so it ties out. The PDF is a read/record copy, not a submission file (moms has no upload channel). - Offer PDF alongside Excel in the report's export menu. - Reframe the not-connected SkatteverketPanel to "Skicka direkt till Skatteverket (valfritt)". Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(salary): compact new-employee dialog and warn on bad account check digit Redesign NewEmployeeDialog into a compact layout: borderless sections split by hairline dividers (no per-section cards), a fixed header + scrolling body + solid footer (fixes content showing through the old sticky bar), and denser grids. EmployeeTaxCard gains a `flat` variant so the dialog can host it without card chrome; the edit page keeps the boxed version. Add non-blocking Swedish account check-digit validation (lib/bankgiro/account-number.ts): mod10 (reuses luhn) + mod11, with a clearing->method table from the Bankgirot "Bankernas kontonummeruppbyggnad" spec, cross-checked against jop-io/kontonummer.js and verified against a real account (Forex 9420/4172385). Surfaced as a soft warning in both employee forms; unrecognised clearings return 'unknown' so we never warn on a valid but unmapped account. Never blocks saving. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(invoices): configurable send time + editing for recurring invoices Re-register the accidentally-removed recurring cron (now hourly) and add a per-schedule send hour (Europe/Stockholm, DST-aware). The cron never sends for a past date, and the enabling migration pauses every existing schedule on deploy so nothing auto-sends behind a user's back; users reactivate consciously (with a confirm) or click "Skapa faktura nu" to send this month on demand. Automatic sending now requires a customer email. Adds a full edit flow (row click opens the prefilled form, PATCH), fixing the row-click 404. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(invoices): configure självfaktura via the invoice API Add an optional is_self_billed flag (plus external_invoice_number, self_billing_agreement_ref, received_date) to the public invoice-create endpoint so callers can register a received self-billing invoice (mottagen självfaktura, ML 17 kap 15§) via the API. It was previously only reachable from the internal dashboard route, so it was missing from the API docs. Extract the booking into a shared service (lib/invoices/self-billed-sale.ts) and refactor the internal /api/invoices/self-billed route to a thin wrapper over it, so the dashboard and the API cannot drift. Books as a sale (Debit 1510 / Credit 30xx+26xx) with the counterparty's number; no own number is consumed. Fields are plain optionals (no schema refine) so UpdateInvoiceSchema.omit() keeps working; required-when-self-billed is enforced in the route. Documented in the endpoint registry. No migration (columns already exist). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(settings): allow a partial voucher-series-per-source-type map In Zod 4 an enum-keyed z.record is exhaustive (every source_type required), so saving a default_voucher_series_per_source_type map that omits a source type (e.g. the newly added result_appropriation) failed with "expected string, received undefined". Use partialRecord so the map can be sparse; the engine falls back to series 'A' for any unmapped key. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(salary): resolve employer name via getCompanyDisplayName Payslip PDFs, the payslip email, AGI, KU10, and the BG/LB + SEPA payment files now resolve the employer name through getCompanyDisplayName (company_settings.company_name, falling back to companies.name), matching how invoices already display it. Read-side coalesce, so no migration or backfill: companies.name is write-once at onboarding and not authoritative for these surfaces. The sidebar company switcher uses the same coalesce for the non-active companies in the list. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * perf(kontoplan): index-only account usage counts + lighter reference load Add a covering index on journal_entry_lines (journal_entry_id, account_number) so get_account_usage_counts becomes an index-only scan (prod worst case ~440ms). Slim /api/bookkeeping/accounts/reference to return only the company's activation rows and merge against the client-bundled BAS_REFERENCE instead of re-sending the full ~1,300-account catalog every load, and defer the BAS catalog + usage counts off the first-paint critical path in ChartOfAccountsManager. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * i18n(salary): add bank-account checksum warning string sv/en strings for the employee bank-account (clearing/kontonummer) soft checksum warning shown by the create/edit forms. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: update decision log Append the 2026-07-06/07 decision entries (salary employer-name coalesce, sidebar switcher, employees API personnummer fix, kontoplan load optimization, momsdeklaration manual filing, recurring invoices resend + reactivation + editing, "spara som mall", voucher-series partial map, and självfaktura via the invoice API). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: address compliance-review findings on recurring invoices + moms filing - recurring cron: close the double-send window with an atomic compare-and-set claim on last_run_at (release-on-failure) so two overlapping hourly runs can't both spawn from the same stale batch row - recurring edit dialog: force auto_send=false whenever the effective customer has no email, so a disabled-but-checked box can't PATCH auto_send=true after the async customer load - momsdeklaration manual-filing: truncate rutor to whole kronor (öretal faller bort per SFL 22 kap 1 §) instead of round-to-nearest, matching the SRU path Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
7f24ede6c0
commit
3a88b53fd9
+15
-2
@@ -13,13 +13,26 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-07-03] New user-facing strings on skattekonto follow that file's existing hardcoded-Swedish convention; the deadlines callout uses next-intl (page already translated). Year-end stays Swedish per .claude/rules/i18n.md.
|
||||
[2026-07-05] Salary run "Ångra godkännande" transitions approved → review (not straight to draft) and hard-deletes generated-but-unfiled AGI declarations — symmetric with the approve step for a clean audit trail, and stale AGI XML must not stay exportable. Blocked with 409 once the AGI is pending_signature/submitted/accepted: the lawful path is then a correction AGI with the same specifikationsnummer. Payment-file tracking is cleared; whether the file reached the bank is outside app knowledge, so the UI confirm makes the user own that check.
|
||||
[2026-07-05] PR #894 bot triage: accepted the delete-after-update reorder (destructive op last) and the manual-filing warning in confirm_unapprove_agi; declined soft-cancel status for unfiled AGI drafts and preserving approved_by on recall — a never-filed generated AGI is regenerable working data derived entirely from retained run data (not räkenskapsinformation; unapprove 409s once anything is filed), and the approval with legal weight is the one in force at booking, which unapprove can never touch (paid/booked runs are locked out).
|
||||
[2026-07-05] Fixed supplier-invoice VAT silently dropped via MCP inbox conversion: gnubok_create_supplier_invoice_from_inbox now derives vat_amount from summed lineItems instead of the unreconciled OCR totals.vat field, and createSupplierInvoiceRegistrationEntry/CashEntry/PrivatelyPaidEntry gate the 2641 posting on itemsHaveVat(items) instead of invoice.vat_amount > 0. Chose to fix both the immediate source (server.ts) and the downstream gate (supplier-invoice-entries.ts) rather than just one: the header field is inherently a redundant, independently-sourced aggregate that can drift again from a different call site in the future, so the engine itself should never trust it as a gate.
|
||||
[2026-07-06] Migration 20260706100000 adds profiles.deleted_at/anonymized_at (ADD COLUMN IF NOT EXISTS) alongside committing anonymize_user_account verbatim: the prod function writes those columns but no repo migration ever created them, so without the columns the drift capture would ship a function that fails on every from-scratch database (CI replay, self-hosted). No-op on prod.
|
||||
[2026-07-06] v1 reconciliation run: confidence_threshold has NO server-side default when omitted (existing API consumers keep current behavior; only the unattended enable-banking sync callers pass DEFAULT_UNATTENDED_CONFIDENCE_THRESHOLD=0.9); registry pitfalls recommend 0.9 to integrators. Revisit if telemetry shows API callers auto-applying fuzzy matches.
|
||||
[2026-07-05] Fixed supplier-invoice VAT silently dropped via MCP inbox conversion: gnubok_create_supplier_invoice_from_inbox now derives vat_amount from summed lineItems instead of the unreconciled OCR totals.vat field, and createSupplierInvoiceRegistrationEntry/CashEntry/PrivatelyPaidEntry gate the 2641 posting on itemsHaveVat(items) instead of invoice.vat_amount > 0. Chose to fix both the immediate source (server.ts) and the downstream gate (supplier-invoice-entries.ts) rather than just one: the header field is inherently a redundant, independently-sourced aggregate that can drift again from a different call site in the future, so the engine itself should never trust it as a gate.
|
||||
[2026-07-06] Salary surfaces (payslip PDF x2, payslip email, AGI, KU10, BG/LB + SEPA payment files) now resolve the employer name via getCompanyDisplayName (company_settings.company_name, fallback companies.name), matching invoices. Chose read-side coalesce (Option A) over mirroring the name onto companies.name on write (Option B): companies.name is write-once at onboarding and never authoritative for these surfaces, so A needs no migration/backfill. Included the Skatteverket forms (AGI/KU10) despite the sarskilt-foretagsnamn caveat because the frozen onboarding name (e.g. a lagerbolag's Grundstenen name) is unambiguously wrong and there is no dedicated registered-name field yet; a Bolagsverket-synced legal-name field is the real long-term fix.
|
||||
[2026-07-06] Sidebar company switcher: extended the same company_settings.company_name coalesce to the NON-active companies in the switcher list (the active one was already overridden with displayName in app/(dashboard)/layout.tsx). Fetched all the user's current names via a bare `company_settings.select('company_id, company_name')` added to the existing dashboard-layout Promise.all, relying on the company_settings SELECT RLS (company_id IN user_company_ids()) to scope it, rather than an explicit `.in(companyIds)` that would depend on allMemberships and add a serial round-trip on the every-dashboard-render critical path. Do not "harden" it with an explicit company_id filter: there is no single id here (it is deliberately the set of the caller's companies), and adding one reintroduces the round-trip.
|
||||
[2026-07-06] Employees API 500 (ERR_CRYPTO_INVALID_AUTH_TAG "length: 6"): root cause was the v1 REST create route storing personnummer plaintext (skipped encryptPersonnummer), which then threw in every decrypt-on-read path. Fix = encrypt-on-write in v1 create + decrypt-on-read in v1 list/detail/patch (the whole v1 employee module assumed plaintext storage) + a tolerant guard in decryptPersonnummer that passes a raw 12-digit value through with a warn (chosen over per-call-site guards: one change un-breaks roster/runs/payslips/KU/AGI/MCP at once, and stays a safety net + observability against any future non-encrypting writer). Prod backfill re-encrypts the 5 existing plaintext rows (2 companies) via scripts/backfill-encrypt-personnummer.ts. Deferred: duplicate-personnummer detection is already broken for encrypted rows (unique index sits on the random-IV column so it never collides); proper fix is a deterministic HMAC hash column + migration, tracked as a follow-up, not folded into the outage fix.
|
||||
[2026-07-06] Employee "Anstallda" clearing/kontonummer validation: added a shared structural validator (lib/salary/payment/bank-account.ts) wired into the create form, edit form, CreateEmployeeSchema, and the PATCH route, so a typo is caught at entry instead of at Bankgirot LB generation. Scoped to structure (4-digit clearing or 5-digit Swedbank 8xxxx; 5-11 digit account; both-or-neither) to mirror encodeReceiverAccount and avoid false rejections; per-bank mod10/mod11 checksum deliberately deferred to a vetted soft-warning follow-up (needs the official clearing-range table; getting it wrong rejects valid accounts). Update validated only when a bank field actually changes so legacy free-text bank data stays editable. Bank-name lookup is a conservative major-ranges-only table returning null (never a guessed name) for unknown clearings.
|
||||
[2026-07-06] Kontoplan (chart-of-accounts) load optimization: fixed a double-fetch (the load effect depended on hideK2Excluded, which the effect itself set, re-running every fetch on each visit), deferred the BAS catalog + K2 setting to first "BAS-katalog" tab open, and moved usage counts off the first-paint critical path. Slimmed /api/bookkeeping/accounts/reference to return only the company's activation rows and merge against the client-bundled BAS_REFERENCE, instead of re-sending the full ~1,300-account catalog (~400KB) every load. For the slow get_account_usage_counts aggregate (prod worst case ~440ms, ~58k heap-buffer hits) added a covering index on journal_entry_lines (journal_entry_id, account_number) so the inner join becomes an index-only scan (verified on staging: node flips to Index Only Scan, 111 heap fetches). Chose the covering index over denormalizing company_id onto journal_entry_lines + (company_id, account_number) index: the latter would need the commit_journal_entry RPC write path changed (Hard Rule #2) plus a 599k-row backfill, disproportionate for a usage-count column. Migration 20260706120000 applied to staging only; prod deploy pending Emil's go.
|
||||
[2026-07-06] Momsdeklaration "file without Skatteverket connection": the VAT report was never gated on the connection (it renders from bookkeeping via /api/reports/vat-declaration); users just read the not-connected "Anslut med BankID" card as a wall. Fix is communication only: added an always-visible VatManualFilingCard under the report (copy-the-rutor + skatteverket.se link) and reframed SkatteverketPanel's not-connected state to "Skicka direkt till Skatteverket (valfritt)". Put the manual card in VatDeclarationView (always rendered) rather than inside SkatteverketPanel, which returns null when the skatteverket extension is disabled, so core/self-hosted users also get manual-filing guidance. New strings kept hardcoded Swedish to match the surrounding momsdeklaration surface (VatDeclarationView + SkatteverketPanel use zero next-intl; VAT ruta labels are a "stays Swedish" surface per i18n rules) rather than adding lone en.json keys to an otherwise all-Swedish card. Rutor copy logic extracted to lib/reports/vat-manual-filing.ts (pure, unit-tested) since components aren't tested. SKATTEVERKET_MOMS_URL is a named constant flagged for Emil to confirm the exact stable e-service URL before shipping.
|
||||
[2026-07-06] Recurring invoices resend: the daily cron was accidentally dropped from vercel.json in #559 (2026-05-22), so no recurring schedule has sent since. Re-registering it as an hourly cron plus a per-schedule send_hour (0-23, Europe/Stockholm, DST-aware via Intl, no dep). Cron never sends for a past date: a schedule with next_run_date < today is rolled forward without generating (protects outages + the reactivation path). Crucially, the enabling migration (20260706140000) does a ONE-TIME pause of every schedule that exists at deploy so nothing resumes emailing customers behind their back after weeks of silence: users must consciously reactivate (Resume now confirms for auto_send schedules) or click "Skapa faktura nu" (new POST /[id]/run, leaves next_run_date untouched). Chose pause-all-existing over roll-forward-and-resume on Emil's explicit call (prior incident: customers got invoices they shouldn't). No backfill of the ~6 dark weeks. Also fixed the /invoices/recurring/[id] row-click 404 by removing the dead navigation (no detail page exists; edit page deferred). Reminders cron (also dropped in #559) deliberately left for a separate task. Migration not applied to prod by me.
|
||||
[2026-07-06] Recurring schedule reactivation (PATCH status -> active) rolls a stale next_run_date forward immediately and STRICTLY into the future (never today, even when today is the schedule's day_of_month), and clears last_run_warning: relying on the cron's stale-roll-forward left a past "Naesta korning" visible for up to an hour, and rolling to today would let the cron send within the hour of reactivation, colliding with the no-surprise-sends rule. Today's invoice is the explicit "Skapa faktura nu" action instead. Cron stale-roll-forward kept as the outage safety net. Verified prod has 2 active schedules (both auto_send, next_run 2026-06-01 and 2026-07-05, both stale), so the deploy race between the Vercel cron and the pause migration is harmless with current data: stale rows roll forward without sending in any ordering.
|
||||
[2026-07-06] Momsdeklaration manual-filing affordance, format decision: replaced the copy-the-rutor clipboard button with a downloadable momsdeklaration PDF (new route app/api/reports/vat-declaration/pdf via withRouteContext + lib/reports/vat-declaration-pdf-template.tsx), and added PDF alongside xlsx in the report's Exportera menu. Rationale: for manual moms filing you submit NO file (you type the rutor into skatteverket.se); moms has no SRU (that's income tax) and its only machine channel is the Skatteverket API. So the export is a read/record document, PDF reads like the actual SKV 4700, and the PDF disclaimer says explicitly it is not an inlamnad deklaration. Amounts are rendered in hela kronor (Skatteverket files whole kronor, no ore): buildManualFilingRows rounds each ruta and recomputes ruta 49 from the rounded output/input rutor per the Section G formula so the document ties out; this whole-krona rounding is deliberate and NOT the ore-precision money rule (nothing here is posted). On-screen report keeps ore (regulated rendering, out of scope). New route uses withRouteContext (not the older createClient+getUser pattern of the sibling report routes) so check:guards' antipattern ratchet stays green.
|
||||
[2026-07-06] "Spara som mall" on the manual bookkeeping form (JournalEntryForm): wired a save-as-template action next to the existing "Anvand mall" picker so users can capture a booking pattern at the moment they figure it out (user request). Reused the exact building blocks the invoice-inbox BookDirectlyDialog already uses (deriveTemplateLinesFromBooking + shared TemplateForm mode=create + POST /api/settings/booking-templates), so no new lib/API/DB. Two deviations from the extension dialog, both deliberate: (1) built the TemplateForm entityLabels from the settings_booking_templates i18n keys (entity_all/entity_enskild_firma/entity_aktiebolag) instead of reusing BookDirectlyDialog's hardcoded Swedish TEMPLATE_ENTITY_LABELS const, because JournalEntryForm is bilingual (journal_form namespace) whereas the inbox dialog is a Swedish-only extension surface; the four new button/dialog strings were added to both sv.json and en.json. (2) Button placed in BOTH the mobile and desktop layout rows AND in both create + edit (editEntryId) modes, mirroring where "Anvand mall" already renders, rather than gating it to fresh entries. accountNameMap derived from the form's existing catalog state (CatalogAccount) so template line labels get BAS names. No component test (repo has none); relied on the already-tested deriveTemplateLinesFromBooking. Not extracting TEMPLATE_ENTITY_LABELS to a shared const to keep the diff off BookDirectlyDialog.
|
||||
[2026-07-06] Recurring schedule editing: reused NewRecurringScheduleDialog for both create and edit (POST vs PATCH, driven by ?edit=<id>, prefilled from the already-loaded list row which carries items + send_hour, no extra fetch) rather than building a separate detail page. This is the "edit surface" deferred earlier ("Create dialog only"); clicking a row now opens the prefilled editor instead of doing nothing. PATCH additionally recomputes next_run_date to the next STRICTLY-future occurrence when day_of_month actually changes (compared against the stored value, not merely present in the payload, so editing name/items/time never moves an imminent send), mirroring the reactivation roll-forward. Existing email-gate + reactivation logic in PATCH cover the edit path unchanged. No schema change.
|
||||
[2026-07-06] v1 invoice POST (#895) refactored onto buildInvoiceWriteData instead of extending the hand-rolled compute: the v1 route was silently dropping ROT/RUT, article_id, revenue_account, accrual, and line_type fields that CreateInvoiceSchema already accepted; one shared builder eliminates that drift class permanently. Wire-shape kept: VAT_RULE_VIOLATION details stay snake_case via a mapping shim.
|
||||
[2026-07-06] v1 dimension value DELETE mirrors internal semantics (hard-delete unreferenced, 409 DIMENSION_VALUE_REFERENCED with archive hint otherwise) rather than DELETE=archive: identical behavior across dashboard and API beats a simpler mental model that would surprise users comparing the two surfaces. Value dates (end_date for projects) ride the existing PATCH; whole-dimension DELETE stays unsupported.
|
||||
[2026-07-06] Fastigheter-on-customers (item 3 of #895) deferred to a follow-up issue instead of shipping a quick column: single-default-property vs multi-property registry changes the data model and the ROT prefill UX; needs its own design pass.
|
||||
[2026-07-06] v1 articles endpoint is read-only list (GET) under invoices:read: the #895 ask is "pick articles when composing invoices via API", not article CRUD; linking article_id does not auto-fill line fields (caller copies price/VAT), matching how invoice_items freeze article data at write time.
|
||||
[2026-07-06] Kept two-step potential-match fetch on /transactions instead of single PostgREST embed: prod schema cache has no FK relationship for transactions.potential_supplier_invoice_id (PGRST200; migration 20260225100248 ADD COLUMN IF NOT EXISTS likely skipped the REFERENCES clause because the column pre-existed). Revisit after adding the FK via a new migration.
|
||||
[2026-07-06] Bolagsverket testbänk E2E as skipped-by-default vitest (BOLAGSVERKET_TESTBANK_E2E=1): needs the IP-bound firewall opening, so it can never run in CI; GUIDE's documented test pnr 190001010106 fails Luhn, 190001010107 is the accepted one.
|
||||
[2026-07-06] Bolagsverket testbank E2E as skipped-by-default vitest (BOLAGSVERKET_TESTBANK_E2E=1): needs the IP-bound firewall opening, so it can never run in CI; GUIDE's documented test pnr 190001010106 fails Luhn, 190001010107 is the accepted one.
|
||||
[2026-07-06] Paywall leak sweep gating choices: SKV unlock (DELETE /declaration/lock) left ungated so a lapsed company can recover a draft it locked while entitled; agi/kontrollera HU/IU gated (direct SKV API interaction = paid, file download stays free); recurring auto-send blocks only the email, invoice creation stays free (freeze-and-retain).
|
||||
[2026-07-07] Sjalvfaktura via the public invoice API (support request: "kan inte hitta det i docs"): exposed the RECEIVED self-billing invoice (mottagen sjalvfaktura, ML 17 kap 15: a SALE, Debit 1510 / Credit 30xx+26xx) on the public API as an OPTIONAL is_self_billed flag on the existing POST /api/v1/.../invoices endpoint (+ external_invoice_number, self_billing_agreement_ref, received_date), on Emil's explicit call ("configure sjalvfaktura when creating an invoice ... optional field") over a dedicated /invoices/self-billed v1 endpoint. First built the WRONG interpretation (issue a self-invoice on a SUPPLIER's behalf = a purchase, on the supplier-invoice stack, new SJ- series + PDF + MCP tool + migration 20260706130000); reverted all of it after Emil clarified the user meant the existing seller/received feature, which already existed internally (/api/invoices/self-billed, cookie-session) but was absent from the public API. No migration for B (is_self_billed/external_invoice_number/self_billing_agreement_ref columns already exist from 20260613100000). Extracted lib/invoices/self-billed-sale.ts (resolveSelfBilledSaleDraft + createSelfBilledSaleInvoice) as the single implementation and refactored the internal route to a thin wrapper over it, so the dashboard "Sjalvfaktura" tab and the API can't drift (internal route test still green, 8/8). Fields added as PLAIN optionals (no z.superRefine) so UpdateInvoiceSchema = CreateInvoiceSchema.omit() keeps working (superRefine turns it into ZodEffects, which has no .omit); "external_invoice_number + received_date required when is_self_billed" enforced in the route instead. Documented the flag in the invoices.create registerEndpoint (description + pitfall) since the whole ask was "can't find it in docs". No git touched; nothing deployed.
|
||||
[2026-07-07] Compliance-review sweep on add/api-and-invoice. FIXED: (1) recurring cron double-send window: replaced the read-only "already ran today" check with an atomic compare-and-set claim on last_run_at (release-on-failure) so two overlapping hourly invocations can't both spawn from the same stale batch row; (2) recurring schedule edit dialog could PATCH auto_send=true for a customer with no email (disabled-but-checked box, async customer load after defaultValues): added a useEffect that forces auto_send=false whenever the effective customer lacks an email, mirroring the manual-select guard; (3) momsdeklaration manual-filing rows: switched Math.round -> Math.trunc (öretal faller bort per SFL 22 kap 1 §; in-repo swedish-sru-filing skill confirms öre are DROPPED not rounded, and this now matches the SRU income-tax path). This narrows the earlier [2026-07-06] "whole-krona rounding is deliberate" decision: whole-krona stays deliberate, but the öre handling is truncation, not round-to-nearest. DECLINED (with rationale): self-billed "Självfakturering" notation + own-voucher-number findings are misdirected: createSelfBilledSaleInvoice books a RECEIVED självfaktura (the counterparty issues the document, we render no PDF), and numberOverride only sets the human-readable verifikat description/tag, not the sequential verifikationsnummer (still assigned atomically by commit_journal_entry). Bank-account mod11 padStart padding is correct: leading zeros contribute 0 and preserve the right-aligned check-digit weighting (already tested vs a real Forex account). AGI/KU10 employer name kept as [2026-07-06] (särskilt-företagsnamn caveat already accepted). Livsmedel 12%->6% (April 2026) reactivation guard declined as a description-keyword food-detector: violates the determinism/never-guess principle, and the one-time pause-all-on-deploy already forces conscious reactivation as the natural checkpoint.
|
||||
|
||||
@@ -30,6 +30,7 @@ type ScheduleRow = RecurringInvoiceSchedule & {
|
||||
export default function RecurringInvoicesPage() {
|
||||
const [schedules, setSchedules] = useState<ScheduleRow[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [runningId, setRunningId] = useState<string | null>(null)
|
||||
const { canWrite } = useCanWrite()
|
||||
const { toast } = useToast()
|
||||
const router = useRouter()
|
||||
@@ -44,6 +45,14 @@ export default function RecurringInvoicesPage() {
|
||||
const closeNewSchedule = () => router.replace('/invoices/recurring', { scroll: false })
|
||||
const openNewSchedule = () => router.push('/invoices/recurring?new=1', { scroll: false })
|
||||
|
||||
// Editing reuses the same modal, driven by ?edit=<id>. The schedule is taken
|
||||
// from the already-loaded list (it carries items + send_hour), so clicking a
|
||||
// row opens a prefilled form with no extra fetch.
|
||||
const editId = searchParams.get('edit')
|
||||
const editSchedule = editId ? schedules.find((s) => s.id === editId) : undefined
|
||||
const closeEdit = () => router.replace('/invoices/recurring', { scroll: false })
|
||||
const openEdit = (id: string) => router.push(`/invoices/recurring?edit=${id}`, { scroll: false })
|
||||
|
||||
async function fetchSchedules() {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
@@ -67,6 +76,15 @@ export default function RecurringInvoicesPage() {
|
||||
|
||||
async function togglePause(s: ScheduleRow) {
|
||||
const next = s.status === 'active' ? 'paused' : 'active'
|
||||
// Reactivating an auto-send schedule resumes automatic emails to the
|
||||
// customer, so make the user consciously confirm they mean to turn it on.
|
||||
if (
|
||||
next === 'active' &&
|
||||
s.auto_send &&
|
||||
!confirm(t('resume_autosend_confirm', { name: s.name }))
|
||||
) {
|
||||
return
|
||||
}
|
||||
const res = await fetch(`/api/invoices/recurring/${s.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -85,6 +103,31 @@ export default function RecurringInvoicesPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function runNow(s: ScheduleRow) {
|
||||
// In-flight guard: a second click while the request runs would create a
|
||||
// duplicate invoice for the customer.
|
||||
if (runningId) return
|
||||
if (!confirm(t('run_now_confirm', { name: s.name }))) return
|
||||
setRunningId(s.id)
|
||||
try {
|
||||
const res = await fetch(`/api/invoices/recurring/${s.id}/run`, { method: 'POST' })
|
||||
if (res.ok) {
|
||||
const json = await res.json().catch(() => ({}))
|
||||
const warning = (json?.data?.warning as string | null | undefined) ?? undefined
|
||||
toast({
|
||||
title: t('run_now_success_title'),
|
||||
description: warning,
|
||||
variant: warning ? 'destructive' : undefined,
|
||||
})
|
||||
fetchSchedules()
|
||||
} else {
|
||||
toast({ title: t('run_now_failed_title'), variant: 'destructive' })
|
||||
}
|
||||
} finally {
|
||||
setRunningId(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSchedule(s: ScheduleRow) {
|
||||
if (!confirm(t('delete_confirm', { name: s.name }))) {
|
||||
return
|
||||
@@ -154,8 +197,8 @@ export default function RecurringInvoicesPage() {
|
||||
{schedules.map((s) => (
|
||||
<TableRow
|
||||
key={s.id}
|
||||
className="cursor-pointer"
|
||||
onClick={() => router.push(`/invoices/recurring/${s.id}`)}
|
||||
className={canWrite ? 'cursor-pointer' : undefined}
|
||||
onClick={canWrite ? () => openEdit(s.id) : undefined}
|
||||
>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -171,7 +214,15 @@ export default function RecurringInvoicesPage() {
|
||||
<TableCell className="text-muted-foreground">
|
||||
{s.customer?.name ?? '-'}
|
||||
</TableCell>
|
||||
<TableCell className="tabular-nums">{s.day_of_month}</TableCell>
|
||||
<TableCell className="tabular-nums">
|
||||
{s.day_of_month}
|
||||
<span className="text-muted-foreground">
|
||||
{' · '}
|
||||
{t('send_time', {
|
||||
time: `${String(s.send_hour ?? 8).padStart(2, '0')}:00`,
|
||||
})}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="tabular-nums">{formatDate(s.next_run_date)}</TableCell>
|
||||
<TableCell>
|
||||
{s.status === 'active' ? (
|
||||
@@ -190,6 +241,14 @@ export default function RecurringInvoicesPage() {
|
||||
>
|
||||
{canWrite && (
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={runningId !== null}
|
||||
onClick={() => runNow(s)}
|
||||
>
|
||||
{t('run_now')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
@@ -221,11 +280,23 @@ export default function RecurringInvoicesPage() {
|
||||
onOpenChange={(open) => {
|
||||
if (!open) closeNewSchedule()
|
||||
}}
|
||||
onCreated={() => {
|
||||
onSaved={() => {
|
||||
closeNewSchedule()
|
||||
fetchSchedules()
|
||||
}}
|
||||
/>
|
||||
|
||||
<NewRecurringScheduleDialog
|
||||
open={!!editSchedule}
|
||||
schedule={editSchedule}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) closeEdit()
|
||||
}}
|
||||
onSaved={() => {
|
||||
closeEdit()
|
||||
fetchSchedules()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -135,6 +135,7 @@ export default async function DashboardLayout({
|
||||
{ data: agentProfileIdentity },
|
||||
{ data: userProfile },
|
||||
capabilities,
|
||||
{ data: allSettingsNames },
|
||||
] = await Promise.all([
|
||||
supabase.from('companies').select('*').eq('id', companyId).single(),
|
||||
supabase.from('company_members').select('role').eq('company_id', companyId).eq('user_id', user.id).single(),
|
||||
@@ -161,18 +162,31 @@ export default async function DashboardLayout({
|
||||
// in, distinct from the active company shown at the top.
|
||||
supabase.from('profiles').select('full_name').eq('id', user.id).maybeSingle(),
|
||||
getCompanyCapabilities(supabase, companyId),
|
||||
// Current display names for ALL the user's companies (the switcher list).
|
||||
// RLS scopes company_settings SELECT to user_company_ids(), so this bare
|
||||
// select returns exactly the caller's companies, letting non-active rows
|
||||
// show company_settings.company_name instead of the frozen companies.name.
|
||||
supabase.from('company_settings').select('company_id, company_name'),
|
||||
])
|
||||
|
||||
// company_id -> current display name for every company the user belongs to.
|
||||
const nameByCompany = new Map(
|
||||
(allSettingsNames || []).map((s) => [s.company_id, s.company_name as string | null]),
|
||||
)
|
||||
|
||||
if (!companyRow || !memberRow) {
|
||||
// Stale cookie pointing to a deleted/inaccessible company.
|
||||
// Render the empty-state dashboard so user can switch or create a company.
|
||||
const companyContextValue = {
|
||||
company: null,
|
||||
role: null,
|
||||
companies: (allMemberships || []).filter(m => m.companies).map((m) => ({
|
||||
company: m.companies as unknown as import('@/types').Company,
|
||||
role: m.role as CompanyRole,
|
||||
})),
|
||||
companies: (allMemberships || []).filter(m => m.companies).map((m) => {
|
||||
const c = m.companies as unknown as import('@/types').Company
|
||||
return {
|
||||
company: { ...c, name: nameByCompany.get(c.id) || c.name },
|
||||
role: m.role as CompanyRole,
|
||||
}
|
||||
}),
|
||||
isTeamMember,
|
||||
team,
|
||||
isSandbox: false,
|
||||
@@ -256,11 +270,13 @@ export default async function DashboardLayout({
|
||||
role: memberRow.role as CompanyRole,
|
||||
companies: (allMemberships || []).map((m) => {
|
||||
const c = m.companies as unknown as import('@/types').Company
|
||||
// Override active company's name with settings name
|
||||
if (c.id === companyId) {
|
||||
return { company: { ...c, name: displayName }, role: m.role as CompanyRole }
|
||||
// Current display name for every company (company_settings.company_name,
|
||||
// falling back to the frozen companies.name) so non-active switcher rows
|
||||
// are current too. For the active company this equals `displayName`.
|
||||
return {
|
||||
company: { ...c, name: nameByCompany.get(c.id) || c.name },
|
||||
role: m.role as CompanyRole,
|
||||
}
|
||||
return { company: c, role: m.role as CompanyRole }
|
||||
}),
|
||||
isTeamMember,
|
||||
team,
|
||||
|
||||
@@ -41,11 +41,7 @@ import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import {
|
||||
ClipboardCheck,
|
||||
ArrowLeftRight,
|
||||
Users,
|
||||
ReceiptText,
|
||||
Bot,
|
||||
BookOpen,
|
||||
ChevronDown,
|
||||
Loader2,
|
||||
Lock,
|
||||
@@ -60,18 +56,79 @@ import type {
|
||||
import { AttachDocumentPreview } from '@/components/bookkeeping/AttachDocumentPreview'
|
||||
import { MatchTransactionInvoicePreview } from '@/components/bookkeeping/MatchTransactionInvoicePreview'
|
||||
|
||||
const OPERATION_LABEL_KEYS: Record<string, { labelKey: string; icon: typeof ArrowLeftRight; variant: 'default' | 'secondary' | 'outline' }> = {
|
||||
categorize_transaction: { labelKey: 'type_categorize_transaction', icon: ArrowLeftRight, variant: 'default' },
|
||||
create_customer: { labelKey: 'type_create_customer', icon: Users, variant: 'secondary' },
|
||||
create_invoice: { labelKey: 'type_create_invoice', icon: ReceiptText, variant: 'outline' },
|
||||
create_transaction: { labelKey: 'type_create_transaction', icon: ArrowLeftRight, variant: 'secondary' },
|
||||
create_voucher: { labelKey: 'type_create_voucher', icon: BookOpen, variant: 'outline' },
|
||||
correct_entry: { labelKey: 'type_correct_entry', icon: BookOpen, variant: 'outline' },
|
||||
reverse_entry: { labelKey: 'type_reverse_entry', icon: BookOpen, variant: 'outline' },
|
||||
mark_invoice_paid: { labelKey: 'type_mark_invoice_paid', icon: ReceiptText, variant: 'default' },
|
||||
send_invoice: { labelKey: 'type_send_invoice', icon: ReceiptText, variant: 'outline' },
|
||||
mark_invoice_sent: { labelKey: 'type_mark_invoice_sent', icon: ReceiptText, variant: 'outline' },
|
||||
match_transaction_invoice: { labelKey: 'type_match_transaction_invoice', icon: ArrowLeftRight, variant: 'secondary' },
|
||||
// Short human label (i18n key in the "pending" namespace) for each staged
|
||||
// operation_type. Keep in sync with OPERATION_RISK_TIERS in
|
||||
// lib/pending-operations/risk-tiers.ts: every operation an agent can stage
|
||||
// needs a label here, otherwise the Granskning list falls back to the raw
|
||||
// snake_case tool name (e.g. "create_supplier_invoice_from_inbox"), which is
|
||||
// long and pushes the meta row to wrap awkwardly on mobile.
|
||||
const OPERATION_LABEL_KEYS: Record<string, string> = {
|
||||
categorize_transaction: 'type_categorize_transaction',
|
||||
create_customer: 'type_create_customer',
|
||||
create_invoice: 'type_create_invoice',
|
||||
create_transaction: 'type_create_transaction',
|
||||
create_voucher: 'type_create_voucher',
|
||||
correct_entry: 'type_correct_entry',
|
||||
reverse_entry: 'type_reverse_entry',
|
||||
mark_invoice_paid: 'type_mark_invoice_paid',
|
||||
send_invoice: 'type_send_invoice',
|
||||
mark_invoice_sent: 'type_mark_invoice_sent',
|
||||
match_transaction_invoice: 'type_match_transaction_invoice',
|
||||
// Master data
|
||||
create_supplier: 'type_create_supplier',
|
||||
create_article: 'type_create_article',
|
||||
update_article: 'type_update_article',
|
||||
create_dimension_value: 'type_create_dimension_value',
|
||||
// Supplier invoices
|
||||
create_supplier_invoice_from_inbox: 'type_create_supplier_invoice_from_inbox',
|
||||
create_self_billed_supplier_invoice: 'type_create_self_billed_supplier_invoice',
|
||||
approve_supplier_invoice: 'type_approve_supplier_invoice',
|
||||
credit_supplier_invoice: 'type_credit_supplier_invoice',
|
||||
// Invoices
|
||||
credit_invoice: 'type_credit_invoice',
|
||||
convert_invoice: 'type_convert_invoice',
|
||||
// Documents & links
|
||||
attach_document_to_transaction: 'type_attach_document_to_transaction',
|
||||
link_document_to_voucher: 'type_link_document_to_voucher',
|
||||
link_invoice_voucher: 'type_link_invoice_voucher',
|
||||
link_supplier_invoice_voucher: 'type_link_supplier_invoice_voucher',
|
||||
link_transaction_journal_entry: 'type_link_transaction_journal_entry',
|
||||
uncategorize_transaction: 'type_uncategorize_transaction',
|
||||
retag_line_dimensions: 'type_retag_line_dimensions',
|
||||
// Bulk booking / allocation
|
||||
match_batch_allocate: 'type_match_batch_allocate',
|
||||
bulk_book_transactions: 'type_bulk_book_transactions',
|
||||
bulk_book_inbox_items: 'type_bulk_book_inbox_items',
|
||||
// Periods, year-end, depreciation
|
||||
close_period: 'type_close_period',
|
||||
lock_period: 'type_lock_period',
|
||||
unlock_period: 'type_unlock_period',
|
||||
set_opening_balances: 'type_set_opening_balances',
|
||||
run_year_end: 'type_run_year_end',
|
||||
run_currency_revaluation: 'type_run_currency_revaluation',
|
||||
post_annual_depreciation: 'type_post_annual_depreciation',
|
||||
explain_voucher_gap: 'type_explain_voucher_gap',
|
||||
// SIE
|
||||
import_sie: 'type_import_sie',
|
||||
undo_sie_import: 'type_undo_sie_import',
|
||||
// Payroll & Skatteverket filings
|
||||
create_salary_run: 'type_create_salary_run',
|
||||
generate_agi: 'type_generate_agi',
|
||||
submit_vat_declaration: 'type_submit_vat_declaration',
|
||||
submit_agi: 'type_submit_agi',
|
||||
}
|
||||
|
||||
// Fallback for an operation_type with no entry above (e.g. a newly added op
|
||||
// not yet given a label): turn "create_supplier_invoice_from_inbox" into
|
||||
// "Create supplier invoice from inbox" so it never surfaces as raw snake_case.
|
||||
function humanizeOperationType(operationType: string): string {
|
||||
const spaced = operationType.replace(/_/g, ' ')
|
||||
return spaced.charAt(0).toUpperCase() + spaced.slice(1)
|
||||
}
|
||||
|
||||
function operationLabel(operationType: string, t: (key: string) => string): string {
|
||||
const labelKey = OPERATION_LABEL_KEYS[operationType]
|
||||
return labelKey ? t(labelKey) : humanizeOperationType(operationType)
|
||||
}
|
||||
|
||||
// Terse per-type labels used in the bulk confirmation dialog list. Phrased so
|
||||
@@ -95,9 +152,7 @@ const bulkActionDescriptions: Record<string, (count: number) => string> = {
|
||||
function bulkActionLabel(operationType: string, count: number, t: (key: string) => string): string {
|
||||
const fn = bulkActionDescriptions[operationType]
|
||||
if (fn) return fn(count)
|
||||
const entry = OPERATION_LABEL_KEYS[operationType]
|
||||
const fallback = entry ? t(entry.labelKey) : operationType
|
||||
return `${count} × ${fallback}`
|
||||
return `${count} × ${operationLabel(operationType, t)}`
|
||||
}
|
||||
|
||||
// Full-sentence warning for the single-op confirmation dialog AND the inline
|
||||
@@ -987,8 +1042,7 @@ export default function PendingOperationsPage() {
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<span className="text-xs text-muted-foreground">{t('quick_pick')}</span>
|
||||
{typeCounts.map(([type, count]) => {
|
||||
const entry = OPERATION_LABEL_KEYS[type]
|
||||
const label = entry ? t(entry.labelKey) : type
|
||||
const label = operationLabel(type, t)
|
||||
return (
|
||||
<Button
|
||||
key={type}
|
||||
@@ -1049,10 +1103,7 @@ export default function PendingOperationsPage() {
|
||||
/>
|
||||
) : (
|
||||
filteredOperations.map((op) => {
|
||||
const entry = OPERATION_LABEL_KEYS[op.operation_type]
|
||||
const config = entry
|
||||
? { label: t(entry.labelKey), icon: entry.icon, variant: entry.variant }
|
||||
: { label: op.operation_type, icon: ClipboardCheck, variant: 'default' as const }
|
||||
const label = operationLabel(op.operation_type, t)
|
||||
const isExpanded = expandedId === op.id
|
||||
const period = getPeriodStatus(op)
|
||||
const periodLocked = period != null && period.status !== 'open'
|
||||
@@ -1128,7 +1179,7 @@ export default function PendingOperationsPage() {
|
||||
>
|
||||
<DataListPrimary>{op.title}</DataListPrimary>
|
||||
<DataListMeta>
|
||||
<span className="font-medium text-foreground/70">{config.label}</span>
|
||||
<span className="font-medium text-foreground/70">{label}</span>
|
||||
{isAgent && (
|
||||
<>
|
||||
<DataListMetaSeparator />
|
||||
|
||||
@@ -14,6 +14,14 @@ import { ArrowLeft, Save, Trash2 } from 'lucide-react'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import {
|
||||
validateEmployeeBankAccount,
|
||||
isValidClearing,
|
||||
isValidAccount,
|
||||
normalizeBankNumber,
|
||||
lookupBankByClearing,
|
||||
checkEmployeeAccountChecksum,
|
||||
} from '@/lib/salary/payment/bank-account'
|
||||
import type { Employee } from '@/types'
|
||||
import { EmployeeBenefitsPanel } from '@/components/salary/EmployeeBenefitsPanel'
|
||||
import EmployeeTaxCard, { type EmployeeTaxValue } from '@/components/salary/EmployeeTaxCard'
|
||||
@@ -41,6 +49,10 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s
|
||||
const [employmentType, setEmploymentType] = useState('employee')
|
||||
const [salaryType, setSalaryType] = useState('monthly')
|
||||
const [vacationRule, setVacationRule] = useState('procentregeln')
|
||||
const [clearing, setClearing] = useState('')
|
||||
const [account, setAccount] = useState('')
|
||||
// Suppress the soft check-digit warning while the bank fields are focused.
|
||||
const [bankFocused, setBankFocused] = useState(false)
|
||||
const [tax, setTax] = useState<EmployeeTaxValue | null>(null)
|
||||
// Default dimensions bag ({sie_dim_no: object_code}) proposed on the
|
||||
// employee's salary-cost lines at booking. The fields render only when
|
||||
@@ -57,6 +69,8 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s
|
||||
setEmploymentType(data.employment_type)
|
||||
setSalaryType(data.salary_type || 'monthly')
|
||||
setVacationRule(data.vacation_rule || 'procentregeln')
|
||||
setClearing(data.clearing_number || '')
|
||||
setAccount(data.bank_account_number || '')
|
||||
setDimensions(data.default_dimensions ?? {})
|
||||
}
|
||||
setLoading(false)
|
||||
@@ -83,6 +97,24 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s
|
||||
|
||||
async function handleSave(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
|
||||
// Block invalid bank details, but only when they actually changed: a legacy
|
||||
// employee with incomplete free-text bank data must stay editable in other
|
||||
// ways (mirrors the server's changed-only check).
|
||||
const clearingChanged = normalizeBankNumber(clearing) !== normalizeBankNumber(employee?.clearing_number)
|
||||
const accountChanged = normalizeBankNumber(account) !== normalizeBankNumber(employee?.bank_account_number)
|
||||
if (clearingChanged || accountChanged) {
|
||||
const bankIssues = validateEmployeeBankAccount(clearing, account)
|
||||
if (bankIssues.length > 0) {
|
||||
toast({
|
||||
title: t('detail_update_failed'),
|
||||
description: bankIssues.map((i) => t(`bank_error_${i.code}`)).join('. '),
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
|
||||
const form = new FormData(e.currentTarget)
|
||||
@@ -104,8 +136,8 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s
|
||||
address_line1: form.get('address_line1') as string || undefined,
|
||||
postal_code: form.get('postal_code') as string || undefined,
|
||||
city: form.get('city') as string || undefined,
|
||||
clearing_number: form.get('clearing_number') as string || undefined,
|
||||
bank_account_number: form.get('bank_account_number') as string || undefined,
|
||||
clearing_number: normalizeBankNumber(clearing) || undefined,
|
||||
bank_account_number: normalizeBankNumber(account) || undefined,
|
||||
vacation_rule: vacationRule,
|
||||
vacation_days_per_year: parseInt(form.get('vacation_days_per_year') as string) || 25,
|
||||
// Always sent: {} clears the employee's default dimensions.
|
||||
@@ -164,6 +196,12 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s
|
||||
return <p className="text-muted-foreground">{t('detail_not_found')}</p>
|
||||
}
|
||||
|
||||
const bankName = lookupBankByClearing(clearing)
|
||||
const showChecksumWarning =
|
||||
!bankFocused &&
|
||||
validateEmployeeBankAccount(clearing, account).length === 0 &&
|
||||
checkEmployeeAccountChecksum(clearing, account) === 'invalid'
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -370,13 +408,44 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="clearing_number">{t('form_clearing_number')}</Label>
|
||||
<Input id="clearing_number" name="clearing_number" defaultValue={employee.clearing_number || ''} disabled={!canWrite} />
|
||||
<Input
|
||||
id="clearing_number"
|
||||
name="clearing_number"
|
||||
inputMode="numeric"
|
||||
value={clearing}
|
||||
onChange={(e) => setClearing(e.target.value)}
|
||||
onFocus={() => setBankFocused(true)}
|
||||
onBlur={() => setBankFocused(false)}
|
||||
disabled={!canWrite}
|
||||
aria-invalid={clearing !== '' && !isValidClearing(normalizeBankNumber(clearing))}
|
||||
/>
|
||||
{clearing !== '' && !isValidClearing(normalizeBankNumber(clearing)) ? (
|
||||
<p className="text-xs text-destructive">{t('bank_error_clearing_format')}</p>
|
||||
) : bankName ? (
|
||||
<p className="text-xs text-muted-foreground">{bankName}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bank_account_number">{t('form_account_number')}</Label>
|
||||
<Input id="bank_account_number" name="bank_account_number" defaultValue={employee.bank_account_number || ''} disabled={!canWrite} />
|
||||
<Input
|
||||
id="bank_account_number"
|
||||
name="bank_account_number"
|
||||
inputMode="numeric"
|
||||
value={account}
|
||||
onChange={(e) => setAccount(e.target.value)}
|
||||
onFocus={() => setBankFocused(true)}
|
||||
onBlur={() => setBankFocused(false)}
|
||||
disabled={!canWrite}
|
||||
aria-invalid={account !== '' && !isValidAccount(normalizeBankNumber(account))}
|
||||
/>
|
||||
{account !== '' && !isValidAccount(normalizeBankNumber(account)) && (
|
||||
<p className="text-xs text-destructive">{t('bank_error_account_format')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{showChecksumWarning && (
|
||||
<p className="mt-2 text-xs text-warning-foreground">{t('bank_warn_checksum')}</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground mt-2">{t('form_bank_hint')}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -62,19 +62,22 @@ describe('GET /api/bookkeeping/accounts/reference', () => {
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('pages the chart with a stable account_number order and merges activation status', async () => {
|
||||
it('pages the chart with a stable account_number order and returns activation status', async () => {
|
||||
const { supabase, calls } = createCapturingSupabase([
|
||||
{ data: [{ account_number: '1930', is_active: true, is_system_account: false }] },
|
||||
])
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
|
||||
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data: Array<{ account_number: string; is_activated: boolean }>
|
||||
data: Array<{ account_number: string; is_active: boolean; is_system_account: boolean }>
|
||||
}>(await referenceGET(createMockRequest('/api/bookkeeping/accounts/reference'), routeParams))
|
||||
|
||||
expect(status).toBe(200)
|
||||
const activated = body.data.find((a) => a.account_number === '1930')
|
||||
expect(activated?.is_activated).toBe(true)
|
||||
// The route returns only the company's activation rows; the BAS catalog is
|
||||
// merged client-side against the bundled reference data.
|
||||
const row = body.data.find((a) => a.account_number === '1930')
|
||||
expect(row?.is_active).toBe(true)
|
||||
expect(row?.is_system_account).toBe(false)
|
||||
// Paging-stability regression guard.
|
||||
expect(calls.filter((c) => c.method === 'order').map((c) => c.args[0])).toContain(
|
||||
'account_number'
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-reference'
|
||||
|
||||
/**
|
||||
* GET /api/bookkeeping/accounts/reference
|
||||
*
|
||||
* Returns the full BAS reference catalog merged with the user's activation status.
|
||||
* Each reference account includes: is_activated (exists in user's chart), is_active, is_system_account, is_custom.
|
||||
* Returns the company's chart-of-accounts activation status: one lightweight
|
||||
* row per account it holds (active or not), shaped
|
||||
* `{ account_number, is_active, is_system_account }`.
|
||||
*
|
||||
* The BAS catalog itself is static and already bundled into the client
|
||||
* (lib/bookkeeping/bas-reference); the kontoplan UI merges this activation
|
||||
* list against that bundled catalog to render the "BAS-katalog" tab. We
|
||||
* deliberately do NOT re-send the full ~1,300-account catalog over the wire on
|
||||
* every page load; the browser already has that payload.
|
||||
*/
|
||||
export const GET = withRouteContext('bookkeeping.accounts.reference', async (_request, ctx) => {
|
||||
const { supabase, companyId } = ctx
|
||||
|
||||
// Paginated with a stable unique order — a full-BAS chart exceeds the
|
||||
// Paginated with a stable unique order: a full-BAS chart exceeds the
|
||||
// 1000-row page size, and unordered .range() paging can duplicate or skip
|
||||
// rows on page boundaries (see fetch-all.ts ordering invariant).
|
||||
try {
|
||||
@@ -25,35 +31,7 @@ export const GET = withRouteContext('bookkeeping.accounts.reference', async (_re
|
||||
.range(from, to)
|
||||
)
|
||||
|
||||
// Build lookup map
|
||||
const userAccountMap = new Map(
|
||||
userAccounts.map((a) => [a.account_number, a])
|
||||
)
|
||||
|
||||
// Merge reference with user status
|
||||
const merged = BAS_REFERENCE.map((ref) => {
|
||||
const userAccount = userAccountMap.get(ref.account_number)
|
||||
return {
|
||||
...ref,
|
||||
is_activated: !!userAccount,
|
||||
is_active: userAccount?.is_active ?? false,
|
||||
is_system_account: userAccount?.is_system_account ?? false,
|
||||
}
|
||||
})
|
||||
|
||||
// Also identify custom accounts (in user's chart but not in BAS reference)
|
||||
const basNumbers = new Set(BAS_REFERENCE.map((r) => r.account_number))
|
||||
const customAccounts = userAccounts
|
||||
.filter((a) => !basNumbers.has(a.account_number))
|
||||
.map((a) => ({
|
||||
account_number: a.account_number,
|
||||
is_custom: true,
|
||||
is_activated: true,
|
||||
is_active: a.is_active,
|
||||
is_system_account: a.is_system_account,
|
||||
}))
|
||||
|
||||
return NextResponse.json({ data: merged, customAccounts })
|
||||
return NextResponse.json({ data: userAccounts })
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: error instanceof Error ? error.message : 'Failed to fetch accounts' }, { status: 500 })
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { describe, it, expect, vi, beforeEach, beforeAll } from 'vitest'
|
||||
import { createMockRequest, createMockRouteParams, parseJsonResponse } from '@/tests/helpers'
|
||||
|
||||
const mockCreateClient = vi.fn()
|
||||
@@ -36,6 +36,14 @@ vi.mock('@/lib/bokslut/accruals/auto-detect', () => ({
|
||||
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
|
||||
// The route module pulls in the bookkeeping engine and accrual detector; that
|
||||
// parse can take seconds under full-suite parallel load. Warm it once here so
|
||||
// no individual test's default 5s timeout has to absorb the import cost.
|
||||
let GET: typeof import('../route').GET
|
||||
beforeAll(async () => {
|
||||
;({ GET } = await import('../route'))
|
||||
}, 30_000)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCreateClient.mockResolvedValue({
|
||||
@@ -48,7 +56,6 @@ describe('GET /api/bookkeeping/fiscal-periods/[id]/accruals', () => {
|
||||
mockCreateClient.mockResolvedValue({
|
||||
auth: { getUser: vi.fn().mockResolvedValue({ data: { user: null } }) },
|
||||
})
|
||||
const { GET } = await import('../route')
|
||||
const res = await GET(
|
||||
createMockRequest('/api/bookkeeping/fiscal-periods/period-1/accruals'),
|
||||
createMockRouteParams({ id: 'period-1' }),
|
||||
@@ -76,7 +83,6 @@ describe('GET /api/bookkeeping/fiscal-periods/[id]/accruals', () => {
|
||||
suggested_deferred_account: null,
|
||||
},
|
||||
])
|
||||
const { GET } = await import('../route')
|
||||
const res = await GET(
|
||||
createMockRequest('/api/bookkeeping/fiscal-periods/period-1/accruals'),
|
||||
createMockRouteParams({ id: 'period-1' }),
|
||||
@@ -92,7 +98,6 @@ describe('GET /api/bookkeeping/fiscal-periods/[id]/accruals', () => {
|
||||
proposals: [],
|
||||
})
|
||||
mockDetectPeriodisering.mockRejectedValue(new Error('boom'))
|
||||
const { GET } = await import('../route')
|
||||
const res = await GET(
|
||||
createMockRequest('/api/bookkeeping/fiscal-periods/period-1/accruals'),
|
||||
createMockRouteParams({ id: 'period-1' }),
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
|
||||
|
||||
// Capturing mock: one shared chain whose .single() resolves the schedule row,
|
||||
// whose bare await resolves { error: null }, and whose .update() records the
|
||||
// payload so tests can assert exactly what gets written.
|
||||
const updatePayloads: Record<string, unknown>[] = []
|
||||
let scheduleRow: Record<string, unknown> | null = null
|
||||
let customerRow: Record<string, unknown> | null = null
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const chain: any = {
|
||||
select: () => chain,
|
||||
update: (payload: Record<string, unknown>) => {
|
||||
updatePayloads.push(payload)
|
||||
return chain
|
||||
},
|
||||
delete: () => chain,
|
||||
insert: () => chain,
|
||||
eq: () => chain,
|
||||
single: () => Promise.resolve({ data: scheduleRow, error: null }),
|
||||
maybeSingle: () => Promise.resolve({ data: customerRow, error: null }),
|
||||
then: (resolve: (v: unknown) => void) => resolve({ error: null }),
|
||||
}
|
||||
|
||||
const mockSupabase = {
|
||||
auth: { getUser: vi.fn() },
|
||||
from: vi.fn(() => chain),
|
||||
}
|
||||
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: () => Promise.resolve(mockSupabase),
|
||||
}))
|
||||
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}))
|
||||
|
||||
import { PATCH } from '../route'
|
||||
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
const params = { params: Promise.resolve({ id: 's-1' }) }
|
||||
const patchReq = (body: unknown) =>
|
||||
createMockRequest('/api/invoices/recurring/s-1', { method: 'PATCH', body })
|
||||
|
||||
describe('PATCH /api/invoices/recurring/[id] reactivation', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
updatePayloads.length = 0
|
||||
customerRow = null
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
|
||||
vi.useFakeTimers()
|
||||
// 08:30 UTC = 10:30 Stockholm (CEST) -> today is 2026-07-06 in Sweden.
|
||||
vi.setSystemTime(new Date('2026-07-06T08:30:00Z'))
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('rolls a stale next_run_date forward and clears the warning on reactivation', async () => {
|
||||
scheduleRow = { next_run_date: '2026-07-05', day_of_month: 5 }
|
||||
|
||||
const { status } = await parseJsonResponse(await PATCH(patchReq({ status: 'active' }), params))
|
||||
expect(status).toBe(200)
|
||||
expect(updatePayloads).toHaveLength(1)
|
||||
expect(updatePayloads[0]).toMatchObject({
|
||||
status: 'active',
|
||||
next_run_date: '2026-08-05',
|
||||
last_run_warning: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('rolls strictly into the future when reactivated on the schedule day itself', async () => {
|
||||
scheduleRow = { next_run_date: '2026-07-06', day_of_month: 6 }
|
||||
|
||||
await PATCH(patchReq({ status: 'active' }), params)
|
||||
// Never today: today's invoice is the explicit run-now action instead.
|
||||
expect(updatePayloads[0].next_run_date).toBe('2026-08-06')
|
||||
})
|
||||
|
||||
it('keeps a future next_run_date untouched but still clears the warning', async () => {
|
||||
scheduleRow = { next_run_date: '2026-07-20', day_of_month: 20 }
|
||||
|
||||
await PATCH(patchReq({ status: 'active' }), params)
|
||||
expect(updatePayloads[0]).not.toHaveProperty('next_run_date')
|
||||
expect(updatePayloads[0]).toHaveProperty('last_run_warning', null)
|
||||
})
|
||||
|
||||
it('recomputes next_run_date to the new day when day_of_month is edited', async () => {
|
||||
// Paused schedule, day 5 -> user edits to day 20. Today is 2026-07-06, so
|
||||
// the next day-20 occurrence is later this month.
|
||||
scheduleRow = { next_run_date: '2026-08-05', day_of_month: 5 }
|
||||
|
||||
await PATCH(patchReq({ day_of_month: 20 }), params)
|
||||
expect(updatePayloads[0]).toMatchObject({ day_of_month: 20, next_run_date: '2026-07-20' })
|
||||
// Not a reactivation, so the warning is left as-is.
|
||||
expect(updatePayloads[0]).not.toHaveProperty('last_run_warning')
|
||||
})
|
||||
|
||||
it('leaves next_run_date alone when the edited day is unchanged', async () => {
|
||||
scheduleRow = { next_run_date: '2026-07-20', day_of_month: 20 }
|
||||
|
||||
await PATCH(patchReq({ day_of_month: 20, name: 'Renamed' }), params)
|
||||
expect(updatePayloads[0]).not.toHaveProperty('next_run_date')
|
||||
expect(updatePayloads[0]).toMatchObject({ day_of_month: 20, name: 'Renamed' })
|
||||
})
|
||||
|
||||
it('does not touch next_run_date or warning when pausing', async () => {
|
||||
scheduleRow = { next_run_date: '2026-07-05', day_of_month: 5 }
|
||||
|
||||
await PATCH(patchReq({ status: 'paused' }), params)
|
||||
expect(updatePayloads[0]).toEqual({ status: 'paused' })
|
||||
})
|
||||
|
||||
it('returns 404 when reactivating a schedule that does not exist', async () => {
|
||||
scheduleRow = null
|
||||
|
||||
const { status, body } = await parseJsonResponse<{ type: string }>(
|
||||
await PATCH(patchReq({ status: 'active' }), params),
|
||||
)
|
||||
expect(status).toBe(404)
|
||||
expect(body.type).toBe('not_found')
|
||||
expect(updatePayloads).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('rejects enabling auto_send when the customer has no email', async () => {
|
||||
scheduleRow = { auto_send: false, customer_id: 'c-1' }
|
||||
customerRow = { email: null }
|
||||
|
||||
const { status, body } = await parseJsonResponse<{ type: string }>(
|
||||
await PATCH(patchReq({ auto_send: true }), params),
|
||||
)
|
||||
expect(status).toBe(400)
|
||||
expect(body.type).toBe('validation_error')
|
||||
expect(updatePayloads).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('allows enabling auto_send when the customer has an email', async () => {
|
||||
scheduleRow = { auto_send: false, customer_id: 'c-1' }
|
||||
customerRow = { email: 'kund@test.se' }
|
||||
|
||||
const { status } = await parseJsonResponse(await PATCH(patchReq({ auto_send: true }), params))
|
||||
expect(status).toBe(200)
|
||||
expect(updatePayloads[0]).toEqual({ auto_send: true })
|
||||
})
|
||||
})
|
||||
@@ -3,6 +3,11 @@ import { ensureInitialized } from '@/lib/init'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponse } from '@/lib/errors/get-structured-error'
|
||||
import { UpdateRecurringScheduleSchema } from '@/lib/api/schemas'
|
||||
import {
|
||||
computeInitialRunDate,
|
||||
computeNextRunDate,
|
||||
getStockholmDateHour,
|
||||
} from '@/lib/invoices/recurring-schedule-service'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
@@ -69,6 +74,94 @@ export const PATCH = withRouteContext(
|
||||
if (v !== undefined) updateRow[k] = v
|
||||
}
|
||||
|
||||
// Turning auto_send on (or moving the schedule to another customer while
|
||||
// it is on) requires the customer to have an email address; otherwise
|
||||
// every cron run degrades to a draft + warning. Mirrors the create
|
||||
// route's guard.
|
||||
if (input.auto_send === true || input.customer_id !== undefined) {
|
||||
const { data: current } = await supabase
|
||||
.from('recurring_invoice_schedules')
|
||||
.select('auto_send, customer_id')
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (!current) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Schedule not found', type: 'not_found' },
|
||||
{ status: 404 },
|
||||
)
|
||||
}
|
||||
|
||||
const effectiveAutoSend = input.auto_send ?? current.auto_send
|
||||
if (effectiveAutoSend) {
|
||||
const { data: customer } = await supabase
|
||||
.from('customers')
|
||||
.select('email')
|
||||
.eq('id', input.customer_id ?? current.customer_id)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
|
||||
if (!customer?.email) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Customer has no email address: automatic sending requires one',
|
||||
type: 'validation_error',
|
||||
},
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Recompute next_run_date when either the schedule is being reactivated
|
||||
// (from a stale date) or its day-of-month actually changed via an edit.
|
||||
if (input.status === 'active' || input.day_of_month !== undefined) {
|
||||
const { data: existing } = await supabase
|
||||
.from('recurring_invoice_schedules')
|
||||
.select('next_run_date, day_of_month')
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (!existing) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Schedule not found', type: 'not_found' },
|
||||
{ status: 404 },
|
||||
)
|
||||
}
|
||||
|
||||
const reactivating = input.status === 'active'
|
||||
const dayChanged =
|
||||
input.day_of_month !== undefined && input.day_of_month !== existing.day_of_month
|
||||
const effectiveDay = input.day_of_month ?? existing.day_of_month
|
||||
const { date: todayStockholm } = getStockholmDateHour(new Date())
|
||||
const stockholmToday = new Date(`${todayStockholm}T00:00:00Z`)
|
||||
|
||||
// Recompute to the next STRICTLY-future occurrence (never today, so an
|
||||
// edit or reactivation can't trigger a same-hour surprise send; today's
|
||||
// invoice is the explicit run-now action instead) when either:
|
||||
// - reactivating a schedule whose date already passed (e.g. the safety
|
||||
// pause when the send-hour cron shipped), or
|
||||
// - the day-of-month changed, so "Nästa körning" follows the new day.
|
||||
// Editing other fields (name, items, time) leaves next_run_date alone,
|
||||
// so an unrelated edit never skips an imminent send.
|
||||
const staleOnReactivate = reactivating && existing.next_run_date <= todayStockholm
|
||||
if (staleOnReactivate || dayChanged) {
|
||||
const rolled = computeInitialRunDate(stockholmToday, effectiveDay)
|
||||
updateRow.next_run_date =
|
||||
rolled === todayStockholm
|
||||
? computeNextRunDate(stockholmToday, effectiveDay)
|
||||
: rolled
|
||||
}
|
||||
|
||||
// A conscious reactivation invalidates any lingering warning (the
|
||||
// safety-pause note, or a stale failure from months ago).
|
||||
if (reactivating) {
|
||||
updateRow.last_run_warning = null
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(updateRow).length > 0) {
|
||||
const { error: updateError } = await supabase
|
||||
.from('recurring_invoice_schedules')
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
|
||||
|
||||
const { supabase: queuedSupabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
// Swappable so one test can use a capturing client to inspect the update payload.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let activeSupabase: any = queuedSupabase
|
||||
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: () => Promise.resolve(activeSupabase),
|
||||
createServiceClient: () => activeSupabase,
|
||||
}))
|
||||
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}))
|
||||
|
||||
const executeRecurringSchedule = vi.fn()
|
||||
vi.mock('@/lib/invoices/recurring-schedule-service', () => ({
|
||||
executeRecurringSchedule: (...args: unknown[]) => executeRecurringSchedule(...args),
|
||||
}))
|
||||
|
||||
import { POST } from '../route'
|
||||
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
const params = { params: Promise.resolve({ id: 's-1' }) }
|
||||
const req = () => createMockRequest('/api/invoices/recurring/s-1/run', { method: 'POST' })
|
||||
|
||||
describe('POST /api/invoices/recurring/[id]/run', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
activeSupabase = queuedSupabase
|
||||
queuedSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
|
||||
})
|
||||
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
queuedSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
|
||||
const { status } = await parseJsonResponse(await POST(req(), params))
|
||||
expect(status).toBe(401)
|
||||
expect(executeRecurringSchedule).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 404 when the schedule does not exist', async () => {
|
||||
enqueue({ data: null, error: null })
|
||||
const { status, body } = await parseJsonResponse<{ type: string }>(await POST(req(), params))
|
||||
expect(status).toBe(404)
|
||||
expect(body.type).toBe('not_found')
|
||||
expect(executeRecurringSchedule).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('generates an invoice and returns it', async () => {
|
||||
enqueue({
|
||||
data: { id: 's-1', company_id: 'company-1', generated_count: 2, items: [] },
|
||||
error: null,
|
||||
})
|
||||
enqueue({ error: null }) // tracking update
|
||||
executeRecurringSchedule.mockResolvedValue({
|
||||
invoiceId: 'inv-9',
|
||||
invoiceNumber: 'F-9',
|
||||
autoSent: false,
|
||||
warning: null,
|
||||
})
|
||||
|
||||
const { status, body } = await parseJsonResponse<{ data: { invoiceId: string } }>(
|
||||
await POST(req(), params),
|
||||
)
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.invoiceId).toBe('inv-9')
|
||||
expect(executeRecurringSchedule).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('records the run but never touches next_run_date (keeps the monthly cadence)', async () => {
|
||||
const updatePayloads: Record<string, unknown>[] = []
|
||||
const scheduleRow = {
|
||||
id: 's-1',
|
||||
company_id: 'company-1',
|
||||
generated_count: 0,
|
||||
next_run_date: '2026-08-05',
|
||||
items: [],
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const chain: any = {
|
||||
select: () => chain,
|
||||
update: (payload: Record<string, unknown>) => {
|
||||
updatePayloads.push(payload)
|
||||
return chain
|
||||
},
|
||||
eq: () => chain,
|
||||
single: () => Promise.resolve({ data: scheduleRow, error: null }),
|
||||
then: (resolve: (v: unknown) => void) => resolve({ error: null }),
|
||||
}
|
||||
activeSupabase = {
|
||||
auth: { getUser: vi.fn().mockResolvedValue({ data: { user: mockUser } }) },
|
||||
from: vi.fn(() => chain),
|
||||
}
|
||||
executeRecurringSchedule.mockResolvedValue({
|
||||
invoiceId: 'inv-1',
|
||||
invoiceNumber: 'F-1',
|
||||
autoSent: false,
|
||||
warning: null,
|
||||
})
|
||||
|
||||
const { status } = await parseJsonResponse(await POST(req(), params))
|
||||
expect(status).toBe(200)
|
||||
expect(updatePayloads).toHaveLength(1)
|
||||
expect(updatePayloads[0]).not.toHaveProperty('next_run_date')
|
||||
expect(updatePayloads[0]).toHaveProperty('generated_count', 1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,90 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponse } from '@/lib/errors/get-structured-error'
|
||||
import { executeRecurringSchedule } from '@/lib/invoices/recurring-schedule-service'
|
||||
import type { RecurringInvoiceSchedule, RecurringInvoiceScheduleItem } from '@/types'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
/**
|
||||
* POST /api/invoices/recurring/[id]/run: manually generate (and, when the
|
||||
* schedule has auto_send, email) an invoice from a recurring schedule right
|
||||
* now, on demand.
|
||||
*
|
||||
* Why this exists: the cron never sends for a past date, and all schedules
|
||||
* were paused on the send-time rollout, so a user who wants this month's
|
||||
* invoice sent now needs an explicit, conscious action. This is that action.
|
||||
* It runs regardless of status (active or paused): the user is clicking the
|
||||
* button themselves, so awareness is not in question.
|
||||
*
|
||||
* It deliberately does NOT touch next_run_date: a manual send is out-of-band
|
||||
* and must not disturb the monthly cadence.
|
||||
*/
|
||||
export const POST = withRouteContext(
|
||||
'recurring_invoice.run_now',
|
||||
async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => {
|
||||
const { id } = await params
|
||||
const { supabase, companyId, log, requestId } = ctx
|
||||
|
||||
const { data: schedule, error } = await supabase
|
||||
.from('recurring_invoice_schedules')
|
||||
.select('*, items:recurring_invoice_schedule_items(*)')
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (error || !schedule) {
|
||||
log.warn('recurring schedule not found for run-now', { scheduleId: id })
|
||||
return NextResponse.json(
|
||||
{ error: 'Schedule not found', type: 'not_found' },
|
||||
{ status: 404 },
|
||||
)
|
||||
}
|
||||
|
||||
const typed = schedule as RecurringInvoiceSchedule & {
|
||||
items: RecurringInvoiceScheduleItem[]
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await executeRecurringSchedule(supabase, typed, new Date())
|
||||
|
||||
// Record the run for the list view (generated count, last invoice,
|
||||
// warning) but leave next_run_date untouched: the monthly cadence runs
|
||||
// independently of this manual send.
|
||||
const { error: updateError } = await supabase
|
||||
.from('recurring_invoice_schedules')
|
||||
.update({
|
||||
last_run_at: new Date().toISOString(),
|
||||
last_invoice_id: result.invoiceId,
|
||||
last_run_warning: result.warning,
|
||||
generated_count: typed.generated_count + 1,
|
||||
})
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
|
||||
if (updateError) {
|
||||
// The invoice exists; the tracking update failing is non-fatal. Log
|
||||
// loudly and still return the created invoice.
|
||||
log.error(
|
||||
'invoice created but failed to update schedule after run-now',
|
||||
updateError,
|
||||
{ scheduleId: id, invoiceId: result.invoiceId },
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
invoiceId: result.invoiceId,
|
||||
invoiceNumber: result.invoiceNumber,
|
||||
autoSent: result.autoSent,
|
||||
warning: result.warning,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
log.error('run-now failed to generate invoice', err as Error, { scheduleId: id })
|
||||
return errorResponse(err, log, { requestId })
|
||||
}
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
@@ -106,6 +106,30 @@ describe('POST /api/invoices/recurring', () => {
|
||||
expect(body.type).toBe('not_found')
|
||||
})
|
||||
|
||||
it('rejects auto_send when the customer has no email', async () => {
|
||||
// customer lookup: exists but without email
|
||||
enqueue({ data: { id: '550e8400-e29b-41d4-a716-446655440000', email: null }, error: null })
|
||||
|
||||
const request = createMockRequest('/api/invoices/recurring', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
customer_id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
name: 'Test',
|
||||
day_of_month: 15,
|
||||
payment_terms_days: 30,
|
||||
currency: 'SEK',
|
||||
auto_send: true,
|
||||
items: [
|
||||
{ description: 'Service', quantity: 1, unit: 'st', unit_price: 1000 },
|
||||
],
|
||||
},
|
||||
})
|
||||
const response = await POST(request, { params: Promise.resolve({}) })
|
||||
const { status, body } = await parseJsonResponse<{ type: string }>(response)
|
||||
expect(status).toBe(400)
|
||||
expect(body.type).toBe('validation_error')
|
||||
})
|
||||
|
||||
it('creates a schedule on the happy path', async () => {
|
||||
const createdSchedule = {
|
||||
id: 's-1',
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
|
||||
|
||||
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createServiceClient: () => mockSupabase,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
|
||||
|
||||
// Cron auth always passes in these tests.
|
||||
vi.mock('@/lib/auth/cron', () => ({ verifyCronSecret: () => null }))
|
||||
|
||||
// Replace only the heavy invoice-spawning function; keep the real date helpers
|
||||
// (getStockholmDateHour / computeNextRunDate / computeInitialRunDate).
|
||||
const executeRecurringSchedule = vi.fn()
|
||||
vi.mock('@/lib/invoices/recurring-schedule-service', async (importActual) => {
|
||||
const actual = await importActual<typeof import('@/lib/invoices/recurring-schedule-service')>()
|
||||
return {
|
||||
...actual,
|
||||
executeRecurringSchedule: (...args: unknown[]) => executeRecurringSchedule(...args),
|
||||
}
|
||||
})
|
||||
|
||||
import { GET } from '../route'
|
||||
|
||||
type ResultRow = {
|
||||
scheduleId: string
|
||||
invoiceId?: string
|
||||
skipped?: boolean
|
||||
skipReason?: string
|
||||
}
|
||||
type CronBody = { success: boolean; succeeded: number; results: ResultRow[] }
|
||||
|
||||
function makeSchedule(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 's-1',
|
||||
company_id: 'c-1',
|
||||
day_of_month: 6,
|
||||
send_hour: 8,
|
||||
next_run_date: '2026-07-06',
|
||||
last_run_at: null,
|
||||
generated_count: 0,
|
||||
items: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const req = () => createMockRequest('/api/invoices/recurring/cron', { method: 'GET' })
|
||||
|
||||
describe('GET /api/invoices/recurring/cron', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('sends a schedule due today once the Stockholm send hour has arrived', async () => {
|
||||
// 08:30 UTC = 10:30 Stockholm (CEST) -> hour 10 >= send_hour 8
|
||||
vi.setSystemTime(new Date('2026-07-06T08:30:00Z'))
|
||||
enqueue({ data: [makeSchedule({ send_hour: 8 })], error: null })
|
||||
// Atomic claim wins (returns the row it flipped).
|
||||
enqueue({ data: [{ id: 's-1' }], error: null })
|
||||
executeRecurringSchedule.mockResolvedValue({
|
||||
invoiceId: 'inv-1',
|
||||
invoiceNumber: 'F-1',
|
||||
autoSent: true,
|
||||
warning: null,
|
||||
})
|
||||
|
||||
const { status, body } = await parseJsonResponse<CronBody>(await GET(req()))
|
||||
expect(status).toBe(200)
|
||||
expect(executeRecurringSchedule).toHaveBeenCalledTimes(1)
|
||||
expect(body.succeeded).toBe(1)
|
||||
expect(body.results[0].invoiceId).toBe('inv-1')
|
||||
})
|
||||
|
||||
it('skips when a concurrent cron run already claimed the schedule', async () => {
|
||||
vi.setSystemTime(new Date('2026-07-06T08:30:00Z'))
|
||||
enqueue({ data: [makeSchedule({ send_hour: 8 })], error: null })
|
||||
// Atomic claim loses the race: the compare-and-set matched zero rows.
|
||||
enqueue({ data: [], error: null })
|
||||
|
||||
const { body } = await parseJsonResponse<CronBody>(await GET(req()))
|
||||
expect(executeRecurringSchedule).not.toHaveBeenCalled()
|
||||
expect(body.results[0].skipReason).toBe('claimed_by_concurrent_run')
|
||||
})
|
||||
|
||||
it('does not send before the chosen Stockholm hour', async () => {
|
||||
// 04:30 UTC = 06:30 Stockholm -> hour 6 < send_hour 8
|
||||
vi.setSystemTime(new Date('2026-07-06T04:30:00Z'))
|
||||
enqueue({ data: [makeSchedule({ send_hour: 8, next_run_date: '2026-07-06' })], error: null })
|
||||
|
||||
const { body } = await parseJsonResponse<CronBody>(await GET(req()))
|
||||
expect(executeRecurringSchedule).not.toHaveBeenCalled()
|
||||
expect(body.results[0].skipReason).toBe('hour_not_reached')
|
||||
})
|
||||
|
||||
it('rolls a past-due schedule forward WITHOUT sending (never invoices the past)', async () => {
|
||||
// Today Stockholm = 2026-07-06; schedule missed its 2026-07-05 date.
|
||||
vi.setSystemTime(new Date('2026-07-06T08:30:00Z'))
|
||||
enqueue({ data: [makeSchedule({ next_run_date: '2026-07-05', day_of_month: 5 })], error: null })
|
||||
|
||||
const { body } = await parseJsonResponse<CronBody>(await GET(req()))
|
||||
expect(executeRecurringSchedule).not.toHaveBeenCalled()
|
||||
expect(body.results[0].skipReason).toBe('stale_rolled_forward')
|
||||
})
|
||||
|
||||
it('skips a schedule that already ran earlier today', async () => {
|
||||
vi.setSystemTime(new Date('2026-07-06T08:30:00Z'))
|
||||
enqueue({
|
||||
data: [makeSchedule({ last_run_at: '2026-07-06T06:15:00Z', send_hour: 8 })],
|
||||
error: null,
|
||||
})
|
||||
|
||||
const { body } = await parseJsonResponse<CronBody>(await GET(req()))
|
||||
expect(executeRecurringSchedule).not.toHaveBeenCalled()
|
||||
expect(body.results[0].skipReason).toBe('already_ran_today')
|
||||
})
|
||||
})
|
||||
@@ -5,6 +5,8 @@ import { createServiceClient } from '@/lib/supabase/server'
|
||||
import {
|
||||
executeRecurringSchedule,
|
||||
computeNextRunDate,
|
||||
computeInitialRunDate,
|
||||
getStockholmDateHour,
|
||||
} from '@/lib/invoices/recurring-schedule-service'
|
||||
import type {
|
||||
RecurringInvoiceSchedule,
|
||||
@@ -16,26 +18,40 @@ ensureInitialized()
|
||||
type DueSchedule = RecurringInvoiceSchedule & { items: RecurringInvoiceScheduleItem[] }
|
||||
|
||||
/**
|
||||
* GET /api/invoices/recurring/cron: daily 06:30 UTC.
|
||||
* GET /api/invoices/recurring/cron: hourly (top of every hour, UTC).
|
||||
*
|
||||
* Spawns invoices for every active schedule whose next_run_date is today or
|
||||
* earlier. Each schedule runs in isolated try/catch so a failure on one
|
||||
* doesn't block the rest. On success: bump next_run_date, last_run_at,
|
||||
* last_invoice_id, generated_count. On failure: leave next_run_date alone so
|
||||
* tomorrow's run retries; pause the schedule only if the same error recurs
|
||||
* across days (out of scope for v1: let the user investigate).
|
||||
* Users pick a send hour in Swedish local time (send_hour, 0-23,
|
||||
* Europe/Stockholm). This cron runs every hour and, for each active schedule
|
||||
* due today, sends only once the chosen Stockholm hour has arrived.
|
||||
*
|
||||
* Safety rules (see DECISIONS.md):
|
||||
* - Never send for a date in the past. A schedule whose next_run_date is
|
||||
* before today (a missed prior day, e.g. after an outage or on a schedule
|
||||
* the user just reactivated) is rolled forward to its next future
|
||||
* occurrence WITHOUT generating anything.
|
||||
* - Paused schedules are ignored (status filter). Existing schedules were
|
||||
* paused on deploy so nothing resumes sending behind the user's back.
|
||||
*
|
||||
* Each schedule runs in isolated try/catch so a failure on one doesn't block
|
||||
* the rest. On a successful send: bump next_run_date to next month, set
|
||||
* last_run_at/last_invoice_id/generated_count. On failure: leave next_run_date
|
||||
* alone so a later run retries.
|
||||
*/
|
||||
export const GET = withCronContext('cron.recurring_invoices', async (_request, ctx) => {
|
||||
const supabase = createServiceClient()
|
||||
|
||||
const today = new Date()
|
||||
const todayIso = today.toISOString().slice(0, 10)
|
||||
const now = new Date()
|
||||
// "Today" and the current hour in Swedish local time. Date math for rolling
|
||||
// next_run_date uses a UTC-midnight Date of the Stockholm calendar day so it
|
||||
// stays consistent with the Stockholm day even across the UTC boundary.
|
||||
const { date: todayStockholm, hour: currentHour } = getStockholmDateHour(now)
|
||||
const stockholmToday = new Date(`${todayStockholm}T00:00:00Z`)
|
||||
|
||||
const { data: due, error } = await supabase
|
||||
.from('recurring_invoice_schedules')
|
||||
.select('*, items:recurring_invoice_schedule_items(*)')
|
||||
.eq('status', 'active')
|
||||
.lte('next_run_date', todayIso)
|
||||
.lte('next_run_date', todayStockholm)
|
||||
|
||||
if (error) {
|
||||
ctx.log.error('failed to load due recurring schedules', error)
|
||||
@@ -49,7 +65,8 @@ export const GET = withCronContext('cron.recurring_invoices', async (_request, c
|
||||
|
||||
ctx.log.info('recurring invoice cron starting', {
|
||||
dueCount: schedules.length,
|
||||
todayIso,
|
||||
todayStockholm,
|
||||
currentHour,
|
||||
})
|
||||
|
||||
type RunResult = {
|
||||
@@ -65,12 +82,49 @@ export const GET = withCronContext('cron.recurring_invoices', async (_request, c
|
||||
const results: RunResult[] = []
|
||||
|
||||
const summary = await ctx.forEach('schedule', schedules, async (schedule, itemCtx) => {
|
||||
// Idempotency: skip if already ran today. Protects against cron retries
|
||||
// within the same UTC day; cheaper than a Postgres advisory lock and the
|
||||
// window we're protecting (one row, ~seconds) is tiny.
|
||||
// 1. Missed a prior day: never send for the past. Roll forward to the next
|
||||
// future occurrence (this month if the day hasn't passed, else next
|
||||
// month) without generating an invoice. This also protects the
|
||||
// reactivation path: turning a long-paused schedule back on rolls it to
|
||||
// its next date rather than firing a stale one immediately.
|
||||
if (schedule.next_run_date < todayStockholm) {
|
||||
const rolledNext = computeInitialRunDate(stockholmToday, schedule.day_of_month)
|
||||
const { error: rollError } = await supabase
|
||||
.from('recurring_invoice_schedules')
|
||||
.update({ next_run_date: rolledNext })
|
||||
.eq('id', schedule.id)
|
||||
.eq('company_id', schedule.company_id)
|
||||
if (rollError) {
|
||||
throw new Error(`failed to roll stale schedule forward: ${rollError.message}`)
|
||||
}
|
||||
itemCtx.log.info('stale schedule rolled forward without sending', {
|
||||
from: schedule.next_run_date,
|
||||
to: rolledNext,
|
||||
})
|
||||
results.push({
|
||||
scheduleId: schedule.id,
|
||||
skipped: true,
|
||||
skipReason: 'stale_rolled_forward',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Due today but the chosen Stockholm hour hasn't arrived yet. A later
|
||||
// run this same day will pick it up (send_hour <= currentHour).
|
||||
if (currentHour < schedule.send_hour) {
|
||||
results.push({
|
||||
scheduleId: schedule.id,
|
||||
skipped: true,
|
||||
skipReason: 'hour_not_reached',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 3. Idempotency fast-path: skip if the row we loaded already shows a run
|
||||
// today (cheap check against the batch, no write).
|
||||
if (schedule.last_run_at) {
|
||||
const lastRunDay = schedule.last_run_at.slice(0, 10)
|
||||
if (lastRunDay >= todayIso) {
|
||||
const lastRunDay = getStockholmDateHour(new Date(schedule.last_run_at)).date
|
||||
if (lastRunDay >= todayStockholm) {
|
||||
itemCtx.log.info('schedule already ran today; skipping')
|
||||
results.push({
|
||||
scheduleId: schedule.id,
|
||||
@@ -81,14 +135,60 @@ export const GET = withCronContext('cron.recurring_invoices', async (_request, c
|
||||
}
|
||||
}
|
||||
|
||||
const result = await executeRecurringSchedule(supabase, schedule, today)
|
||||
// 4. Atomic claim. Two hourly cron invocations can overlap (an hour-boundary
|
||||
// retry, or a manual re-trigger) and both read the same stale
|
||||
// last_run_at from the batch above, so the read-only check in step 3
|
||||
// can't by itself stop a double-send. Compare-and-set last_run_at from
|
||||
// the exact value we read to `now`: Postgres row-locking serialises the
|
||||
// two writers, so only the one whose WHERE still matches the old value
|
||||
// flips the row and gets it back; the loser matches zero rows and skips.
|
||||
// Cheaper than a Postgres advisory lock, and it closes the window for the
|
||||
// whole batch execution, not just a single row.
|
||||
const claimTs = now.toISOString()
|
||||
const claimBase = supabase
|
||||
.from('recurring_invoice_schedules')
|
||||
.update({ last_run_at: claimTs })
|
||||
.eq('id', schedule.id)
|
||||
.eq('company_id', schedule.company_id)
|
||||
const claimGated = schedule.last_run_at
|
||||
? claimBase.eq('last_run_at', schedule.last_run_at)
|
||||
: claimBase.is('last_run_at', null)
|
||||
const { data: claimed, error: claimError } = await claimGated.select('id')
|
||||
if (claimError) {
|
||||
throw new Error(`failed to claim schedule for today: ${claimError.message}`)
|
||||
}
|
||||
if (!claimed || (claimed as unknown[]).length === 0) {
|
||||
itemCtx.log.info('schedule claimed by a concurrent cron run; skipping')
|
||||
results.push({
|
||||
scheduleId: schedule.id,
|
||||
skipped: true,
|
||||
skipReason: 'claimed_by_concurrent_run',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const nextRunDate = computeNextRunDate(today, schedule.day_of_month)
|
||||
// 5. Spawn the invoice. If it throws after we claimed, release the claim
|
||||
// (restore the prior last_run_at) so a later cron retries today rather
|
||||
// than treating the row as already run.
|
||||
let result: Awaited<ReturnType<typeof executeRecurringSchedule>>
|
||||
try {
|
||||
result = await executeRecurringSchedule(supabase, schedule, now)
|
||||
} catch (err) {
|
||||
await supabase
|
||||
.from('recurring_invoice_schedules')
|
||||
.update({ last_run_at: schedule.last_run_at })
|
||||
.eq('id', schedule.id)
|
||||
.eq('company_id', schedule.company_id)
|
||||
.eq('last_run_at', claimTs)
|
||||
throw err
|
||||
}
|
||||
|
||||
const nextRunDate = computeNextRunDate(stockholmToday, schedule.day_of_month)
|
||||
const { error: updateError } = await supabase
|
||||
.from('recurring_invoice_schedules')
|
||||
.update({
|
||||
next_run_date: nextRunDate,
|
||||
last_run_at: today.toISOString(),
|
||||
last_run_at: now.toISOString(),
|
||||
last_invoice_id: result.invoiceId,
|
||||
last_run_warning: result.warning,
|
||||
generated_count: schedule.generated_count + 1,
|
||||
|
||||
@@ -64,7 +64,7 @@ export const POST = withRouteContext(
|
||||
// 404 than the FK violation we'd otherwise get).
|
||||
const { data: customer } = await supabase
|
||||
.from('customers')
|
||||
.select('id')
|
||||
.select('id, email')
|
||||
.eq('id', input.customer_id)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
@@ -76,6 +76,19 @@ export const POST = withRouteContext(
|
||||
)
|
||||
}
|
||||
|
||||
// auto_send without a customer email would silently degrade to a monthly
|
||||
// draft + warning at cron time. Reject it up front instead; the dialog
|
||||
// blocks this client-side, so this is the API backstop.
|
||||
if (input.auto_send && !customer.email) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Customer has no email address: automatic sending requires one',
|
||||
type: 'validation_error',
|
||||
},
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
const nextRunDate = computeInitialRunDate(
|
||||
new Date(),
|
||||
input.day_of_month,
|
||||
@@ -90,6 +103,7 @@ export const POST = withRouteContext(
|
||||
customer_id: input.customer_id,
|
||||
name: input.name,
|
||||
day_of_month: input.day_of_month,
|
||||
send_hour: input.send_hour,
|
||||
payment_terms_days: input.payment_terms_days,
|
||||
currency: input.currency,
|
||||
your_reference: input.your_reference ?? null,
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { CreateSelfBillingInvoiceSchema } from '@/lib/api/schemas'
|
||||
import { getVatRules, getAvailableVatRates } from '@/lib/invoices/vat-rules'
|
||||
import { fetchExchangeRate, convertToSEK } from '@/lib/currency/riksbanken'
|
||||
import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import type { EntityType, Invoice } from '@/types'
|
||||
import {
|
||||
createSelfBilledSaleInvoice,
|
||||
type SelfBilledSaleInput,
|
||||
type SelfBilledSaleFailure,
|
||||
} from '@/lib/invoices/self-billed-sale'
|
||||
import type { Logger } from '@/lib/logger'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
@@ -26,12 +26,54 @@ ensureInitialized()
|
||||
* (BFL 5 kap 6§). Enforced by the invoices_self_billed_numbering constraint.
|
||||
* - There is no send step. Under faktureringsmetoden (accrual) we book the
|
||||
* registration entry here. Under kontantmetoden (cash) we leave it unbooked
|
||||
* until payment (identical to a normal invoice) and the existing mark-paid
|
||||
* flow books the cash entry then.
|
||||
* until payment and the existing mark-paid flow books the cash entry then.
|
||||
*
|
||||
* Payment is handled by the existing flows: the row is created with status
|
||||
* 'sent', so "Markera som betald" / bank matching work unchanged.
|
||||
* The booking logic lives in lib/invoices/self-billed-sale.ts, shared with the
|
||||
* public v1 invoice-create endpoint (POST /api/v1/.../invoices with
|
||||
* is_self_billed=true), so the dashboard and the API can never drift.
|
||||
*/
|
||||
function mapSelfBilledFailure(failure: SelfBilledSaleFailure, log: Logger, requestId?: string) {
|
||||
switch (failure.code) {
|
||||
case 'customer_not_found':
|
||||
return errorResponseFromCode('INVOICE_CUSTOMER_NOT_FOUND', log, {
|
||||
requestId,
|
||||
details: { customerId: failure.customerId },
|
||||
})
|
||||
case 'vat_rule_violation':
|
||||
return errorResponseFromCode('INVOICE_CREATE_VAT_RULE_VIOLATION', log, {
|
||||
requestId,
|
||||
details: {
|
||||
attemptedRate: failure.attemptedRate,
|
||||
allowedRates: failure.allowedRates,
|
||||
customerType: failure.customerType,
|
||||
},
|
||||
})
|
||||
case 'fx_rate_unavailable':
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Kunde inte hämta växelkurs för ${failure.currency} på fakturadatumet (${failure.invoiceDate}). Försök igen senare.`,
|
||||
type: 'validation_error',
|
||||
},
|
||||
{ status: 400 },
|
||||
)
|
||||
case 'no_fiscal_period':
|
||||
return NextResponse.json(
|
||||
{ error: 'Ingen öppen bokföringsperiod för fakturadatumet', type: 'validation_error' },
|
||||
{ status: 400 },
|
||||
)
|
||||
case 'insert_failed':
|
||||
return errorResponseFromCode('INVOICE_CREATE_INSERT_FAILED', log, {
|
||||
requestId,
|
||||
details: { stage: failure.stage, pgCode: failure.pgCode, pgMessage: failure.pgMessage },
|
||||
})
|
||||
case 'items_failed':
|
||||
return errorResponseFromCode('INVOICE_CREATE_ITEMS_FAILED', log, {
|
||||
requestId,
|
||||
details: { pgCode: failure.pgCode, pgMessage: failure.pgMessage },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const POST = withRouteContext(
|
||||
'invoice.self_billed.create',
|
||||
async (request, ctx) => {
|
||||
@@ -61,251 +103,31 @@ export const POST = withRouteContext(
|
||||
}
|
||||
const input = parsed.data
|
||||
|
||||
// The issuer of a self-billing invoice is, in our books, the customer we
|
||||
// sold to. Require an existing customer row so VAT rules + reporting work.
|
||||
// Project only the fields used below (data minimisation, GDPR Art. 25 /
|
||||
// SOC 2 CC6.3): VAT treatment derivation and the verifikat description.
|
||||
const { data: customer, error: customerError } = await supabase
|
||||
.from('customers')
|
||||
.select('id, name, customer_type, vat_number_validated')
|
||||
.eq('id', input.customer_id)
|
||||
.eq('company_id', companyId!)
|
||||
.single()
|
||||
|
||||
if (customerError || !customer) {
|
||||
return errorResponseFromCode('INVOICE_CUSTOMER_NOT_FOUND', log, {
|
||||
requestId,
|
||||
details: { customerId: input.customer_id },
|
||||
})
|
||||
const selfBilledInput: SelfBilledSaleInput = {
|
||||
customer_id: input.customer_id,
|
||||
external_invoice_number: input.external_invoice_number,
|
||||
self_billing_agreement_ref: input.self_billing_agreement_ref ?? null,
|
||||
invoice_date: input.invoice_date,
|
||||
received_date: input.received_date,
|
||||
due_date: input.due_date,
|
||||
currency: input.currency,
|
||||
notes: input.notes ?? null,
|
||||
items: input.items.map((it) => ({
|
||||
description: it.description,
|
||||
quantity: it.quantity,
|
||||
unit: it.unit,
|
||||
unit_price: it.unit_price,
|
||||
vat_rate: it.vat_rate,
|
||||
})),
|
||||
}
|
||||
|
||||
// VAT treatment is driven by who the customer is (domestic / EU reverse
|
||||
// charge / export), exactly like an own-issued invoice.
|
||||
const vatRules = getVatRules(customer.customer_type, customer.vat_number_validated)
|
||||
const availableRates = getAvailableVatRates(customer.customer_type, customer.vat_number_validated)
|
||||
const allowedRates = new Set(availableRates.map((r) => r.rate))
|
||||
|
||||
let vatAmount = 0
|
||||
for (const item of input.items) {
|
||||
const itemRate = item.vat_rate !== undefined ? item.vat_rate : vatRules.rate
|
||||
if (!allowedRates.has(itemRate)) {
|
||||
return errorResponseFromCode('INVOICE_CREATE_VAT_RULE_VIOLATION', log, {
|
||||
requestId,
|
||||
details: {
|
||||
attemptedRate: itemRate,
|
||||
allowedRates: Array.from(allowedRates),
|
||||
customerType: customer.customer_type,
|
||||
},
|
||||
})
|
||||
}
|
||||
const lineTotal = item.quantity * item.unit_price
|
||||
vatAmount += roundOre((lineTotal * itemRate) / 100)
|
||||
try {
|
||||
const result = await createSelfBilledSaleInvoice(supabase, companyId!, user.id, selfBilledInput)
|
||||
if (!result.ok) return mapSelfBilledFailure(result.failure, log, requestId)
|
||||
return NextResponse.json({ data: result.invoice })
|
||||
} catch (err) {
|
||||
return errorResponse(err, log, { requestId })
|
||||
}
|
||||
|
||||
const subtotal = input.items.reduce((sum, item) => sum + item.quantity * item.unit_price, 0)
|
||||
const total = roundOre(subtotal + vatAmount)
|
||||
|
||||
const uniqueRates = new Set(input.items.map((item) => item.vat_rate ?? vatRules.rate))
|
||||
const isMixedRate = uniqueRates.size > 1
|
||||
|
||||
// Foreign currency: convert using the rate on the INVOICE date (ML 7 kap 7§),
|
||||
// not today's rate.
|
||||
let exchangeRate: number | null = null
|
||||
let exchangeRateDate: string | null = null
|
||||
let subtotalSek: number | null = null
|
||||
let vatAmountSek: number | null = null
|
||||
let totalSek: number | null = null
|
||||
if (input.currency !== 'SEK') {
|
||||
const rateData = await fetchExchangeRate(input.currency, new Date(input.invoice_date))
|
||||
if (!rateData) {
|
||||
// No FX rate for the invoice date: refuse rather than letting the
|
||||
// booking fall through to resolveSekAmount's legacy 1:1 fallback, which
|
||||
// would treat e.g. 1 000 USD as 1 000 SEK and commit a balanced but
|
||||
// silently wrong-magnitude verifikat. ML 7 kap 7§ requires the
|
||||
// invoice-date rate; we never substitute today's. The user can retry
|
||||
// once the rate is published.
|
||||
log.warn('self-billed invoice rejected: no FX rate for invoice date', {
|
||||
currency: input.currency,
|
||||
invoiceDate: input.invoice_date,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Kunde inte hämta växelkurs för ${input.currency} på fakturadatumet (${input.invoice_date}). Försök igen senare.`,
|
||||
type: 'validation_error',
|
||||
},
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
exchangeRate = rateData.rate
|
||||
exchangeRateDate = rateData.date
|
||||
subtotalSek = convertToSEK(subtotal, exchangeRate)
|
||||
vatAmountSek = convertToSEK(vatAmount, exchangeRate)
|
||||
totalSek = convertToSEK(total, exchangeRate)
|
||||
}
|
||||
|
||||
const { data: invoice, error: invoiceError } = await supabase
|
||||
.from('invoices')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
company_id: companyId,
|
||||
customer_id: input.customer_id,
|
||||
// No own number: the counterparty's number lives in external_invoice_number.
|
||||
invoice_number: null,
|
||||
is_self_billed: true,
|
||||
external_invoice_number: input.external_invoice_number,
|
||||
self_billing_agreement_ref: input.self_billing_agreement_ref ?? null,
|
||||
received_date: input.received_date,
|
||||
invoice_date: input.invoice_date,
|
||||
due_date: input.due_date,
|
||||
// Booked + awaiting/with payment: never a draft, so it shows in the AR
|
||||
// ledger and is payable via the existing mark-paid / matching flows.
|
||||
status: 'sent',
|
||||
currency: input.currency,
|
||||
exchange_rate: exchangeRate,
|
||||
exchange_rate_date: exchangeRateDate,
|
||||
subtotal,
|
||||
subtotal_sek: subtotalSek,
|
||||
vat_amount: vatAmount,
|
||||
vat_amount_sek: vatAmountSek,
|
||||
total,
|
||||
total_sek: totalSek,
|
||||
remaining_amount: total,
|
||||
vat_treatment: vatRules.treatment,
|
||||
vat_rate: isMixedRate ? null : (uniqueRates.values().next().value ?? vatRules.rate),
|
||||
moms_ruta: vatRules.momsRuta,
|
||||
reverse_charge_text: vatRules.reverseChargeText || null,
|
||||
notes: input.notes,
|
||||
document_type: 'invoice',
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (invoiceError || !invoice) {
|
||||
log.error('self-billed invoice insert failed', invoiceError)
|
||||
return errorResponseFromCode('INVOICE_CREATE_INSERT_FAILED', log, {
|
||||
requestId,
|
||||
details: { pgCode: invoiceError?.code, pgMessage: invoiceError?.message },
|
||||
})
|
||||
}
|
||||
|
||||
const items = input.items.map((item, index) => {
|
||||
const itemRate = item.vat_rate !== undefined ? item.vat_rate : vatRules.rate
|
||||
const lineTotal = item.quantity * item.unit_price
|
||||
return {
|
||||
invoice_id: invoice.id,
|
||||
sort_order: index,
|
||||
description: item.description,
|
||||
quantity: item.quantity,
|
||||
unit: item.unit,
|
||||
unit_price: item.unit_price,
|
||||
line_total: lineTotal,
|
||||
vat_rate: itemRate,
|
||||
vat_amount: roundOre((lineTotal * itemRate) / 100),
|
||||
}
|
||||
})
|
||||
|
||||
const { error: itemsError } = await supabase.from('invoice_items').insert(items)
|
||||
if (itemsError) {
|
||||
// The item insert failed, so nothing was written there: just remove the
|
||||
// orphaned invoice header.
|
||||
await supabase.from('invoices').delete().eq('id', invoice.id)
|
||||
log.error('self-billed invoice items insert failed; rolled back', itemsError, { invoiceId: invoice.id })
|
||||
return errorResponseFromCode('INVOICE_CREATE_ITEMS_FAILED', log, {
|
||||
requestId,
|
||||
details: { pgCode: itemsError.code, pgMessage: itemsError.message },
|
||||
})
|
||||
}
|
||||
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('accounting_method, entity_type')
|
||||
.eq('company_id', companyId!)
|
||||
.single()
|
||||
const accountingMethod = settings?.accounting_method || 'accrual'
|
||||
const entityType = (settings?.entity_type as EntityType) || 'enskild_firma'
|
||||
|
||||
const { data: completeInvoice } = await supabase
|
||||
.from('invoices')
|
||||
.select('*, customer:customers(*), items:invoice_items(*)')
|
||||
.eq('id', invoice.id)
|
||||
.single()
|
||||
|
||||
// Faktureringsmetoden: book the registration entry now (Debit 1510, Credit
|
||||
// 30xx + 26xx). Kontantmetoden: leave unbooked until payment, exactly like a
|
||||
// normal invoice: the mark-paid flow books the cash entry then.
|
||||
if (accountingMethod === 'accrual') {
|
||||
if (!completeInvoice) {
|
||||
// The row was inserted but the re-fetch came back empty (transient DB
|
||||
// issue). Roll back rather than crash on a null cast inside the engine,
|
||||
// and surface it as a fetch failure, not an opaque booking error.
|
||||
await supabase.from('invoices').delete().eq('id', invoice.id)
|
||||
log.error('self-billed invoice re-fetch returned no row before booking; rolled back', undefined, {
|
||||
invoiceId: invoice.id,
|
||||
})
|
||||
return errorResponseFromCode('INVOICE_CREATE_INSERT_FAILED', log, {
|
||||
requestId,
|
||||
details: { stage: 'refetch_before_booking' },
|
||||
})
|
||||
}
|
||||
try {
|
||||
const journalEntry = await createInvoiceJournalEntry(
|
||||
supabase,
|
||||
companyId!,
|
||||
user.id,
|
||||
completeInvoice as Invoice,
|
||||
entityType,
|
||||
customer.name,
|
||||
{ descriptionPrefix: 'Självfaktura', numberOverride: input.external_invoice_number },
|
||||
)
|
||||
if (!journalEntry) {
|
||||
// No open fiscal period for the invoice date: roll the row back so we
|
||||
// never leave an unbooked self-billing sale sitting as 'sent'.
|
||||
await supabase.from('invoices').delete().eq('id', invoice.id)
|
||||
return NextResponse.json(
|
||||
{ error: 'Ingen öppen bokföringsperiod för fakturadatumet', type: 'validation_error' },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
const { error: linkError } = await supabase
|
||||
.from('invoices')
|
||||
.update({ journal_entry_id: journalEntry.id })
|
||||
.eq('id', invoice.id)
|
||||
.eq('company_id', companyId!)
|
||||
if (linkError) {
|
||||
// The verifikat is already committed (immutable): don't roll it back
|
||||
// over a failed convenience link. Log loudly: this is the exact write
|
||||
// that silently no-ops if the journal_entry_id column is ever missing
|
||||
// again (it was absent in prod for months before 20260613100000).
|
||||
log.error('self-billed invoice booked but journal_entry_id link failed', linkError, {
|
||||
invoiceId: invoice.id,
|
||||
journalEntryId: journalEntry.id,
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
await supabase.from('invoices').delete().eq('id', invoice.id)
|
||||
log.error('failed to book self-billed invoice; rolled back', err as Error, { invoiceId: invoice.id })
|
||||
return errorResponse(err, log, { requestId })
|
||||
}
|
||||
}
|
||||
|
||||
const { data: finalInvoice } = await supabase
|
||||
.from('invoices')
|
||||
.select('*, customer:customers(*), items:invoice_items(*)')
|
||||
.eq('id', invoice.id)
|
||||
.single()
|
||||
|
||||
// The invoice is committed (and, under accrual, booked) by this point. If the
|
||||
// final re-fetch comes back empty under transient load, fall back to the
|
||||
// shapes we already hold so the 200 always carries a usable id; otherwise
|
||||
// the client's redirect to /invoices/{id} would throw on a null result.
|
||||
const responseInvoice = (finalInvoice ?? completeInvoice ?? invoice) as Invoice
|
||||
|
||||
await eventBus.emit({
|
||||
type: 'invoice.created',
|
||||
payload: { invoice: responseInvoice, companyId: companyId!, userId: user.id },
|
||||
})
|
||||
|
||||
return NextResponse.json({ data: responseInvoice })
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
|
||||
@@ -21,6 +21,7 @@ vi.mock('@/lib/salary/payslips/build-payslip-data', () => ({
|
||||
import { GET } from '../route'
|
||||
import { createServiceClientNoCookies } from '@/lib/auth/api-keys'
|
||||
import { resolvePayslipToken } from '@/lib/salary/payslips/links'
|
||||
import { buildPayslipData } from '@/lib/salary/payslips/build-payslip-data'
|
||||
|
||||
// Distinct valid-format tokens per test — the route's rate-limit map is
|
||||
// module-level state shared across this file.
|
||||
@@ -96,6 +97,7 @@ describe('GET /api/payslip/[token]/pdf', () => {
|
||||
{ data: { id: 'run-1', period_year: 2026, period_month: 6, payment_date: '2026-06-25' } },
|
||||
{ data: { employee: { first_name: 'Anna', last_name: 'A', personnummer: 'enc' }, line_items: [] } },
|
||||
{ data: { name: 'Bolaget AB', org_number: null } },
|
||||
{ data: { company_name: 'Ny Firma AB' } },
|
||||
])
|
||||
|
||||
const response = await GET(
|
||||
@@ -107,5 +109,44 @@ describe('GET /api/payslip/[token]/pdf', () => {
|
||||
expect(response.headers.get('Content-Type')).toBe('application/pdf')
|
||||
expect(response.headers.get('Cache-Control')).toBe('no-store')
|
||||
expect(response.headers.get('Content-Disposition')).toContain('lonespec_Test_2026-06.pdf')
|
||||
// Employer name follows the current company_settings.company_name, not the
|
||||
// frozen onboarding companies.name.
|
||||
expect(vi.mocked(buildPayslipData)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ company: { name: 'Ny Firma AB', org_number: null } }),
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to companies.name when company_settings has no company_name', async () => {
|
||||
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
||||
vi.mocked(createServiceClientNoCookies).mockReturnValue(supabase as never)
|
||||
vi.mocked(resolvePayslipToken).mockResolvedValue({
|
||||
ok: true,
|
||||
link: {
|
||||
id: 'link-1',
|
||||
company_id: 'company-1',
|
||||
salary_run_id: 'run-1',
|
||||
employee_id: 'emp-1',
|
||||
token_hash: 'h',
|
||||
expires_at: new Date(Date.now() + 60_000).toISOString(),
|
||||
revoked_at: null,
|
||||
access_count: 0,
|
||||
},
|
||||
})
|
||||
enqueueMany([
|
||||
{ data: { id: 'run-1', period_year: 2026, period_month: 6, payment_date: '2026-06-25' } },
|
||||
{ data: { employee: { first_name: 'Anna', last_name: 'A', personnummer: 'enc' }, line_items: [] } },
|
||||
{ data: { name: 'Bolaget AB', org_number: '5560000000' } },
|
||||
{ data: { company_name: null } },
|
||||
])
|
||||
|
||||
const response = await GET(
|
||||
createMockRequest('/api/payslip/t/pdf'),
|
||||
createMockRouteParams({ token: token('G') }),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(vi.mocked(buildPayslipData)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ company: { name: 'Bolaget AB', org_number: '5560000000' } }),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -62,7 +62,7 @@ export async function GET(
|
||||
|
||||
const { link } = resolved
|
||||
|
||||
const [{ data: run }, { data: sre }, { data: company }] = await Promise.all([
|
||||
const [{ data: run }, { data: sre }, { data: company }, { data: settings }] = await Promise.all([
|
||||
serviceClient
|
||||
.from('salary_runs')
|
||||
.select('*')
|
||||
@@ -80,6 +80,11 @@ export async function GET(
|
||||
.select('name, org_number')
|
||||
.eq('id', link.company_id)
|
||||
.single(),
|
||||
serviceClient
|
||||
.from('company_settings')
|
||||
.select('company_name')
|
||||
.eq('company_id', link.company_id)
|
||||
.maybeSingle(),
|
||||
])
|
||||
|
||||
if (!run || !sre || !company) {
|
||||
@@ -97,7 +102,14 @@ export async function GET(
|
||||
bank_account_number: string | null
|
||||
}
|
||||
|
||||
const data = buildPayslipData({ run, sre, employee: emp, company })
|
||||
// Employer name follows the current company_settings.company_name, falling
|
||||
// back to the frozen onboarding companies.name.
|
||||
const data = buildPayslipData({
|
||||
run,
|
||||
sre,
|
||||
employee: emp,
|
||||
company: { name: settings?.company_name || company.name, org_number: company.org_number },
|
||||
})
|
||||
const fileName = payslipFileName(run, emp)
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
const mockSupabase = {
|
||||
auth: { getUser: vi.fn() },
|
||||
from: vi.fn(),
|
||||
}
|
||||
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: () => Promise.resolve(mockSupabase),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/require-auth', () => ({
|
||||
requireAuth: vi.fn(),
|
||||
}))
|
||||
|
||||
// Stub the PDF renderer so the test never spins up real PDF layout. Provide the
|
||||
// primitives the template imports at module load (StyleSheet.create runs then).
|
||||
vi.mock('@react-pdf/renderer', () => ({
|
||||
renderToBuffer: vi.fn().mockResolvedValue(Buffer.from('%PDF-1.4 test')),
|
||||
StyleSheet: { create: (s: unknown) => s },
|
||||
Document: (p: unknown) => p,
|
||||
Page: (p: unknown) => p,
|
||||
Text: (p: unknown) => p,
|
||||
View: (p: unknown) => p,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/reports/vat-declaration', () => ({
|
||||
calculateVatDeclaration: vi.fn(),
|
||||
formatPeriodLabel: vi.fn(() => 'Kvartal 3 2026'),
|
||||
}))
|
||||
|
||||
import { GET } from '../route'
|
||||
import { requireAuth } from '@/lib/auth/require-auth'
|
||||
import { calculateVatDeclaration } from '@/lib/reports/vat-declaration'
|
||||
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
|
||||
function companySettingsQuery(data: unknown) {
|
||||
return {
|
||||
select: vi.fn().mockReturnThis(),
|
||||
eq: vi.fn().mockReturnThis(),
|
||||
single: vi.fn().mockResolvedValue({ data, error: null }),
|
||||
}
|
||||
}
|
||||
|
||||
function makeDeclaration() {
|
||||
return {
|
||||
period: { start: '2026-07-01', end: '2026-09-30' },
|
||||
rutor: {
|
||||
ruta05: 100000, ruta06: 0, ruta07: 0, ruta08: 0,
|
||||
ruta10: 25000, ruta11: 0, ruta12: 0,
|
||||
ruta20: 0, ruta21: 0, ruta22: 0, ruta23: 0, ruta24: 0,
|
||||
ruta30: 0, ruta31: 0, ruta32: 0,
|
||||
ruta35: 0, ruta36: 0, ruta37: 0, ruta38: 0, ruta39: 0, ruta40: 0, ruta41: 0, ruta42: 0,
|
||||
ruta48: 3200,
|
||||
ruta49: 21800,
|
||||
ruta50: 0, ruta60: 0, ruta61: 0, ruta62: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('GET /api/reports/vat-declaration/pdf', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(requireAuth).mockResolvedValue({
|
||||
user: mockUser as never,
|
||||
supabase: mockSupabase as never,
|
||||
error: null,
|
||||
})
|
||||
mockSupabase.from.mockReturnValue(
|
||||
companySettingsQuery({
|
||||
company_name: 'Testbolaget AB',
|
||||
org_number: '5566778899',
|
||||
vat_number: 'SE556677889901',
|
||||
accounting_method: 'accrual',
|
||||
}),
|
||||
)
|
||||
vi.mocked(calculateVatDeclaration).mockResolvedValue(makeDeclaration() as never)
|
||||
})
|
||||
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
vi.mocked(requireAuth).mockResolvedValue({
|
||||
user: null as never,
|
||||
supabase: mockSupabase as never,
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
const req = new Request(
|
||||
'http://localhost/api/reports/vat-declaration/pdf?periodType=quarterly&year=2026&period=3',
|
||||
)
|
||||
const res = await GET(req, { params: Promise.resolve({}) })
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 400 when period params are missing', async () => {
|
||||
const req = new Request('http://localhost/api/reports/vat-declaration/pdf')
|
||||
const res = await GET(req, { params: Promise.resolve({}) })
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 404 when company settings are missing', async () => {
|
||||
mockSupabase.from.mockReturnValue(companySettingsQuery(null))
|
||||
const req = new Request(
|
||||
'http://localhost/api/reports/vat-declaration/pdf?periodType=quarterly&year=2026&period=3',
|
||||
)
|
||||
const res = await GET(req, { params: Promise.resolve({}) })
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('happy path: returns a PDF attachment', async () => {
|
||||
const req = new Request(
|
||||
'http://localhost/api/reports/vat-declaration/pdf?periodType=quarterly&year=2026&period=3',
|
||||
)
|
||||
const res = await GET(req, { params: Promise.resolve({}) })
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers.get('Content-Type')).toBe('application/pdf')
|
||||
expect(res.headers.get('Content-Disposition')).toContain('momsdeklaration-2026-07-01--2026-09-30.pdf')
|
||||
expect(calculateVatDeclaration).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { renderToBuffer } from '@react-pdf/renderer'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import {
|
||||
calculateVatDeclaration,
|
||||
formatPeriodLabel,
|
||||
} from '@/lib/reports/vat-declaration'
|
||||
import { buildManualFilingRows } from '@/lib/reports/vat-manual-filing'
|
||||
import { VatDeclarationPDF } from '@/lib/reports/vat-declaration-pdf-template'
|
||||
import type { VatPeriodType, AccountingMethod, CompanySettings } from '@/types'
|
||||
|
||||
/**
|
||||
* Momsdeklaration PDF for manual filing at skatteverket.se. The declaration is
|
||||
* computed purely from the bookkeeping (no Skatteverket connection needed);
|
||||
* this is a reading/record copy in hela kronor, not a file that is uploaded to
|
||||
* Skatteverket. See lib/reports/vat-declaration-pdf-template.tsx.
|
||||
*/
|
||||
export const GET = withRouteContext(
|
||||
'reports.vat-declaration.pdf',
|
||||
async (request, { supabase, companyId }) => {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const periodType = searchParams.get('periodType') as VatPeriodType | null
|
||||
const yearStr = searchParams.get('year')
|
||||
const periodStr = searchParams.get('period')
|
||||
// Yearly = räkenskapsår (see the main vat-declaration route); ignored for
|
||||
// monthly/quarterly.
|
||||
const fiscalPeriodId = searchParams.get('fiscal_period_id') ?? undefined
|
||||
|
||||
if (!periodType || !yearStr || !periodStr) {
|
||||
return NextResponse.json(
|
||||
{ error: 'periodType, year, and period are required' },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
if (!['monthly', 'quarterly', 'yearly'].includes(periodType)) {
|
||||
return NextResponse.json({ error: 'Invalid periodType' }, { status: 400 })
|
||||
}
|
||||
const year = parseInt(yearStr, 10)
|
||||
const period = parseInt(periodStr, 10)
|
||||
if (isNaN(year) || isNaN(period)) {
|
||||
return NextResponse.json({ error: 'Invalid year or period' }, { status: 400 })
|
||||
}
|
||||
|
||||
const { data: companyRow } = await supabase
|
||||
.from('company_settings')
|
||||
.select('*')
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (!companyRow) {
|
||||
return NextResponse.json({ error: 'Företagsinställningar saknas' }, { status: 404 })
|
||||
}
|
||||
|
||||
const accountingMethod = (companyRow.accounting_method as AccountingMethod) || 'accrual'
|
||||
|
||||
const declaration = await calculateVatDeclaration(
|
||||
supabase,
|
||||
companyId,
|
||||
periodType,
|
||||
year,
|
||||
period,
|
||||
accountingMethod,
|
||||
{ fiscalPeriodId },
|
||||
)
|
||||
|
||||
const rows = buildManualFilingRows(declaration.rutor)
|
||||
|
||||
const pdfBuffer = await renderToBuffer(
|
||||
VatDeclarationPDF({
|
||||
rows,
|
||||
period: declaration.period,
|
||||
periodLabel: formatPeriodLabel(periodType, year, period),
|
||||
company: companyRow as CompanySettings,
|
||||
generatedAt: new Date().toISOString(),
|
||||
}),
|
||||
)
|
||||
|
||||
const filename = `momsdeklaration-${declaration.period.start}--${declaration.period.end}.pdf`
|
||||
return new Response(new Uint8Array(pdfBuffer), {
|
||||
headers: {
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||
},
|
||||
})
|
||||
},
|
||||
)
|
||||
@@ -7,6 +7,7 @@ import { requireCompanyId, getCompanyEntityType } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import { decryptPersonnummer, encryptPersonnummer, extractLast4, maskPersonnummer, validatePersonnummer } from '@/lib/salary/personnummer'
|
||||
import { isEmploymentTypeAllowedForEntity, EF_OWNER_EMPLOYMENT_ERROR } from '@/lib/salary/employment-rules'
|
||||
import { validateEmployeeBankAccount } from '@/lib/salary/payment/bank-account'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
@@ -87,6 +88,21 @@ export async function PATCH(
|
||||
return NextResponse.json({ error: mergedErrors.join('. ') }, { status: 400 })
|
||||
}
|
||||
|
||||
// Validate bank details only when the caller actually changes them, so a
|
||||
// legacy employee with incomplete/free-text bank data (from before this
|
||||
// validation existed) can still be edited in unrelated ways. Validate the
|
||||
// merged pair so both-or-neither reflects the row's real end state.
|
||||
const clearingChanged =
|
||||
body.clearing_number !== undefined && body.clearing_number !== existing.clearing_number
|
||||
const accountChanged =
|
||||
body.bank_account_number !== undefined && body.bank_account_number !== existing.bank_account_number
|
||||
if (clearingChanged || accountChanged) {
|
||||
const bankIssues = validateEmployeeBankAccount(merged.clearing_number, merged.bank_account_number)
|
||||
if (bankIssues.length > 0) {
|
||||
return NextResponse.json({ error: bankIssues.map((i) => i.message).join('. ') }, { status: 400 })
|
||||
}
|
||||
}
|
||||
|
||||
// Only when the caller is changing employment_type: block setting an EF's
|
||||
// owner/board on payroll (mirrors the enforce_ef_no_owner_employee trigger,
|
||||
// which fires on UPDATE OF employment_type: so unrelated edits to any
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Regression test for the plaintext-personnummer crash.
|
||||
*
|
||||
* GET /api/salary/employees decrypts every employee's personnummer on read and
|
||||
* maps over the whole roster. A row whose personnummer was stored UNENCRYPTED
|
||||
* (a pre-fix v1 REST create, or a seed) used to throw
|
||||
* ERR_CRYPTO_INVALID_AUTH_TAG ("Invalid authentication tag length: 6") inside
|
||||
* the .map(), 500-ing the entire endpoint for the affected company. The decrypt
|
||||
* helper now passes a raw 12-digit value through unchanged, so a mixed
|
||||
* encrypted/plaintext table no longer takes the roster down.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}))
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
getCompanyEntityType: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/lib/supabase/server', () => ({ createClient: vi.fn() }))
|
||||
|
||||
import { GET } from '../route'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { encryptPersonnummer } from '@/lib/salary/personnummer'
|
||||
|
||||
const mockCreateClient = createClient as ReturnType<typeof vi.fn>
|
||||
|
||||
// Synthetic 12-digit values (year 1900 / 1902, zero suffix): obviously not
|
||||
// real birthdates. ISO A.5.34 / GDPR Art.5(1)(c): fixtures must not look like
|
||||
// production PII.
|
||||
const PLAINTEXT_PNR = '190001010000'
|
||||
const ENCRYPTED_PNR = encryptPersonnummer('190203040000')
|
||||
|
||||
function supabaseWithRows(rows: unknown[]) {
|
||||
const query: Record<string, unknown> = {}
|
||||
query.select = vi.fn(() => query)
|
||||
query.eq = vi.fn(() => query)
|
||||
query.order = vi.fn(() => Promise.resolve({ data: rows, error: null }))
|
||||
return {
|
||||
auth: { getUser: vi.fn().mockResolvedValue({ data: { user: { id: 'user-1' } } }) },
|
||||
from: vi.fn(() => query),
|
||||
}
|
||||
}
|
||||
|
||||
function req() {
|
||||
return new Request('https://x.test/api/salary/employees')
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('GET /api/salary/employees', () => {
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
mockCreateClient.mockResolvedValue({
|
||||
auth: { getUser: vi.fn().mockResolvedValue({ data: { user: null } }) },
|
||||
})
|
||||
|
||||
const res = await GET(req())
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('does not 500 on a mixed plaintext + encrypted roster; masks both', async () => {
|
||||
mockCreateClient.mockResolvedValue(
|
||||
supabaseWithRows([
|
||||
{ id: 'e1', last_name: 'A', personnummer: PLAINTEXT_PNR },
|
||||
{ id: 'e2', last_name: 'B', personnummer: ENCRYPTED_PNR },
|
||||
]),
|
||||
)
|
||||
|
||||
const res = await GET(req())
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
// Both rows masked birthdate-visible, last-4 hidden.
|
||||
expect(body.data[0].personnummer).toBe('19000101-XXXX')
|
||||
expect(body.data[1].personnummer).toBe('19020304-XXXX')
|
||||
// Neither the plaintext nor the stored ciphertext may leak.
|
||||
expect(JSON.stringify(body)).not.toContain(PLAINTEXT_PNR)
|
||||
expect(JSON.stringify(body)).not.toContain(ENCRYPTED_PNR)
|
||||
})
|
||||
})
|
||||
@@ -42,7 +42,7 @@ export async function GET(
|
||||
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('org_number, phone, email')
|
||||
.select('company_name, org_number, phone, email')
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
@@ -121,11 +121,14 @@ export async function GET(
|
||||
return NextResponse.json({ error: `Inga bokförda lönekörningar för ${yearNum}` }, { status: 404 })
|
||||
}
|
||||
|
||||
// Employer name on the KU10 follows the current company name
|
||||
// (company_settings.company_name), not the frozen onboarding companies.name.
|
||||
const companyName = settings?.company_name || company.name
|
||||
const companyData: KU10CompanyData = {
|
||||
orgNumber: (settings?.org_number || company.org_number || '').trim(),
|
||||
companyName: company.name,
|
||||
companyName,
|
||||
year: yearNum,
|
||||
contactName: (profile?.full_name || company.name || '').trim(),
|
||||
contactName: (profile?.full_name || companyName || '').trim(),
|
||||
contactPhone: (settings?.phone || '').trim(),
|
||||
contactEmail: (settings?.email || profile?.email || user.email || '').trim(),
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ export async function GET(
|
||||
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('bankgiro')
|
||||
.select('company_name, bankgiro')
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
@@ -105,7 +105,9 @@ export async function GET(
|
||||
}
|
||||
|
||||
const companyData: BgLbCompanyData = {
|
||||
name: company.name,
|
||||
// Sender name follows the current company name (company_settings.company_name),
|
||||
// not the frozen onboarding companies.name.
|
||||
name: settings.company_name || company.name,
|
||||
senderBankgiro: settings.bankgiro,
|
||||
}
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ export async function GET(
|
||||
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('iban, bic')
|
||||
.select('company_name, iban, bic')
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
@@ -95,7 +95,9 @@ export async function GET(
|
||||
}
|
||||
|
||||
const companyData: Pain001CompanyData = {
|
||||
name: company.name,
|
||||
// Sender name follows the current company name (company_settings.company_name),
|
||||
// not the frozen onboarding companies.name.
|
||||
name: settings.company_name || company.name,
|
||||
orgNumber: company.org_number || '',
|
||||
iban: settings.iban,
|
||||
bic: settings.bic,
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { createQueuedMockSupabase, createMockRequest, createMockRouteParams } from '@/tests/helpers'
|
||||
|
||||
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
|
||||
vi.mock('@/lib/supabase/server', () => ({ createClient: vi.fn() }))
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
getCompanyDisplayName: vi.fn().mockResolvedValue('Ny Firma AB'),
|
||||
}))
|
||||
vi.mock('@react-pdf/renderer', () => ({
|
||||
renderToBuffer: vi.fn(async () => Buffer.from('%PDF-fake')),
|
||||
}))
|
||||
vi.mock('@/lib/salary/pdf/payslip-template', () => ({ PayslipPDF: vi.fn(() => null) }))
|
||||
vi.mock('@/lib/salary/payslips/build-payslip-data', () => ({
|
||||
buildPayslipData: vi.fn(() => ({})),
|
||||
payslipFileName: vi.fn(() => 'lonespec_Test_2026-06.pdf'),
|
||||
}))
|
||||
|
||||
import { GET } from '../route'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { getCompanyDisplayName } from '@/lib/company/context'
|
||||
import { buildPayslipData } from '@/lib/salary/payslips/build-payslip-data'
|
||||
|
||||
const mockUser = { id: 'user-1' }
|
||||
|
||||
function mockClient(user: unknown) {
|
||||
const { supabase, enqueue, enqueueMany } = createQueuedMockSupabase()
|
||||
supabase.auth.getUser = vi.fn().mockResolvedValue({ data: { user }, error: null })
|
||||
vi.mocked(createClient).mockResolvedValue(supabase as never)
|
||||
return { supabase, enqueue, enqueueMany }
|
||||
}
|
||||
|
||||
describe('GET /api/salary/runs/[id]/payslips/[employeeId]/pdf', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(getCompanyDisplayName).mockResolvedValue('Ny Firma AB')
|
||||
})
|
||||
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
mockClient(null)
|
||||
const response = await GET(
|
||||
createMockRequest('/api/salary/runs/run-1/payslips/emp-1/pdf'),
|
||||
createMockRouteParams({ id: 'run-1', employeeId: 'emp-1' }),
|
||||
)
|
||||
expect(response.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 404 when the run does not exist', async () => {
|
||||
const { enqueueMany } = mockClient(mockUser)
|
||||
enqueueMany([{ data: null }])
|
||||
const response = await GET(
|
||||
createMockRequest('/api/salary/runs/run-x/payslips/emp-1/pdf'),
|
||||
createMockRouteParams({ id: 'run-x', employeeId: 'emp-1' }),
|
||||
)
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
|
||||
it('renders the payslip PDF with the current company name', async () => {
|
||||
const { enqueueMany } = mockClient(mockUser)
|
||||
enqueueMany([
|
||||
{ data: { id: 'run-1', period_year: 2026, period_month: 6, payment_date: '2026-06-25' } },
|
||||
{ data: { employee: { first_name: 'Anna', last_name: 'A', personnummer: 'enc' }, line_items: [] } },
|
||||
{ data: { name: 'Bolaget AB', org_number: '5560000000' } },
|
||||
])
|
||||
|
||||
const response = await GET(
|
||||
createMockRequest('/api/salary/runs/run-1/payslips/emp-1/pdf'),
|
||||
createMockRouteParams({ id: 'run-1', employeeId: 'emp-1' }),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get('Content-Type')).toBe('application/pdf')
|
||||
// Employer name follows the current company_settings.company_name (resolved
|
||||
// by getCompanyDisplayName), not the frozen onboarding companies.name.
|
||||
expect(vi.mocked(buildPayslipData)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ company: { name: 'Ny Firma AB', org_number: '5560000000' } }),
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to companies.name when the resolver returns null', async () => {
|
||||
const { enqueueMany } = mockClient(mockUser)
|
||||
vi.mocked(getCompanyDisplayName).mockResolvedValue(null)
|
||||
enqueueMany([
|
||||
{ data: { id: 'run-1', period_year: 2026, period_month: 6, payment_date: '2026-06-25' } },
|
||||
{ data: { employee: { first_name: 'Anna', last_name: 'A', personnummer: 'enc' }, line_items: [] } },
|
||||
{ data: { name: 'Bolaget AB', org_number: '5560000000' } },
|
||||
])
|
||||
|
||||
const response = await GET(
|
||||
createMockRequest('/api/salary/runs/run-1/payslips/emp-1/pdf'),
|
||||
createMockRouteParams({ id: 'run-1', employeeId: 'emp-1' }),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(vi.mocked(buildPayslipData)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ company: { name: 'Bolaget AB', org_number: '5560000000' } }),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireCompanyId, getCompanyDisplayName } from '@/lib/company/context'
|
||||
import { renderToBuffer } from '@react-pdf/renderer'
|
||||
import { PayslipPDF } from '@/lib/salary/pdf/payslip-template'
|
||||
import { buildPayslipData, payslipFileName } from '@/lib/salary/payslips/build-payslip-data'
|
||||
@@ -69,7 +69,15 @@ export async function GET(
|
||||
clearing_number: string | null; bank_account_number: string | null;
|
||||
}
|
||||
|
||||
const data = buildPayslipData({ run, sre, employee: emp, company })
|
||||
// Employer name on the payslip follows the current company name
|
||||
// (company_settings.company_name), not the frozen onboarding companies.name.
|
||||
const displayName = await getCompanyDisplayName(supabase, companyId)
|
||||
const data = buildPayslipData({
|
||||
run,
|
||||
sre,
|
||||
employee: emp,
|
||||
company: { name: displayName ?? company.name, org_number: company.org_number },
|
||||
})
|
||||
const fileName = payslipFileName(run, emp)
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
||||
@@ -13,6 +13,7 @@ vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
|
||||
vi.mock('@/lib/auth/require-auth', () => ({ requireAuth: vi.fn() }))
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
getCompanyDisplayName: vi.fn().mockResolvedValue('Ny Firma AB'),
|
||||
}))
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
|
||||
@@ -193,6 +194,9 @@ describe('POST /api/salary/runs/[id]/payslips/send', () => {
|
||||
expect(emailArgs.to).toBe('anna@example.test')
|
||||
expect(emailArgs.html).toContain(`https://app.example.test/payslip/${'T'.repeat(43)}`)
|
||||
expect(emailArgs.attachments).toBeUndefined()
|
||||
// Uses the current company name (company_settings.company_name via the
|
||||
// resolver), not the frozen onboarding companies.name ('Bolaget AB').
|
||||
expect(emailArgs.subject).toContain('Ny Firma AB')
|
||||
})
|
||||
|
||||
it('records provider failures without failing the whole batch', async () => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { getEmailService } from '@/lib/email/service'
|
||||
import { getBranding } from '@/lib/branding/service'
|
||||
import { rotateLinkForEmployee } from '@/lib/salary/payslips/links'
|
||||
import { buildPayslipLinkEmail } from '@/lib/salary/payslips/email-template'
|
||||
import { getCompanyDisplayName } from '@/lib/company/context'
|
||||
import { requireCapability } from '@/lib/entitlements/has-capability'
|
||||
import { CAPABILITY } from '@/lib/entitlements/keys'
|
||||
|
||||
@@ -54,6 +55,10 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
return errorResponseFromCode('COMPANY_NOT_FOUND', log, { requestId })
|
||||
}
|
||||
|
||||
// Email employer name follows the current company name
|
||||
// (company_settings.company_name), not the frozen onboarding companies.name.
|
||||
const displayName = await getCompanyDisplayName(supabase, companyId)
|
||||
|
||||
const { data: runEmployees } = await supabase
|
||||
.from('salary_run_employees')
|
||||
.select('employee_id, employee:employees(first_name, last_name, email)')
|
||||
@@ -102,7 +107,7 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
|
||||
const email = buildPayslipLinkEmail({
|
||||
employeeFirstName: emp.first_name,
|
||||
companyName: company.name,
|
||||
companyName: displayName ?? company.name,
|
||||
periodYear: run.period_year,
|
||||
periodMonth: run.period_month,
|
||||
paymentDate: run.payment_date,
|
||||
|
||||
@@ -25,6 +25,7 @@ import { withApiV1 } from '@/lib/api/v1/with-api-v1'
|
||||
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
|
||||
import { UpdateEmployeeSchema } from '@/lib/api/schemas'
|
||||
import { maskPersonnummer } from '@/lib/api/v1/mask-personnummer'
|
||||
import { decryptPersonnummer } from '@/lib/salary/personnummer'
|
||||
|
||||
const EmploymentType = z.enum(['employee', 'company_owner', 'board_member'])
|
||||
const SalaryType = z.enum(['monthly', 'hourly'])
|
||||
@@ -95,7 +96,10 @@ type ExistingRow = {
|
||||
*/
|
||||
function maskExistingForResponse(row: ExistingRow): Record<string, unknown> {
|
||||
const { personnummer, ...rest } = row
|
||||
return { ...rest, personnummer_masked: maskPersonnummer(personnummer) }
|
||||
// personnummer is stored encrypted; decrypt before masking so the write
|
||||
// response is birthdate-visible (YYYYMMDDXXXX), not fully redacted. The
|
||||
// decrypt helper passes legacy plaintext rows through unchanged.
|
||||
return { ...rest, personnummer_masked: maskPersonnummer(decryptPersonnummer(personnummer)) }
|
||||
}
|
||||
|
||||
registerEndpoint({
|
||||
@@ -167,7 +171,14 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string; id: string }
|
||||
return v1ErrorResponseFromCode('EMPLOYEE_NOT_FOUND', ctx.log, { requestId: ctx.requestId })
|
||||
}
|
||||
|
||||
return ok(data, { requestId: ctx.requestId })
|
||||
// Detail is the deliberate drill-in: return the full personnummer. It is
|
||||
// stored encrypted, so decrypt before returning. Legacy plaintext rows
|
||||
// pass through the decrypt helper unchanged.
|
||||
const detail = data as Record<string, unknown> & { personnummer: string }
|
||||
return ok(
|
||||
{ ...detail, personnummer: decryptPersonnummer(detail.personnummer) },
|
||||
{ requestId: ctx.requestId },
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
PATCH as updateEmployee,
|
||||
DELETE as deleteEmployee,
|
||||
} from '../[id]/route'
|
||||
import { encryptPersonnummer, decryptPersonnummer } from '@/lib/salary/personnummer'
|
||||
|
||||
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
|
||||
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
|
||||
@@ -210,6 +211,30 @@ describe('GET /api/v1/companies/:companyId/employees', () => {
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('INSUFFICIENT_SCOPE')
|
||||
})
|
||||
|
||||
it('decrypts an encrypted-at-rest row and masks it birthdate-visible', async () => {
|
||||
// Rows are stored encrypted; the list must decrypt before masking so the
|
||||
// mask is YYYYMMDDXXXX (not fully redacted). Neither the ciphertext nor
|
||||
// the plaintext may leak in the response.
|
||||
const encRow = { ...SAMPLE_EMPLOYEE, personnummer: encryptPersonnummer(SAMPLE_PERSONNUMMER) }
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
employees: { data: [encRow], error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await listEmployees(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees`),
|
||||
companyParams(COMPANY_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data[0].personnummer_masked).toBe('19000101XXXX')
|
||||
expect(JSON.stringify(body)).not.toContain(encRow.personnummer)
|
||||
expect(JSON.stringify(body)).not.toContain(SAMPLE_PERSONNUMMER)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /api/v1/companies/:companyId/employees/:id', () => {
|
||||
@@ -262,6 +287,29 @@ describe('GET /api/v1/companies/:companyId/employees/:id', () => {
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('decrypts the stored ciphertext and returns the full personnummer', async () => {
|
||||
// The detail drill-in returns the full value; it is stored encrypted, so
|
||||
// the endpoint must decrypt it rather than hand back the ciphertext.
|
||||
const encRow = { ...SAMPLE_EMPLOYEE, personnummer: encryptPersonnummer(SAMPLE_PERSONNUMMER) }
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
employees: { data: encRow, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await getEmployee(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}`),
|
||||
detailParams(COMPANY_ID, EMPLOYEE_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data.personnummer).toBe(SAMPLE_PERSONNUMMER)
|
||||
// The raw ciphertext must not surface.
|
||||
expect(JSON.stringify(body)).not.toContain(encRow.personnummer)
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/v1/companies/:companyId/employees', () => {
|
||||
@@ -469,6 +517,52 @@ describe('POST /api/v1/companies/:companyId/employees', () => {
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('VALIDATION_ERROR')
|
||||
})
|
||||
|
||||
it('stores the personnummer encrypted at rest (round-trips, never plaintext)', async () => {
|
||||
// Regression: this path used to insert body.personnummer verbatim, leaving
|
||||
// plaintext personnummer in the DB and 500-ing every decrypt-on-read path.
|
||||
let inserted: Record<string, unknown> | undefined
|
||||
mockServiceClient.mockReturnValue({
|
||||
from: (table: string) =>
|
||||
new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_t, prop) {
|
||||
if (prop === 'then') {
|
||||
const data =
|
||||
table === 'company_members'
|
||||
? { company_id: COMPANY_ID, role: 'owner' }
|
||||
: table === 'employees'
|
||||
? SAMPLE_EMPLOYEE
|
||||
: null
|
||||
return (resolve: (v: unknown) => void) => resolve({ data, error: null })
|
||||
}
|
||||
return (...args: unknown[]) => {
|
||||
if (prop === 'insert' && table === 'employees') {
|
||||
inserted = args[0] as Record<string, unknown>
|
||||
}
|
||||
return new Proxy({}, this!)
|
||||
}
|
||||
},
|
||||
},
|
||||
),
|
||||
})
|
||||
|
||||
const res = await createEmployee(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(validBody),
|
||||
}),
|
||||
companyParams(COMPANY_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(201)
|
||||
expect(inserted).toBeDefined()
|
||||
const storedPnr = inserted!.personnummer as string
|
||||
// Not stored as plaintext, and round-trips back to the supplied value.
|
||||
expect(storedPnr).not.toBe(SAMPLE_PERSONNUMMER)
|
||||
expect(decryptPersonnummer(storedPnr)).toBe(SAMPLE_PERSONNUMMER)
|
||||
})
|
||||
})
|
||||
|
||||
describe('PATCH /api/v1/companies/:companyId/employees/:id', () => {
|
||||
|
||||
@@ -29,6 +29,7 @@ import { withApiV1 } from '@/lib/api/v1/with-api-v1'
|
||||
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
|
||||
import { CreateEmployeeSchema } from '@/lib/api/schemas'
|
||||
import { maskPersonnummer } from '@/lib/api/v1/mask-personnummer'
|
||||
import { decryptPersonnummer, encryptPersonnummer } from '@/lib/salary/personnummer'
|
||||
import { getCompanyEntityType } from '@/lib/company/context'
|
||||
import { isEmploymentTypeAllowedForEntity, EF_OWNER_EMPLOYMENT_ERROR } from '@/lib/salary/employment-rules'
|
||||
|
||||
@@ -199,7 +200,10 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
|
||||
id: r.id,
|
||||
first_name: r.first_name,
|
||||
last_name: r.last_name,
|
||||
personnummer_masked: maskPersonnummer(r.personnummer),
|
||||
// Rows are encrypted at rest: decrypt before masking so the mask is
|
||||
// birthdate-visible (YYYYMMDDXXXX). The decrypt helper passes legacy
|
||||
// plaintext rows through unchanged.
|
||||
personnummer_masked: maskPersonnummer(decryptPersonnummer(r.personnummer)),
|
||||
employment_type: r.employment_type,
|
||||
employment_start: r.employment_start,
|
||||
employment_end: r.employment_end,
|
||||
@@ -402,7 +406,11 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
|
||||
company_id: ctx.companyId!,
|
||||
first_name: body.first_name,
|
||||
last_name: body.last_name,
|
||||
personnummer: body.personnummer,
|
||||
// Encrypt at rest (aes-256-gcm). This is the fix for the plaintext
|
||||
// personnummer bug: the cookie-session route already encrypts; this
|
||||
// path used to store the raw value, which then 500'd every
|
||||
// decrypt-on-read path with ERR_CRYPTO_INVALID_AUTH_TAG.
|
||||
personnummer: encryptPersonnummer(body.personnummer),
|
||||
personnummer_last4: personnummerLast4,
|
||||
employment_type: body.employment_type,
|
||||
employment_start: body.employment_start,
|
||||
@@ -490,7 +498,8 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
|
||||
id: row.id,
|
||||
first_name: row.first_name,
|
||||
last_name: row.last_name,
|
||||
personnummer_masked: maskPersonnummer(row.personnummer),
|
||||
// Mask the plaintext input, not the stored value (now ciphertext).
|
||||
personnummer_masked: maskPersonnummer(body.personnummer),
|
||||
employment_type: row.employment_type,
|
||||
employment_start: row.employment_start,
|
||||
employment_end: row.employment_end,
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* Tests for the self-billing (is_self_billed) branch of
|
||||
* POST /api/v1/companies/:companyId/invoices.
|
||||
*
|
||||
* The self-billed-sale service is mocked so these target the ROUTE branch:
|
||||
* the required-field guard, dry-run dispatch, failure->code mapping, and the
|
||||
* 201 success envelope. The service and the normal-invoice path have their own
|
||||
* tests.
|
||||
*/
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
beforeAll(() => {
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
throw new Error(`self-billed invoice route tests require NODE_ENV=test (got ${process.env.NODE_ENV ?? 'undefined'})`)
|
||||
}
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321'
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key'
|
||||
})
|
||||
|
||||
vi.mock('@/lib/auth/api-keys', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/auth/api-keys')>('@/lib/auth/api-keys')
|
||||
return { ...actual, validateApiKey: vi.fn(), createServiceClientNoCookies: vi.fn() }
|
||||
})
|
||||
|
||||
vi.mock('@supabase/supabase-js', async () => {
|
||||
const actual = await vi.importActual<typeof import('@supabase/supabase-js')>('@supabase/supabase-js')
|
||||
return { ...actual, createClient: vi.fn().mockReturnValue({}) }
|
||||
})
|
||||
|
||||
vi.mock('@/lib/invoices/self-billed-sale', () => ({
|
||||
resolveSelfBilledSaleDraft: vi.fn(),
|
||||
createSelfBilledSaleInvoice: vi.fn(),
|
||||
}))
|
||||
|
||||
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
|
||||
import { resolveSelfBilledSaleDraft, createSelfBilledSaleInvoice } from '@/lib/invoices/self-billed-sale'
|
||||
import { POST as createInvoice } from '../route'
|
||||
|
||||
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
|
||||
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
|
||||
const mockResolve = resolveSelfBilledSaleDraft as ReturnType<typeof vi.fn>
|
||||
const mockCreate = createSelfBilledSaleInvoice as ReturnType<typeof vi.fn>
|
||||
|
||||
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
|
||||
const CUSTOMER_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'
|
||||
const INVOICE_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
|
||||
const USER_ID = 'user-1'
|
||||
|
||||
function makeFlexibleSupabase(byTable: Record<string, { data?: unknown; error?: unknown }>) {
|
||||
const buildChain = (table: string): unknown => {
|
||||
const handler: ProxyHandler<object> = {
|
||||
get(_target, prop) {
|
||||
if (prop === 'then') {
|
||||
return (resolve: (v: unknown) => void) => resolve(byTable[table] ?? { data: null, error: null })
|
||||
}
|
||||
return (..._args: unknown[]) => buildChain(table)
|
||||
},
|
||||
}
|
||||
return new Proxy({}, handler)
|
||||
}
|
||||
return { from: vi.fn((table: string) => buildChain(table)) }
|
||||
}
|
||||
|
||||
function companyParams(companyId: string) {
|
||||
return { params: Promise.resolve({ companyId }) }
|
||||
}
|
||||
|
||||
const VALID_BODY = {
|
||||
customer_id: CUSTOMER_ID,
|
||||
invoice_date: '2026-05-10',
|
||||
received_date: '2026-05-11',
|
||||
due_date: '2026-06-09',
|
||||
currency: 'SEK',
|
||||
is_self_billed: true,
|
||||
external_invoice_number: 'KUND-55012',
|
||||
items: [{ description: 'Milk delivery', quantity: 1, unit: 'st', unit_price: 10000, vat_rate: 25 }],
|
||||
}
|
||||
|
||||
function makePost(body: unknown, opts: { dryRun?: boolean; idempotency?: boolean } = {}): Request {
|
||||
const url = `http://localhost/api/v1/companies/${COMPANY_ID}/invoices${opts.dryRun ? '?dry_run=true' : ''}`
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Bearer test-fixture-not-a-real-key',
|
||||
}
|
||||
if (opts.idempotency !== false) headers['Idempotency-Key'] = 'idem-1'
|
||||
return new Request(url, { method: 'POST', headers, body: JSON.stringify(body) })
|
||||
}
|
||||
|
||||
const DRAFT = {
|
||||
customer: { id: CUSTOMER_ID, name: 'Stora Bolaget AB', customer_type: 'swedish_business', vat_number_validated: null },
|
||||
items: [
|
||||
{ sort_order: 0, description: 'Milk delivery', quantity: 1, unit: 'st', unit_price: 10000, line_total: 10000, vat_rate: 25, vat_amount: 2500 },
|
||||
],
|
||||
subtotal: 10000,
|
||||
vatAmount: 2500,
|
||||
total: 12500,
|
||||
subtotalSek: null,
|
||||
vatAmountSek: null,
|
||||
totalSek: null,
|
||||
exchangeRate: null,
|
||||
exchangeRateDate: null,
|
||||
currency: 'SEK',
|
||||
vatTreatment: 'standard_25',
|
||||
momsRuta: '05',
|
||||
reverseChargeText: null,
|
||||
vatRate: 25,
|
||||
}
|
||||
|
||||
const CREATED_INVOICE = {
|
||||
id: INVOICE_ID,
|
||||
customer_id: CUSTOMER_ID,
|
||||
invoice_number: null,
|
||||
is_self_billed: true,
|
||||
external_invoice_number: 'KUND-55012',
|
||||
status: 'sent',
|
||||
currency: 'SEK',
|
||||
subtotal: 10000,
|
||||
vat_amount: 2500,
|
||||
total: 12500,
|
||||
remaining_amount: 12500,
|
||||
document_type: 'invoice',
|
||||
created_at: '2026-05-11T09:00:00Z',
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockValidate.mockResolvedValue({
|
||||
userId: USER_ID,
|
||||
companyId: COMPANY_ID,
|
||||
apiKeyId: 'ak_1',
|
||||
apiKeyName: 'CI key',
|
||||
scopes: ['invoices:write'],
|
||||
mode: 'live',
|
||||
})
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({ company_members: { data: { user_id: USER_ID, company_id: COMPANY_ID }, error: null } }),
|
||||
)
|
||||
})
|
||||
|
||||
describe('POST /api/v1/.../invoices with is_self_billed', () => {
|
||||
it('400 when is_self_billed but external_invoice_number is missing', async () => {
|
||||
const { external_invoice_number: _omit, ...body } = VALID_BODY
|
||||
const res = await createInvoice(makePost(body), companyParams(COMPANY_ID))
|
||||
expect(res.status).toBe(400)
|
||||
const json = await res.json()
|
||||
expect(json.error.code).toBe('VALIDATION_ERROR')
|
||||
expect(mockCreate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('400 when is_self_billed but received_date is missing', async () => {
|
||||
const { received_date: _omit, ...body } = VALID_BODY
|
||||
const res = await createInvoice(makePost(body), companyParams(COMPANY_ID))
|
||||
expect(res.status).toBe(400)
|
||||
expect(mockCreate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('dry-run validates + previews without creating', async () => {
|
||||
mockResolve.mockResolvedValue({ ok: true, draft: DRAFT })
|
||||
const res = await createInvoice(makePost(VALID_BODY, { dryRun: true }), companyParams(COMPANY_ID))
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers.get('X-Dry-Run')).toBe('true')
|
||||
expect(mockCreate).not.toHaveBeenCalled()
|
||||
const json = await res.json()
|
||||
expect(json.data.preview.is_self_billed).toBe(true)
|
||||
expect(json.data.preview.external_invoice_number).toBe('KUND-55012')
|
||||
expect(json.data.preview.total).toBe(12500)
|
||||
})
|
||||
|
||||
it('201 registers the self-billed invoice on the happy path', async () => {
|
||||
mockCreate.mockResolvedValue({ ok: true, invoice: CREATED_INVOICE })
|
||||
const res = await createInvoice(makePost(VALID_BODY), companyParams(COMPANY_ID))
|
||||
expect(res.status).toBe(201)
|
||||
expect(mockCreate).toHaveBeenCalledTimes(1)
|
||||
const json = await res.json()
|
||||
expect(json.data.is_self_billed).toBe(true)
|
||||
expect(json.data.external_invoice_number).toBe('KUND-55012')
|
||||
expect(json.data.status).toBe('sent')
|
||||
})
|
||||
|
||||
it('404 when the service reports customer_not_found', async () => {
|
||||
mockCreate.mockResolvedValue({ ok: false, failure: { code: 'customer_not_found', customerId: CUSTOMER_ID } })
|
||||
const res = await createInvoice(makePost(VALID_BODY), companyParams(COMPANY_ID))
|
||||
expect(res.status).toBe(404)
|
||||
const json = await res.json()
|
||||
expect(json.error.code).toBe('INVOICE_CUSTOMER_NOT_FOUND')
|
||||
})
|
||||
|
||||
it('400 when the service reports a VAT rule violation', async () => {
|
||||
mockCreate.mockResolvedValue({
|
||||
ok: false,
|
||||
failure: { code: 'vat_rule_violation', attemptedRate: 10, allowedRates: [25, 12, 6, 0], customerType: 'swedish_business' },
|
||||
})
|
||||
const res = await createInvoice(makePost(VALID_BODY), companyParams(COMPANY_ID))
|
||||
expect(res.status).toBe(400)
|
||||
const json = await res.json()
|
||||
expect(json.error.code).toBe('INVOICE_CREATE_VAT_RULE_VIOLATION')
|
||||
})
|
||||
|
||||
it('does not touch the self-billed path for a normal invoice', async () => {
|
||||
const { is_self_billed: _f, external_invoice_number: _e, received_date: _r, ...normal } = VALID_BODY
|
||||
// A normal invoice create hits the draft path (not our mocked service).
|
||||
// We only assert the self-billed service was not called.
|
||||
await createInvoice(makePost(normal), companyParams(COMPANY_ID)).catch(() => undefined)
|
||||
expect(mockCreate).not.toHaveBeenCalled()
|
||||
expect(mockResolve).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -24,14 +24,60 @@ import {
|
||||
} from '@/lib/api/v1/pagination'
|
||||
import { parseExpand } from '@/lib/api/v1/expand'
|
||||
import { registerEndpoint, listEnvelope, dataEnvelope } from '@/lib/api/v1/registry'
|
||||
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
|
||||
import { withApiV1, type ApiV1Context } from '@/lib/api/v1/with-api-v1'
|
||||
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
|
||||
import { CreateInvoiceSchema } from '@/lib/api/schemas'
|
||||
import { INVOICE_FULL_COLUMNS, INVOICE_ITEM_FULL_COLUMNS } from '@/lib/api/v1/invoice-columns'
|
||||
import { buildInvoiceWriteData } from '@/lib/invoices/build-invoice-write'
|
||||
import {
|
||||
resolveSelfBilledSaleDraft,
|
||||
createSelfBilledSaleInvoice,
|
||||
type SelfBilledSaleInput,
|
||||
type SelfBilledSaleFailure,
|
||||
} from '@/lib/invoices/self-billed-sale'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import type { Customer, Invoice, InvoiceDocumentType } from '@/types'
|
||||
|
||||
// Map a self-billed-sale service failure onto the v1 invoice error envelope.
|
||||
function selfBilledFailureResponse(failure: SelfBilledSaleFailure, ctx: ApiV1Context) {
|
||||
const base = { requestId: ctx.requestId }
|
||||
switch (failure.code) {
|
||||
case 'customer_not_found':
|
||||
return v1ErrorResponseFromCode('INVOICE_CUSTOMER_NOT_FOUND', ctx.log, { ...base, details: { resource: 'customer' } })
|
||||
case 'vat_rule_violation':
|
||||
return v1ErrorResponseFromCode('INVOICE_CREATE_VAT_RULE_VIOLATION', ctx.log, {
|
||||
...base,
|
||||
details: {
|
||||
attempted_rate: failure.attemptedRate,
|
||||
allowed_rates: failure.allowedRates,
|
||||
customer_type: failure.customerType,
|
||||
},
|
||||
})
|
||||
case 'fx_rate_unavailable':
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
...base,
|
||||
details: {
|
||||
field: 'currency',
|
||||
currency: failure.currency,
|
||||
invoice_date: failure.invoiceDate,
|
||||
message: `Ingen växelkurs för ${failure.currency} på fakturadatumet (${failure.invoiceDate}). Försök igen senare.`,
|
||||
},
|
||||
})
|
||||
case 'no_fiscal_period':
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
...base,
|
||||
details: { field: 'invoice_date', message: 'Ingen öppen bokföringsperiod för fakturadatumet.' },
|
||||
})
|
||||
case 'insert_failed':
|
||||
return v1ErrorResponseFromCode('INVOICE_CREATE_INSERT_FAILED', ctx.log, {
|
||||
...base,
|
||||
details: { stage: failure.stage, pg_code: failure.pgCode },
|
||||
})
|
||||
case 'items_failed':
|
||||
return v1ErrorResponseFromCode('INVOICE_CREATE_ITEMS_FAILED', ctx.log, { ...base, details: { pg_code: failure.pgCode } })
|
||||
}
|
||||
}
|
||||
|
||||
const InvoiceStatus = z.enum([
|
||||
'draft',
|
||||
'sent',
|
||||
@@ -324,7 +370,7 @@ registerEndpoint({
|
||||
path: '/api/v1/companies/:companyId/invoices',
|
||||
summary: 'Create a draft invoice, proforma, or delivery note.',
|
||||
description:
|
||||
'Creates an invoice in draft status. The F-series invoice_number is allocated atomically on the first send action (PR-B-2b). Per-item VAT rates are validated against the customer\'s allowed rates (mixed-rate invoices supported). Non-SEK invoices are converted to SEK at the Riksbanken exchange rate fetched at create time. Supports ROT/RUT deduction lines (items[].deduction_type = "rot"|"rut" with invoice-level deduction_personnummer + deduction_housing_designation, or deduction_apartment_number + deduction_brf_org_number for bostadsrätt), article linkage (items[].article_id + optional revenue_account override from the artikelregister), and project/cost-centre tagging (default_dimensions / items[].dimensions). Idempotent (mandatory Idempotency-Key). Dry-runnable: the preview returns the validated would-be invoice + items with computed totals; no journal entry is involved at draft stage (posting happens on :send).',
|
||||
'Creates an invoice in draft status. The F-series invoice_number is allocated atomically on the first send action (PR-B-2b). Per-item VAT rates are validated against the customer\'s allowed rates (mixed-rate invoices supported). Non-SEK invoices are converted to SEK at the Riksbanken exchange rate fetched at create time. Supports ROT/RUT deduction lines (items[].deduction_type = "rot"|"rut" with invoice-level deduction_personnummer + deduction_housing_designation, or deduction_apartment_number + deduction_brf_org_number for bostadsrätt), article linkage (items[].article_id + optional revenue_account override from the artikelregister), and project/cost-centre tagging (default_dimensions / items[].dimensions). Idempotent (mandatory Idempotency-Key). Dry-runnable: the preview returns the validated would-be invoice + items with computed totals; no journal entry is involved at draft stage (posting happens on :send). Set is_self_billed=true (with external_invoice_number + received_date) to instead register a received self-billing invoice (mottagen självfaktura, ML 17 kap 15§): a sale booked immediately with the counterparty\'s number, not a draft.',
|
||||
useWhen:
|
||||
'You need to issue a new invoice, proforma, or delivery note. Use dry-run first to confirm VAT calculations and currency conversion before committing.',
|
||||
doNotUseFor:
|
||||
@@ -335,6 +381,7 @@ registerEndpoint({
|
||||
'Non-SEK currencies require an active Riksbanken exchange-rate fetch. Failure is non-fatal: the invoice is created with null SEK fields and the agent can recompute later.',
|
||||
'invoice_number is null on creation. The number is allocated atomically when the invoice transitions out of draft. Counting on a specific number at create time is a bug.',
|
||||
'document_type=\'delivery_note\' produces no VAT and a different number sequence (D-series). Most use cases want the default document_type=\'invoice\'.',
|
||||
'is_self_billed=true registers a self-billing invoice your CUSTOMER issued on your behalf (a sale for you). It is booked immediately (not a draft, no F-number), so external_invoice_number and received_date are required and it is NOT dry-run-free of side effects on the live call. Do NOT set it for a normal invoice you issue yourself.',
|
||||
'Project/cost-center tagging: pass default_dimensions ({"6":"P001"} = project, {"1":"KS01"} = kostnadsställe) for the whole invoice and/or items[].dimensions per line (per-line wins per key). Tags are stored on the draft and applied to the journal entry lines when the invoice is sent. When the company has the dimension registry enabled, unknown or archived codes are rejected at :send with 400 DIMENSION_VALIDATION_FAILED — list valid codes via GET /dimensions.',
|
||||
'ROT/RUT: set items[].deduction_type ("rot"|"rut") on labor lines plus labor_hours and work_type (Skatteverket arbetstypskod). The invoice must carry deduction_personnummer AND housing info: deduction_housing_designation (fastighetsbeteckning) for småhus, or deduction_apartment_number + deduction_brf_org_number for bostadsrätt. deduction_amount is computed server-side and cannot be set by the caller; the response exposes deduction_total and remaining_amount = total - deduction_total (Skatteverket pays the rest via 1513). Validation failures return 400 INVOICE_CREATE_ROT_RUT_VALIDATION.',
|
||||
'Articles: pass items[].article_id (from the artikelregister, GET /articles) to link a line to a catalog article; price/description are still taken from the request body (the API never auto-fills from the article: send the values you want on the invoice). items[].revenue_account optionally overrides the BAS class-3 account and is validated against the chart of accounts.',
|
||||
@@ -411,6 +458,91 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
|
||||
})
|
||||
}
|
||||
const input = parsed.data
|
||||
|
||||
// Self-billing (mottagen självfaktura, ML 17 kap 15§): the customer issued
|
||||
// the invoice on our behalf, so for our books it is a SALE booked
|
||||
// immediately (never a draft). Optional flag; delegated to the shared
|
||||
// self-billed-sale service so this and the internal dashboard route agree.
|
||||
if (input.is_self_billed) {
|
||||
if (!input.external_invoice_number || !input.received_date) {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: {
|
||||
issues: [
|
||||
...(!input.external_invoice_number
|
||||
? [{ field: 'external_invoice_number', message: 'external_invoice_number is required when is_self_billed is true.' }]
|
||||
: []),
|
||||
...(!input.received_date
|
||||
? [{ field: 'received_date', message: 'received_date is required when is_self_billed is true.' }]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const selfBilledInput: SelfBilledSaleInput = {
|
||||
customer_id: input.customer_id,
|
||||
external_invoice_number: input.external_invoice_number,
|
||||
self_billing_agreement_ref: input.self_billing_agreement_ref ?? null,
|
||||
invoice_date: input.invoice_date,
|
||||
received_date: input.received_date,
|
||||
due_date: input.due_date,
|
||||
currency: input.currency,
|
||||
notes: input.notes ?? null,
|
||||
items: input.items.map((it) => ({
|
||||
description: it.description,
|
||||
quantity: it.quantity,
|
||||
unit: it.unit ?? 'st',
|
||||
unit_price: it.unit_price,
|
||||
vat_rate: it.vat_rate,
|
||||
})),
|
||||
}
|
||||
|
||||
if (ctx.dryRun) {
|
||||
const resolved = await resolveSelfBilledSaleDraft(ctx.supabase, ctx.companyId!, selfBilledInput)
|
||||
if (!resolved.ok) return selfBilledFailureResponse(resolved.failure, ctx)
|
||||
const { draft } = resolved
|
||||
return dryRunPreview(
|
||||
{
|
||||
invoice_number: null,
|
||||
customer_id: selfBilledInput.customer_id,
|
||||
customer_name: draft.customer.name,
|
||||
is_self_billed: true,
|
||||
external_invoice_number: selfBilledInput.external_invoice_number,
|
||||
self_billing_agreement_ref: selfBilledInput.self_billing_agreement_ref,
|
||||
invoice_date: selfBilledInput.invoice_date,
|
||||
received_date: selfBilledInput.received_date,
|
||||
due_date: selfBilledInput.due_date,
|
||||
status: 'sent' as const,
|
||||
currency: draft.currency,
|
||||
exchange_rate: draft.exchangeRate,
|
||||
subtotal: draft.subtotal,
|
||||
subtotal_sek: draft.subtotalSek,
|
||||
vat_amount: draft.vatAmount,
|
||||
vat_amount_sek: draft.vatAmountSek,
|
||||
total: draft.total,
|
||||
total_sek: draft.totalSek,
|
||||
remaining_amount: draft.total,
|
||||
vat_treatment: draft.vatTreatment,
|
||||
vat_rate: draft.vatRate,
|
||||
moms_ruta: draft.momsRuta,
|
||||
document_type: 'invoice' as const,
|
||||
items: draft.items,
|
||||
would_book_journal_entry: true,
|
||||
},
|
||||
{ requestId: ctx.requestId, log: ctx.log },
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await createSelfBilledSaleInvoice(ctx.supabase, ctx.companyId!, ctx.userId, selfBilledInput)
|
||||
if (!result.ok) return selfBilledFailureResponse(result.failure, ctx)
|
||||
return created(result.invoice as unknown as Record<string, unknown>, { requestId: ctx.requestId })
|
||||
} catch (err) {
|
||||
return v1ErrorResponse(err, ctx.log, { requestId: ctx.requestId })
|
||||
}
|
||||
}
|
||||
|
||||
const documentType: InvoiceDocumentType = input.document_type || 'invoice'
|
||||
|
||||
// Customer fetch (scoped to company). The builder only reads
|
||||
|
||||
@@ -9,6 +9,10 @@ import { Switch } from '@/components/ui/switch'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { AccountNumber } from '@/components/ui/account-number'
|
||||
import {
|
||||
DestructiveConfirmDialog,
|
||||
useDestructiveConfirm,
|
||||
} from '@/components/ui/destructive-confirm-dialog'
|
||||
import { AddAccountDialog } from './AddAccountDialog'
|
||||
import { EditAccountDialog } from './EditAccountDialog'
|
||||
import { PruneAccountsDialog } from './PruneAccountsDialog'
|
||||
@@ -24,7 +28,7 @@ import {
|
||||
BookOpen,
|
||||
} from 'lucide-react'
|
||||
import type { BASAccount } from '@/types'
|
||||
import { isStandardBASAccount, type BASReferenceAccount } from '@/lib/bookkeeping/bas-reference'
|
||||
import { BAS_REFERENCE, isStandardBASAccount, type BASReferenceAccount } from '@/lib/bookkeeping/bas-reference'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -43,6 +47,8 @@ interface ReferenceAccount extends BASReferenceAccount {
|
||||
export default function ChartOfAccountsManager() {
|
||||
const { toast } = useToast()
|
||||
const t = useTranslations('chart_of_accounts')
|
||||
const tCommon = useTranslations('common')
|
||||
const { dialogProps: confirmDialogProps, confirm } = useDestructiveConfirm()
|
||||
|
||||
const classLabel = (cls: number): string => {
|
||||
if (cls < 1 || cls > 8) return ''
|
||||
@@ -69,6 +75,10 @@ export default function ChartOfAccountsManager() {
|
||||
const [referenceAccounts, setReferenceAccounts] = useState<ReferenceAccount[]>([])
|
||||
const [usageCounts, setUsageCounts] = useState<Map<string, number>>(new Map())
|
||||
const [loading, setLoading] = useState(true)
|
||||
// The BAS catalog + K2 default are fetched lazily the first time the user
|
||||
// opens that tab, so they never block the default "Mina konton" view.
|
||||
const [referenceLoaded, setReferenceLoaded] = useState(false)
|
||||
const [referenceLoading, setReferenceLoading] = useState(false)
|
||||
|
||||
// Dialog state
|
||||
const [addDialogOpen, setAddDialogOpen] = useState(false)
|
||||
@@ -90,10 +100,27 @@ export default function ChartOfAccountsManager() {
|
||||
setAccounts(data || [])
|
||||
}, [])
|
||||
|
||||
// The server sends only the company's per-account activation status; the BAS
|
||||
// catalog is static and already bundled here, so we merge client-side rather
|
||||
// than re-download ~1,300 catalog rows on every load.
|
||||
const fetchReference = useCallback(async () => {
|
||||
const res = await fetch('/api/bookkeeping/accounts/reference')
|
||||
const { data } = await res.json()
|
||||
setReferenceAccounts(data || [])
|
||||
const userMap = new Map<string, { account_number: string; is_active: boolean; is_system_account: boolean }>(
|
||||
(data || []).map(
|
||||
(a: { account_number: string; is_active: boolean; is_system_account: boolean }) => [a.account_number, a],
|
||||
),
|
||||
)
|
||||
const merged: ReferenceAccount[] = BAS_REFERENCE.map((ref) => {
|
||||
const userAccount = userMap.get(ref.account_number)
|
||||
return {
|
||||
...ref,
|
||||
is_activated: !!userAccount,
|
||||
is_active: userAccount?.is_active ?? false,
|
||||
is_system_account: userAccount?.is_system_account ?? false,
|
||||
}
|
||||
})
|
||||
setReferenceAccounts(merged)
|
||||
}, [])
|
||||
|
||||
// Per-account posting counts — drives the "Verifikat" column. Non-fatal:
|
||||
@@ -116,33 +143,63 @@ export default function ChartOfAccountsManager() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// First paint blocks only on the user's own chart (the default view). Usage
|
||||
// counts drive the informational "Verifikat" column and load in the
|
||||
// background; the BAS catalog + K2 setting are deferred to tab open. This
|
||||
// must NOT depend on hideK2Excluded: doing so re-ran the whole load a second
|
||||
// time once the K2 default was set, doubling every fetch on each visit.
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
await Promise.all([fetchAccounts(), fetchReference(), fetchUsage()])
|
||||
// Set K2 filter default based on company settings (plan_type)
|
||||
if (hideK2Excluded === null) {
|
||||
try {
|
||||
const res = await fetch('/api/settings')
|
||||
if (res.ok) {
|
||||
const { data } = await res.json()
|
||||
// Default to hiding K2-excluded accounts if the company uses K2 (plan_type === 'k1')
|
||||
setHideK2Excluded(data?.plan_type === 'k1')
|
||||
} else {
|
||||
setHideK2Excluded(false)
|
||||
}
|
||||
} catch {
|
||||
setHideK2Excluded(false)
|
||||
}
|
||||
}
|
||||
setLoading(false)
|
||||
await fetchAccounts()
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
load()
|
||||
}, [fetchAccounts, fetchReference, fetchUsage, hideK2Excluded])
|
||||
void fetchUsage()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [fetchAccounts, fetchUsage])
|
||||
|
||||
// Loads the BAS catalog and the K2 default on demand, once, the first time
|
||||
// the user opens the "BAS-katalog" tab.
|
||||
const ensureReferenceLoaded = useCallback(async () => {
|
||||
if (referenceLoaded || referenceLoading) return
|
||||
setReferenceLoading(true)
|
||||
try {
|
||||
await Promise.all([
|
||||
fetchReference(),
|
||||
(async () => {
|
||||
if (hideK2Excluded !== null) return
|
||||
try {
|
||||
const res = await fetch('/api/settings')
|
||||
if (res.ok) {
|
||||
const { data } = await res.json()
|
||||
// Default to hiding K2-excluded accounts if the company uses K2 (plan_type === 'k1')
|
||||
setHideK2Excluded(data?.plan_type === 'k1')
|
||||
} else {
|
||||
setHideK2Excluded(false)
|
||||
}
|
||||
} catch {
|
||||
setHideK2Excluded(false)
|
||||
}
|
||||
})(),
|
||||
])
|
||||
setReferenceLoaded(true)
|
||||
} finally {
|
||||
setReferenceLoading(false)
|
||||
}
|
||||
}, [referenceLoaded, referenceLoading, fetchReference, hideK2Excluded])
|
||||
|
||||
const refreshAll = useCallback(async () => {
|
||||
await Promise.all([fetchAccounts(), fetchReference(), fetchUsage()])
|
||||
}, [fetchAccounts, fetchReference, fetchUsage])
|
||||
await Promise.all([
|
||||
fetchAccounts(),
|
||||
fetchUsage(),
|
||||
// Only refresh the catalog if the user has actually opened that tab.
|
||||
...(referenceLoaded ? [fetchReference()] : []),
|
||||
])
|
||||
}, [fetchAccounts, fetchUsage, fetchReference, referenceLoaded])
|
||||
|
||||
// -------------------------------------------
|
||||
// Actions
|
||||
@@ -166,7 +223,12 @@ export default function ChartOfAccountsManager() {
|
||||
}
|
||||
|
||||
async function deleteAccount(account: BASAccount) {
|
||||
const confirmed = window.confirm(t('delete_confirm', { number: account.account_number, name: account.account_name }))
|
||||
const confirmed = await confirm({
|
||||
title: t('delete_confirm_title'),
|
||||
description: t('delete_confirm', { number: account.account_number, name: account.account_name }),
|
||||
confirmLabel: t('delete_confirm_action'),
|
||||
cancelLabel: tCommon('cancel'),
|
||||
})
|
||||
if (!confirmed) return
|
||||
setDeletingAccount(account.account_number)
|
||||
try {
|
||||
@@ -298,8 +360,10 @@ export default function ChartOfAccountsManager() {
|
||||
<Tabs
|
||||
value={view}
|
||||
onValueChange={(v) => {
|
||||
setView(v as 'my-accounts' | 'bas-catalog')
|
||||
const next = v as 'my-accounts' | 'bas-catalog'
|
||||
setView(next)
|
||||
setExpandedClasses(new Set())
|
||||
if (next === 'bas-catalog') void ensureReferenceLoaded()
|
||||
}}
|
||||
>
|
||||
<TabsList>
|
||||
@@ -496,6 +560,14 @@ export default function ChartOfAccountsManager() {
|
||||
{/* BAS Catalog view */}
|
||||
{view === 'bas-catalog' && (
|
||||
<div className="space-y-2">
|
||||
{referenceLoading && referenceAccounts.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-muted-foreground">
|
||||
<Loader2 className="h-5 w-5 animate-spin mx-auto mb-2" />
|
||||
{t('loading')}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{Object.entries(groupedReference)
|
||||
.sort(([a], [b]) => Number(a) - Number(b))
|
||||
.map(([cls, classAccounts]) => {
|
||||
@@ -600,7 +672,7 @@ export default function ChartOfAccountsManager() {
|
||||
)
|
||||
})}
|
||||
|
||||
{filteredReference.length === 0 && (
|
||||
{!referenceLoading && filteredReference.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-muted-foreground">
|
||||
{t('no_matches')}
|
||||
@@ -611,6 +683,8 @@ export default function ChartOfAccountsManager() {
|
||||
)}
|
||||
|
||||
{/* Dialogs */}
|
||||
<DestructiveConfirmDialog {...confirmDialogProps} />
|
||||
|
||||
<AddAccountDialog
|
||||
open={addDialogOpen}
|
||||
onOpenChange={setAddDialogOpen}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { Plus, Trash2, AlertTriangle, Loader2, Lock, CalendarPlus, Eraser, Tags } from 'lucide-react'
|
||||
import { Plus, Trash2, AlertTriangle, Loader2, Lock, CalendarPlus, Eraser, Tags, BookmarkPlus } from 'lucide-react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'
|
||||
@@ -20,6 +20,8 @@ import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
|
||||
import LineDimensionFields from '@/components/dimensions/LineDimensionFields'
|
||||
import { loadBasCatalog, type CatalogAccount } from '@/lib/bookkeeping/bas-catalog-client'
|
||||
import BookingTemplatePicker from '@/components/bookkeeping/BookingTemplatePicker'
|
||||
import { deriveTemplateLinesFromBooking } from '@/lib/bookkeeping/template-library'
|
||||
import { TemplateForm } from '@/components/settings/TemplateForm'
|
||||
import CreatePeriodDialog from '@/components/bookkeeping/CreatePeriodDialog'
|
||||
import { ActivateAccountsDialog } from '@/components/bookkeeping/ActivateAccountsDialog'
|
||||
import { AddAccountDialog } from '@/components/bookkeeping/AddAccountDialog'
|
||||
@@ -35,7 +37,7 @@ import { formatVoucher, resolveDefaultSeriesForSource } from '@/lib/bookkeeping/
|
||||
import { useUnsavedChanges } from '@/lib/hooks/use-unsaved-changes'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone'
|
||||
import type { CreateJournalEntryLineInput, FiscalPeriod, BASAccount, JournalEntrySourceType, Currency } from '@/types'
|
||||
import type { CreateJournalEntryLineInput, FiscalPeriod, BASAccount, JournalEntrySourceType, Currency, BookingTemplateLibrary } from '@/types'
|
||||
import type { BookedDuplicateCandidate } from '@/lib/transactions/booking-duplicate-detection'
|
||||
|
||||
const CURRENCIES: { value: Currency; label: string }[] = [
|
||||
@@ -103,6 +105,10 @@ export default function JournalEntryForm({
|
||||
const { toast } = useToast()
|
||||
const { company } = useCompany()
|
||||
const t = useTranslations('journal_form')
|
||||
// Reused only for the bilingual entity-type labels the shared TemplateForm
|
||||
// expects (matches BookingTemplatesPanel); the form itself already pulls its
|
||||
// copy from this namespace.
|
||||
const tTpl = useTranslations('settings_booking_templates')
|
||||
const locale = useLocale()
|
||||
const [periods, setPeriods] = useState<FiscalPeriod[]>([])
|
||||
const [selectedPeriod, setSelectedPeriod] = useState('')
|
||||
@@ -154,6 +160,10 @@ export default function JournalEntryForm({
|
||||
const [periodMismatch, setPeriodMismatch] = useState<'no_period' | 'wrong_period' | null>(null)
|
||||
const [showCreatePeriod, setShowCreatePeriod] = useState(false)
|
||||
const [showClearConfirm, setShowClearConfirm] = useState(false)
|
||||
// "Spara som mall": derive a reusable template from the current kontering and
|
||||
// hand it to the shared TemplateForm (create mode). Amounts become ratios/VAT
|
||||
// rates so the mall re-computes when applied to a fresh amount later.
|
||||
const [showSaveTemplate, setShowSaveTemplate] = useState(false)
|
||||
// Month (YYYY-MM) of the most recently posted voucher this session. Used to
|
||||
// flag, at the review step, when the user is about to book into a different
|
||||
// month: guards against accidentally posting to the wrong month.
|
||||
@@ -597,6 +607,18 @@ export default function JournalEntryForm({
|
||||
&& submittableLines.length >= 2
|
||||
&& incompleteLineCount === 0
|
||||
|
||||
// Account number → BAS name, so derived template lines get meaningful labels.
|
||||
const accountNameMap = useMemo(
|
||||
() => Object.fromEntries(catalog.map((a) => [a.account_number, a.account_name])),
|
||||
[catalog],
|
||||
)
|
||||
// Template lines derived from the current kontering. Fewer than two usable
|
||||
// lines (a 4-digit account + an amount) disables "Spara som mall".
|
||||
const derivedTemplateLines = useMemo(
|
||||
() => deriveTemplateLinesFromBooking(lines, accountNameMap),
|
||||
[lines, accountNameMap],
|
||||
)
|
||||
|
||||
const rate = parseFloat(exchangeRate) || 0
|
||||
// If user has manually entered a foreign amount, use that; otherwise derive from SEK total
|
||||
const parsedForeignInput = parseFloat(foreignAmount) || 0
|
||||
@@ -1398,6 +1420,16 @@ export default function JournalEntryForm({
|
||||
onApply={handleTemplateApply}
|
||||
entityType={company?.entity_type}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowSaveTemplate(true)}
|
||||
disabled={derivedTemplateLines.length < 2}
|
||||
title={derivedTemplateLines.length < 2 ? t('save_template_disabled_hint') : undefined}
|
||||
>
|
||||
<BookmarkPlus className="h-3 w-3 mr-1" />
|
||||
{t('save_as_template')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1586,6 +1618,16 @@ export default function JournalEntryForm({
|
||||
onApply={handleTemplateApply}
|
||||
entityType={company?.entity_type}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowSaveTemplate(true)}
|
||||
disabled={derivedTemplateLines.length < 2}
|
||||
title={derivedTemplateLines.length < 2 ? t('save_template_disabled_hint') : undefined}
|
||||
>
|
||||
<BookmarkPlus className="h-3 w-3 mr-1" />
|
||||
{t('save_as_template')}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-1.5 text-xs text-muted-foreground">
|
||||
{t('fill_balance_hint')} {t('keyboard_hint')}
|
||||
@@ -1672,6 +1714,45 @@ export default function JournalEntryForm({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Save the current kontering as a reusable template. Amounts are stored
|
||||
as ratios of the total, so the user picks a fresh amount when applying
|
||||
the mall later. The shared TemplateForm re-seeds from the derived lines
|
||||
each time the dialog opens (Radix unmounts its content when closed). */}
|
||||
<Dialog open={showSaveTemplate} onOpenChange={setShowSaveTemplate}>
|
||||
<DialogContent className="max-w-lg max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('save_template_dialog_title')}</DialogTitle>
|
||||
<DialogDescription>{t('save_template_dialog_description')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{showSaveTemplate && (
|
||||
<TemplateForm
|
||||
mode="create"
|
||||
entityLabels={{
|
||||
all: tTpl('entity_all'),
|
||||
enskild_firma: tTpl('entity_enskild_firma'),
|
||||
aktiebolag: tTpl('entity_aktiebolag'),
|
||||
}}
|
||||
initialTemplate={{
|
||||
id: '',
|
||||
company_id: null,
|
||||
team_id: null,
|
||||
created_by: null,
|
||||
name: description.trim(),
|
||||
description: '',
|
||||
category: 'other',
|
||||
entity_type: company?.entity_type ?? 'all',
|
||||
lines: derivedTemplateLines,
|
||||
is_system: false,
|
||||
is_active: true,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
} satisfies BookingTemplateLibrary}
|
||||
onSaved={() => setShowSaveTemplate(false)}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<ActivateAccountsDialog
|
||||
open={activationDialog.open}
|
||||
accountNumbers={activationDialog.accountNumbers}
|
||||
|
||||
@@ -14,6 +14,10 @@ import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import {
|
||||
DestructiveConfirmDialog,
|
||||
useDestructiveConfirm,
|
||||
} from '@/components/ui/destructive-confirm-dialog'
|
||||
import { Loader2, Search, Trash2 } from 'lucide-react'
|
||||
|
||||
interface PruneCandidate {
|
||||
@@ -43,6 +47,7 @@ export function PruneAccountsDialog({ open, onOpenChange, onPruned }: PruneAccou
|
||||
const t = useTranslations('chart_of_accounts')
|
||||
const tCommon = useTranslations('common')
|
||||
const { toast } = useToast()
|
||||
const { dialogProps: confirmDialogProps, confirm } = useDestructiveConfirm()
|
||||
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [candidates, setCandidates] = useState<PruneCandidate[]>([])
|
||||
@@ -117,6 +122,12 @@ export function PruneAccountsDialog({ open, onOpenChange, onPruned }: PruneAccou
|
||||
[filteredCandidates, isImportedOrCustom],
|
||||
)
|
||||
|
||||
// Master toggle spans both groups, scoped to the current filter so it only
|
||||
// ever selects what the user can actually see.
|
||||
const allFilteredChecked =
|
||||
filteredCandidates.length > 0 &&
|
||||
filteredCandidates.every((c) => selected.has(c.account_number))
|
||||
|
||||
function toggleAccount(accountNumber: string) {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev)
|
||||
@@ -145,6 +156,13 @@ export function PruneAccountsDialog({ open, onOpenChange, onPruned }: PruneAccou
|
||||
|
||||
async function handlePrune() {
|
||||
if (selected.size === 0) return
|
||||
const confirmed = await confirm({
|
||||
title: t('prune_confirm_title', { count: selected.size }),
|
||||
description: t('prune_confirm_body'),
|
||||
confirmLabel: t('prune_confirm', { count: selected.size }),
|
||||
cancelLabel: tCommon('cancel'),
|
||||
})
|
||||
if (!confirmed) return
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
const res = await fetch('/api/bookkeeping/accounts/prune', {
|
||||
@@ -225,6 +243,7 @@ export function PruneAccountsDialog({ open, onOpenChange, onPruned }: PruneAccou
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={(v) => !isDeleting && onOpenChange(v)}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
@@ -254,6 +273,21 @@ export function PruneAccountsDialog({ open, onOpenChange, onPruned }: PruneAccou
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
{filteredCandidates.length > 0 && (
|
||||
<label className="flex items-center gap-3 text-sm cursor-pointer">
|
||||
<Checkbox
|
||||
checked={allFilteredChecked}
|
||||
onCheckedChange={(checked) => toggleGroup(filteredCandidates, checked === true)}
|
||||
aria-label={t('prune_select_all')}
|
||||
/>
|
||||
<span className="font-medium">
|
||||
{t('prune_select_all')}{' '}
|
||||
<span className="text-muted-foreground tabular-nums">
|
||||
({filteredCandidates.length})
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
<div className="space-y-4 max-h-[60vh] overflow-y-auto overflow-x-hidden pr-1">
|
||||
{filteredCandidates.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">{t('no_matches')}</p>
|
||||
@@ -291,5 +325,7 @@ export function PruneAccountsDialog({ open, onOpenChange, onPruned }: PruneAccou
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<DestructiveConfirmDialog {...confirmDialogProps} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import { Plus, Trash2 } from 'lucide-react'
|
||||
import type { Customer, Currency } from '@/types'
|
||||
import type { Customer, Currency, RecurringInvoiceSchedule } from '@/types'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
|
||||
const currencies: Currency[] = ['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK']
|
||||
@@ -36,16 +36,18 @@ const units = ['st', 'tim', 'dag', 'månad', 'km', 'kg']
|
||||
interface Props {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
/** Fired after a successful create. Hosts close the dialog and refresh their list. */
|
||||
onCreated: () => void
|
||||
/** Fired after a successful create or edit. Hosts close the dialog and refresh their list. */
|
||||
onSaved: () => void
|
||||
/** When provided, the dialog edits this schedule (PATCH) instead of creating a new one. */
|
||||
schedule?: RecurringInvoiceSchedule
|
||||
}
|
||||
|
||||
/**
|
||||
* "Nytt schema" as a modal: mirrors NewInvoiceDialog now that regular
|
||||
* invoice creation opens in one. Card sections carry over from the old
|
||||
* /invoices/recurring/new page.
|
||||
* "Nytt schema" / "Redigera schema" as a modal: mirrors NewInvoiceDialog now
|
||||
* that regular invoice creation opens in one. Passing `schedule` switches it to
|
||||
* edit mode (prefilled form, PATCH on save).
|
||||
*/
|
||||
export default function NewRecurringScheduleDialog({ open, onOpenChange, onCreated }: Props) {
|
||||
export default function NewRecurringScheduleDialog({ open, onOpenChange, onSaved, schedule }: Props) {
|
||||
const t = useTranslations('invoice_recurring_new')
|
||||
|
||||
return (
|
||||
@@ -61,17 +63,30 @@ export default function NewRecurringScheduleDialog({ open, onOpenChange, onCreat
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('title')}</DialogTitle>
|
||||
<DialogTitle>{schedule ? t('edit_title') : t('title')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<NewRecurringScheduleForm onCreated={onCreated} onCancel={() => onOpenChange(false)} />
|
||||
<NewRecurringScheduleForm
|
||||
schedule={schedule}
|
||||
onSaved={onSaved}
|
||||
onCancel={() => onOpenChange(false)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
// Inner component so form state resets whenever the dialog reopens (Radix
|
||||
// unmounts DialogContent children on close).
|
||||
function NewRecurringScheduleForm({ onCreated, onCancel }: { onCreated: () => void; onCancel: () => void }) {
|
||||
// unmounts DialogContent children on close). This is also what makes edit mode
|
||||
// work: each open re-mounts with the current schedule's values as defaults.
|
||||
function NewRecurringScheduleForm({
|
||||
schedule,
|
||||
onSaved,
|
||||
onCancel,
|
||||
}: {
|
||||
schedule?: RecurringInvoiceSchedule
|
||||
onSaved: () => void
|
||||
onCancel: () => void
|
||||
}) {
|
||||
const { toast } = useToast()
|
||||
const { company } = useCompany()
|
||||
const supabase = createClient()
|
||||
@@ -94,6 +109,7 @@ function NewRecurringScheduleForm({ onCreated, onCancel }: { onCreated: () => vo
|
||||
customer_id: z.string().uuid(t('validation_customer_required')),
|
||||
name: z.string().min(1, t('validation_name_required')),
|
||||
day_of_month: z.number().int().min(1).max(31),
|
||||
send_hour: z.number().int().min(0).max(23),
|
||||
payment_terms_days: z.number().int().min(0).max(90),
|
||||
currency: z.enum(['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK']),
|
||||
auto_send: z.boolean(),
|
||||
@@ -111,18 +127,45 @@ function NewRecurringScheduleForm({ onCreated, onCancel }: { onCreated: () => vo
|
||||
control,
|
||||
handleSubmit,
|
||||
watch,
|
||||
setValue,
|
||||
formState: { errors },
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
customer_id: '',
|
||||
name: '',
|
||||
day_of_month: 15,
|
||||
payment_terms_days: 30,
|
||||
currency: 'SEK',
|
||||
auto_send: false,
|
||||
items: [{ description: '', quantity: 1, unit: 'st', unit_price: 0, vat_rate: 25 }],
|
||||
},
|
||||
defaultValues: schedule
|
||||
? {
|
||||
customer_id: schedule.customer_id,
|
||||
name: schedule.name,
|
||||
day_of_month: schedule.day_of_month,
|
||||
send_hour: schedule.send_hour ?? 8,
|
||||
payment_terms_days: schedule.payment_terms_days,
|
||||
currency: schedule.currency,
|
||||
auto_send: schedule.auto_send,
|
||||
your_reference: schedule.your_reference ?? undefined,
|
||||
our_reference: schedule.our_reference ?? undefined,
|
||||
notes: schedule.notes ?? undefined,
|
||||
items:
|
||||
schedule.items && schedule.items.length > 0
|
||||
? [...schedule.items]
|
||||
.sort((a, b) => a.sort_order - b.sort_order)
|
||||
.map((it) => ({
|
||||
description: it.description,
|
||||
quantity: it.quantity,
|
||||
unit: it.unit,
|
||||
unit_price: it.unit_price,
|
||||
vat_rate: (it.vat_rate as 0 | 6 | 12 | 25 | null) ?? null,
|
||||
}))
|
||||
: [{ description: '', quantity: 1, unit: 'st', unit_price: 0, vat_rate: 25 }],
|
||||
}
|
||||
: {
|
||||
customer_id: '',
|
||||
name: '',
|
||||
day_of_month: 15,
|
||||
send_hour: 8,
|
||||
payment_terms_days: 30,
|
||||
currency: 'SEK',
|
||||
auto_send: false,
|
||||
items: [{ description: '', quantity: 1, unit: 'st', unit_price: 0, vat_rate: 25 }],
|
||||
},
|
||||
})
|
||||
|
||||
const { fields, append, remove } = useFieldArray({ control, name: 'items' })
|
||||
@@ -140,20 +183,23 @@ function NewRecurringScheduleForm({ onCreated, onCancel }: { onCreated: () => vo
|
||||
async function onSubmit(data: FormData) {
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const res = await fetch('/api/invoices/recurring', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
const res = await fetch(
|
||||
schedule ? `/api/invoices/recurring/${schedule.id}` : '/api/invoices/recurring',
|
||||
{
|
||||
method: schedule ? 'PATCH' : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
},
|
||||
)
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}))
|
||||
throw new Error(body.error || t('create_failed_fallback'))
|
||||
}
|
||||
toast({ title: t('created_title') })
|
||||
onCreated()
|
||||
toast({ title: schedule ? t('updated_title') : t('created_title') })
|
||||
onSaved()
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: t('create_failed_title'),
|
||||
title: schedule ? t('update_failed_title') : t('create_failed_title'),
|
||||
description: err instanceof Error ? err.message : undefined,
|
||||
variant: 'destructive',
|
||||
})
|
||||
@@ -164,6 +210,20 @@ function NewRecurringScheduleForm({ onCreated, onCancel }: { onCreated: () => vo
|
||||
|
||||
const items = watch('items')
|
||||
const watchCurrency = watch('currency')
|
||||
// Automatic sending requires a customer email; without one the cron would
|
||||
// just produce a monthly draft + warning. Block it at the source.
|
||||
const watchCustomerId = watch('customer_id')
|
||||
const selectedCustomer = customers.find((c) => c.id === watchCustomerId)
|
||||
const customerMissingEmail = !!selectedCustomer && !selectedCustomer.email
|
||||
|
||||
// The onValueChange guard on the customer select only fires on a manual
|
||||
// change. In edit mode a schedule can load with auto_send=true against a
|
||||
// customer who has since lost their email (customers load async, after the
|
||||
// form's defaultValues). Force auto_send off whenever the effective customer
|
||||
// has no email so a disabled-but-checked box can't PATCH auto_send=true.
|
||||
useEffect(() => {
|
||||
if (customerMissingEmail) setValue('auto_send', false)
|
||||
}, [customerMissingEmail, setValue])
|
||||
const subtotalRaw = items.reduce(
|
||||
(sum, it) => sum + (it.quantity || 0) * (it.unit_price || 0),
|
||||
0,
|
||||
@@ -196,7 +256,16 @@ function NewRecurringScheduleForm({ onCreated, onCancel }: { onCreated: () => vo
|
||||
control={control}
|
||||
name="customer_id"
|
||||
render={({ field }) => (
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={(v) => {
|
||||
field.onChange(v)
|
||||
// Switching to a customer without email while auto-send is
|
||||
// checked would create an unsendable schedule.
|
||||
const c = customers.find((x) => x.id === v)
|
||||
if (!c?.email) setValue('auto_send', false)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="customer_id">
|
||||
<SelectValue placeholder={t('customer_placeholder')} />
|
||||
</SelectTrigger>
|
||||
@@ -215,7 +284,7 @@ function NewRecurringScheduleForm({ onCreated, onCancel }: { onCreated: () => vo
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="day_of_month">{t('day_label')}</Label>
|
||||
<Input
|
||||
@@ -230,6 +299,33 @@ function NewRecurringScheduleForm({ onCreated, onCancel }: { onCreated: () => vo
|
||||
{t('day_hint')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="send_hour">{t('send_hour_label')}</Label>
|
||||
<Controller
|
||||
control={control}
|
||||
name="send_hour"
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
value={String(field.value)}
|
||||
onValueChange={(v) => field.onChange(Number(v))}
|
||||
>
|
||||
<SelectTrigger id="send_hour" className="tabular-nums">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Array.from({ length: 24 }, (_, h) => h).map((h) => (
|
||||
<SelectItem key={h} value={String(h)} className="tabular-nums">
|
||||
{`${String(h).padStart(2, '0')}:00`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{t('send_hour_hint')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="payment_terms_days">{t('payment_terms_label')}</Label>
|
||||
<Input
|
||||
@@ -275,6 +371,7 @@ function NewRecurringScheduleForm({ onCreated, onCancel }: { onCreated: () => vo
|
||||
id="auto_send"
|
||||
checked={field.value}
|
||||
onChange={(e) => field.onChange(e.target.checked)}
|
||||
disabled={customerMissingEmail}
|
||||
className="mt-1 h-4 w-4"
|
||||
/>
|
||||
)}
|
||||
@@ -286,6 +383,11 @@ function NewRecurringScheduleForm({ onCreated, onCancel }: { onCreated: () => vo
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t('auto_send_description')}
|
||||
</p>
|
||||
{customerMissingEmail && (
|
||||
<p className="text-sm text-warning-foreground mt-1">
|
||||
{t('auto_send_missing_email')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -403,7 +505,13 @@ function NewRecurringScheduleForm({ onCreated, onCancel }: { onCreated: () => vo
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? t('creating') : t('create_schedule')}
|
||||
{schedule
|
||||
? isSubmitting
|
||||
? t('saving')
|
||||
: t('save_changes')
|
||||
: isSubmitting
|
||||
? t('creating')
|
||||
: t('create_schedule')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -517,14 +517,18 @@ function SkatteverketPanelInner({ periodType, year, period, hasData, rutor }: Sk
|
||||
)
|
||||
}
|
||||
|
||||
// Not connected
|
||||
// Not connected. The momsdeklaration is already complete and can be filed
|
||||
// manually at skatteverket.se with no connection (see the manual-filing card
|
||||
// rendered above this panel). Connecting is an optional convenience for
|
||||
// submitting directly from Accounted, so frame it that way: users must not
|
||||
// read the connection as a prerequisite for producing or filing the report.
|
||||
if (!status?.connected) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<FileCheck className="h-5 w-5" />
|
||||
Skicka till Skatteverket
|
||||
Skicka direkt till Skatteverket (valfritt)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
@@ -535,7 +539,10 @@ function SkatteverketPanelInner({ periodType, year, period, hasData, rutor }: Sk
|
||||
</div>
|
||||
)}
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Anslut till Skatteverket med BankID för att skicka momsdeklarationen direkt.
|
||||
Deklarationen är redan klar att lämnas in manuellt hos Skatteverket
|
||||
(se ovan). Vill du slippa skriva in siffrorna själv kan du ansluta med
|
||||
BankID och skicka den direkt härifrån, samt validera, spara utkast och
|
||||
signera.
|
||||
</p>
|
||||
<Button onClick={handleConnect} className="gap-2">
|
||||
<Link2 className="h-4 w-4" />
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { AlertCircle, ChevronDown, ChevronRight, Percent } from 'lucide-react'
|
||||
import { AlertCircle, ChevronDown, ChevronRight, ExternalLink, FileDown, Percent } from 'lucide-react'
|
||||
import AgentSparkleButton from '@/components/agent/AgentSparkleButton'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { EmptyState } from '@/components/ui/empty-state'
|
||||
@@ -1042,6 +1042,56 @@ function ReportSectionTable({
|
||||
// (räkenskapsår); undefined for monthly/quarterly (calendar periods).
|
||||
const VatDrillContext = React.createContext<{ fiscalPeriodId?: string }>({})
|
||||
|
||||
// Skatteverket's e-service entry point for manually filing the momsdeklaration.
|
||||
// Manual filing needs no connection, so this link is the default path for
|
||||
// anyone who hasn't set up (or doesn't want) the direct-submission integration.
|
||||
const SKATTEVERKET_MOMS_URL =
|
||||
'https://www.skatteverket.se/foretag/etjansterochblanketter/etjanster/momsocharbetsgivardeklarationer'
|
||||
|
||||
/**
|
||||
* Manual-filing affordance shown directly under the calculated momsdeklaration.
|
||||
* The report is generated purely from the bookkeeping and never depends on the
|
||||
* Skatteverket connection, so every user (including core builds with the
|
||||
* skatteverket extension disabled) can file manually. This card makes that
|
||||
* explicit and offers the momsdeklaration as a PDF (in hela kronor) to read off
|
||||
* while filling in the form at skatteverket.se.
|
||||
*/
|
||||
function VatManualFilingCard({ pdfHref }: { pdfHref: string }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Lämna in din momsdeklaration</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Deklarationen ovan är klar. Du behöver inte vara ansluten till
|
||||
Skatteverket för att lämna in den, du kan fylla i beloppen direkt hos
|
||||
Skatteverket.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Ladda ner momsdeklarationen som PDF (belopp i hela kronor). Logga sedan
|
||||
in på skatteverket.se med BankID, öppna Moms- och
|
||||
arbetsgivardeklarationer och skriv in rutorna.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" asChild className="gap-2">
|
||||
<a href={pdfHref} target="_blank" rel="noopener noreferrer">
|
||||
<FileDown className="h-4 w-4" />
|
||||
Ladda ner momsdeklaration (PDF)
|
||||
</a>
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" asChild className="gap-2">
|
||||
<a href={SKATTEVERKET_MOMS_URL} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
Öppna skatteverket.se
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export function VatDeclarationView() {
|
||||
const currentYear = new Date().getFullYear()
|
||||
const currentMonth = new Date().getMonth() + 1
|
||||
@@ -1242,7 +1292,10 @@ export function VatDeclarationView() {
|
||||
<VatDrillContext.Provider value={{ fiscalPeriodId: isYearly ? fiscalPeriodId : undefined }}>
|
||||
<div className="space-y-4">
|
||||
<ReportExportMenu
|
||||
items={[{ format: 'xlsx', href: `/api/reports/vat-declaration/xlsx?${vatQueryString()}` }]}
|
||||
items={[
|
||||
{ format: 'pdf', href: `/api/reports/vat-declaration/pdf?${vatQueryString()}` },
|
||||
{ format: 'xlsx', href: `/api/reports/vat-declaration/xlsx?${vatQueryString()}` },
|
||||
]}
|
||||
>
|
||||
<AgentSparkleButton
|
||||
intentId="vat.review"
|
||||
@@ -1539,6 +1592,10 @@ export function VatDeclarationView() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<VatManualFilingCard
|
||||
pdfHref={`/api/reports/vat-declaration/pdf?${vatQueryString()}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -25,6 +25,12 @@ interface EmployeeTaxCardProps {
|
||||
/** Income year the table/column applies to. Defaults to the current year. */
|
||||
year?: number
|
||||
disabled?: boolean
|
||||
/**
|
||||
* Render the fields without the Card chrome (no border/header), for hosts
|
||||
* that lay their own section dividers around it (e.g. the compact
|
||||
* NewEmployeeDialog). The edit page keeps the default boxed rendering.
|
||||
*/
|
||||
flat?: boolean
|
||||
onChange: (value: EmployeeTaxValue) => void
|
||||
}
|
||||
|
||||
@@ -43,6 +49,7 @@ export default function EmployeeTaxCard({
|
||||
initial,
|
||||
year,
|
||||
disabled,
|
||||
flat,
|
||||
onChange,
|
||||
}: EmployeeTaxCardProps) {
|
||||
const t = useTranslations('salary_employee')
|
||||
@@ -88,12 +95,8 @@ export default function EmployeeTaxCard({
|
||||
})
|
||||
}, [fSkatt, sido, tableNumber, effectiveColumn, municipality, requiresTable])
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t('tax_title')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
const body = (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="f_skatt_status">
|
||||
@@ -252,7 +255,19 @@ export default function EmployeeTaxCard({
|
||||
: t('tax_no_table_f_skatt')}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</>
|
||||
)
|
||||
|
||||
if (flat) {
|
||||
return <div className="space-y-4">{body}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t('tax_title')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">{body}</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,14 +7,24 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Save } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import {
|
||||
validateEmployeeBankAccount,
|
||||
isValidClearing,
|
||||
isValidAccount,
|
||||
normalizeBankNumber,
|
||||
lookupBankByClearing,
|
||||
checkEmployeeAccountChecksum,
|
||||
BANK_ISSUE_MESSAGES_SV,
|
||||
BANK_CHECKSUM_WARNING_SV,
|
||||
} from '@/lib/salary/payment/bank-account'
|
||||
import EmployeeTaxCard, { type EmployeeTaxValue } from '@/components/salary/EmployeeTaxCard'
|
||||
import LineDimensionFields from '@/components/dimensions/LineDimensionFields'
|
||||
|
||||
@@ -22,6 +32,35 @@ function RequiredMark() {
|
||||
return <span className="text-destructive ml-0.5">*</span>
|
||||
}
|
||||
|
||||
// Small caps section label; sections are separated by a hairline divider
|
||||
// instead of being boxed in their own cards (compact, Linear-style layout).
|
||||
const SECTION_HEADER = 'text-xs font-semibold uppercase tracking-wider text-muted-foreground'
|
||||
|
||||
/** Compact label + control stack shared by every field in the dialog. */
|
||||
function Field({
|
||||
label,
|
||||
htmlFor,
|
||||
required,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
label: string
|
||||
htmlFor?: string
|
||||
required?: boolean
|
||||
className?: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('space-y-1', className)}>
|
||||
<Label htmlFor={htmlFor}>
|
||||
{label}
|
||||
{required && <RequiredMark />}
|
||||
</Label>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
@@ -32,13 +71,17 @@ interface Props {
|
||||
/**
|
||||
* "Ny anställd" as a modal: mirrors NewSupplierInvoiceDialog. The last
|
||||
* register entity (after customers/suppliers/articles) to move off a full
|
||||
* page. Card sections carry over from the old /salary/employees/new page.
|
||||
* page. Compact layout: borderless sections split by hairline dividers
|
||||
* (no per-section cards) with a sticky Spara bar, to keep scrolling short.
|
||||
*/
|
||||
export default function NewEmployeeDialog({ open, onOpenChange, onCreated }: Props) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="sm:max-w-3xl max-h-[95dvh] sm:max-h-[90vh] overflow-y-auto"
|
||||
// Override the primitive's own padding + whole-dialog scroll: this
|
||||
// dialog is a fixed header + scrolling body + solid footer (flex
|
||||
// column), so the footer never overlaps scrolling content.
|
||||
className="sm:max-w-3xl max-h-[95dvh] sm:max-h-[90vh] flex flex-col p-0 gap-0 overflow-hidden"
|
||||
// A half-typed employee must survive an accidental backdrop click or
|
||||
// a stray Escape (the municipality combobox and dimension pickers
|
||||
// portal outside the dialog). Closing is explicit: the header X or
|
||||
@@ -47,7 +90,7 @@ export default function NewEmployeeDialog({ open, onOpenChange, onCreated }: Pro
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogHeader className="border-b border-border px-6 pb-4 pt-6">
|
||||
<DialogTitle>Ny anställd</DialogTitle>
|
||||
</DialogHeader>
|
||||
<NewEmployeeForm onCreated={onCreated} onCancel={() => onOpenChange(false)} />
|
||||
@@ -65,6 +108,11 @@ function NewEmployeeForm({ onCreated, onCancel }: { onCreated: () => void; onCan
|
||||
const [salaryType, setSalaryType] = useState('monthly')
|
||||
const [personnummer, setPersonnummer] = useState('')
|
||||
const [vacationRule, setVacationRule] = useState('procentregeln')
|
||||
const [clearing, setClearing] = useState('')
|
||||
const [account, setAccount] = useState('')
|
||||
// Suppress the (soft) check-digit warning while the user is still typing in
|
||||
// the bank fields; only surface it once they move focus away.
|
||||
const [bankFocused, setBankFocused] = useState(false)
|
||||
// Default dimensions bag ({sie_dim_no: object_code}) proposed on the
|
||||
// employee's salary-cost lines at booking. The fields render only when
|
||||
// company_settings.dimensions_enabled: same UI gate as the voucher form.
|
||||
@@ -97,6 +145,18 @@ function NewEmployeeForm({ onCreated, onCancel }: { onCreated: () => void; onCan
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
|
||||
// Block on structurally invalid bank details before hitting the server.
|
||||
const bankIssues = validateEmployeeBankAccount(clearing, account)
|
||||
if (bankIssues.length > 0) {
|
||||
toast({
|
||||
title: 'Kontrollera bankuppgifterna',
|
||||
description: bankIssues.map((i) => i.message).join('. '),
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
|
||||
const form = new FormData(e.currentTarget)
|
||||
@@ -121,8 +181,8 @@ function NewEmployeeForm({ onCreated, onCancel }: { onCreated: () => void; onCan
|
||||
address_line1: form.get('address_line1') as string || undefined,
|
||||
postal_code: form.get('postal_code') as string || undefined,
|
||||
city: form.get('city') as string || undefined,
|
||||
clearing_number: form.get('clearing_number') as string || undefined,
|
||||
bank_account_number: form.get('bank_account_number') as string || undefined,
|
||||
clearing_number: normalizeBankNumber(clearing) || undefined,
|
||||
bank_account_number: normalizeBankNumber(account) || undefined,
|
||||
vacation_rule: vacationRule,
|
||||
vacation_days_per_year: parseInt(form.get('vacation_days_per_year') as string) || 25,
|
||||
// Always sent: {} means no default dimensions.
|
||||
@@ -150,27 +210,29 @@ function NewEmployeeForm({ onCreated, onCancel }: { onCreated: () => void; onCan
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
const bankName = lookupBankByClearing(clearing)
|
||||
const showChecksumWarning =
|
||||
!bankFocused &&
|
||||
validateEmployeeBankAccount(clearing, account).length === 0 &&
|
||||
checkEmployeeAccountChecksum(clearing, account) === 'invalid'
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Personal info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Personuppgifter</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="first_name">Förnamn<RequiredMark /></Label>
|
||||
<form onSubmit={handleSubmit} className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-4">
|
||||
<div className="divide-y divide-border">
|
||||
{/* Personuppgifter */}
|
||||
<section className="space-y-3 py-4 first:pt-0">
|
||||
<h3 className={SECTION_HEADER}>Personuppgifter</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<Field label="Förnamn" htmlFor="first_name" required>
|
||||
<Input id="first_name" name="first_name" required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="last_name">Efternamn<RequiredMark /></Label>
|
||||
</Field>
|
||||
<Field label="Efternamn" htmlFor="last_name" required>
|
||||
<Input id="last_name" name="last_name" required />
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="personnummer">Personnummer (12 siffror)<RequiredMark /></Label>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<Field label="Personnummer" htmlFor="personnummer" required>
|
||||
<Input
|
||||
id="personnummer"
|
||||
name="personnummer"
|
||||
@@ -180,53 +242,37 @@ function NewEmployeeForm({ onCreated, onCancel }: { onCreated: () => void; onCan
|
||||
value={personnummer}
|
||||
onChange={(e) => setPersonnummer(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Krypteras vid lagring</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">E-post</Label>
|
||||
<Input id="email" name="email" type="email" />
|
||||
<p className="text-xs text-muted-foreground">Krävs för att skicka lönebesked</p>
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="E-post" htmlFor="email">
|
||||
<Input id="email" name="email" type="email" placeholder="Krävs för lönebesked" />
|
||||
</Field>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone">Telefon</Label>
|
||||
<Input id="phone" name="phone" className="max-w-xs" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Field label="Telefon" htmlFor="phone" className="max-w-xs">
|
||||
<Input id="phone" name="phone" />
|
||||
</Field>
|
||||
</section>
|
||||
|
||||
{/* Address */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Adress</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="address_line1">Gatuadress</Label>
|
||||
<Input id="address_line1" name="address_line1" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="postal_code">Postnummer</Label>
|
||||
<Input id="postal_code" name="postal_code" className="max-w-[160px]" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="city">Ort</Label>
|
||||
{/* Adress */}
|
||||
<section className="space-y-3 py-4">
|
||||
<h3 className={SECTION_HEADER}>Adress</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-[1fr_160px_1fr] gap-3">
|
||||
<Field label="Gatuadress" htmlFor="address_line1">
|
||||
<Input id="address_line1" name="address_line1" />
|
||||
</Field>
|
||||
<Field label="Postnummer" htmlFor="postal_code">
|
||||
<Input id="postal_code" name="postal_code" />
|
||||
</Field>
|
||||
<Field label="Ort" htmlFor="city">
|
||||
<Input id="city" name="city" />
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Employment */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Anställning</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="employment_type">Typ</Label>
|
||||
{/* Anställning & lön */}
|
||||
<section className="space-y-3 py-4">
|
||||
<h3 className={SECTION_HEADER}>Anställning & lön</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
<Field label="Typ" htmlFor="employment_type">
|
||||
<Select value={employmentType} onValueChange={setEmploymentType}>
|
||||
<SelectTrigger id="employment_type">
|
||||
<SelectValue />
|
||||
@@ -237,32 +283,17 @@ function NewEmployeeForm({ onCreated, onCancel }: { onCreated: () => void; onCan
|
||||
<SelectItem value="board_member">Styrelseledamot</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="employment_start">Anställningsdatum<RequiredMark /></Label>
|
||||
</Field>
|
||||
<Field label="Anställningsdatum" htmlFor="employment_start" required>
|
||||
<Input id="employment_start" name="employment_start" type="date" required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="employment_end">Slutdatum</Label>
|
||||
</Field>
|
||||
<Field label="Slutdatum" htmlFor="employment_end">
|
||||
<Input id="employment_end" name="employment_end" type="date" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="employment_degree">Sysselsättningsgrad (%)</Label>
|
||||
</Field>
|
||||
<Field label="Sysselsättningsgrad (%)" htmlFor="employment_degree">
|
||||
<Input id="employment_degree" name="employment_degree" type="number" defaultValue="100" min="1" max="100" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Salary */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Lön</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="salary_type">Löneform<RequiredMark /></Label>
|
||||
</Field>
|
||||
<Field label="Löneform" htmlFor="salary_type" required>
|
||||
<Select value={salaryType} onValueChange={setSalaryType}>
|
||||
<SelectTrigger id="salary_type">
|
||||
<SelectValue />
|
||||
@@ -272,49 +303,30 @@ function NewEmployeeForm({ onCreated, onCancel }: { onCreated: () => void; onCan
|
||||
<SelectItem value="hourly">Timlön</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</Field>
|
||||
{salaryType === 'monthly' ? (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="monthly_salary">Månadslön (brutto, SEK)<RequiredMark /></Label>
|
||||
<Field label="Månadslön (brutto)" htmlFor="monthly_salary" required>
|
||||
<Input id="monthly_salary" name="monthly_salary" type="number" step="1" min="1" required />
|
||||
</div>
|
||||
</Field>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="hourly_rate">Timlön (SEK)<RequiredMark /></Label>
|
||||
<Field label="Timlön (SEK)" htmlFor="hourly_rate" required>
|
||||
<Input id="hourly_rate" name="hourly_rate" type="number" step="0.01" min="0.01" required />
|
||||
</div>
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Default dimensions (kostnadsställe/projekt) */}
|
||||
{dimensionsEnabled && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Kostnadsställe / Projekt (standard)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<LineDimensionFields dimensions={dimensions} onChange={setDimension} />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Föreslås på lönekostnadsrader vid bokföring av lönekörningar.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{/* Skatt */}
|
||||
<section className="space-y-3 py-4">
|
||||
<h3 className={SECTION_HEADER}>Skatt</h3>
|
||||
<EmployeeTaxCard personnummer={personnummer} onChange={setTax} flat />
|
||||
</section>
|
||||
|
||||
{/* Tax */}
|
||||
<EmployeeTaxCard personnummer={personnummer} onChange={setTax} />
|
||||
|
||||
{/* Vacation */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Semester</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vacation_rule">Semesterregel</Label>
|
||||
{/* Semester */}
|
||||
<section className="space-y-3 py-4">
|
||||
<h3 className={SECTION_HEADER}>Semester</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<Field label="Semesterregel" htmlFor="vacation_rule">
|
||||
<Select value={vacationRule} onValueChange={setVacationRule}>
|
||||
<SelectTrigger id="vacation_rule">
|
||||
<SelectValue />
|
||||
@@ -336,38 +348,74 @@ function NewEmployeeForm({ onCreated, onCancel }: { onCreated: () => void; onCan
|
||||
12 % läggs på varje lönekörning och bokas mot 7285. Ingen semesterlöneskuld byggs upp. Vanligt för tim- och visstidsanställda.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vacation_days_per_year">Semesterdagar per år</Label>
|
||||
</Field>
|
||||
<Field label="Semesterdagar per år" htmlFor="vacation_days_per_year">
|
||||
<Input id="vacation_days_per_year" name="vacation_days_per_year" type="number" min="25" max="40" defaultValue="25" />
|
||||
<p className="text-xs text-muted-foreground">Lagstadgat minimum: 25 dagar</p>
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Bank */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Bankkonto</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="clearing_number">Clearingnummer</Label>
|
||||
<Input id="clearing_number" name="clearing_number" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bank_account_number">Kontonummer</Label>
|
||||
<Input id="bank_account_number" name="bank_account_number" />
|
||||
</div>
|
||||
{/* Kostnadsställe / Projekt (standard) */}
|
||||
{dimensionsEnabled && (
|
||||
<section className="space-y-3 py-4">
|
||||
<h3 className={SECTION_HEADER}>Kostnadsställe / Projekt</h3>
|
||||
<LineDimensionFields dimensions={dimensions} onChange={setDimension} />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Föreslås på lönekostnadsrader vid bokföring av lönekörningar.
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Bankkonto */}
|
||||
<section className="space-y-3 py-4">
|
||||
<h3 className={SECTION_HEADER}>Bankkonto</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<Field label="Clearingnummer" htmlFor="clearing_number">
|
||||
<Input
|
||||
id="clearing_number"
|
||||
name="clearing_number"
|
||||
inputMode="numeric"
|
||||
value={clearing}
|
||||
onChange={(e) => setClearing(e.target.value)}
|
||||
onFocus={() => setBankFocused(true)}
|
||||
onBlur={() => setBankFocused(false)}
|
||||
aria-invalid={clearing !== '' && !isValidClearing(normalizeBankNumber(clearing))}
|
||||
/>
|
||||
{clearing !== '' && !isValidClearing(normalizeBankNumber(clearing)) ? (
|
||||
<p className="text-xs text-destructive">{BANK_ISSUE_MESSAGES_SV.clearing_format}</p>
|
||||
) : bankName ? (
|
||||
<p className="text-xs text-muted-foreground">{bankName}</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">Krävs innan lönekörning</p>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Kontonummer" htmlFor="bank_account_number">
|
||||
<Input
|
||||
id="bank_account_number"
|
||||
name="bank_account_number"
|
||||
inputMode="numeric"
|
||||
value={account}
|
||||
onChange={(e) => setAccount(e.target.value)}
|
||||
onFocus={() => setBankFocused(true)}
|
||||
onBlur={() => setBankFocused(false)}
|
||||
aria-invalid={account !== '' && !isValidAccount(normalizeBankNumber(account))}
|
||||
/>
|
||||
{account !== '' && !isValidAccount(normalizeBankNumber(account)) && (
|
||||
<p className="text-xs text-destructive">{BANK_ISSUE_MESSAGES_SV.account_format}</p>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">Krävs innan lönekörning kan godkännas</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{showChecksumWarning && (
|
||||
<p className="text-xs text-warning-foreground">{BANK_CHECKSUM_WARNING_SV}</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3">
|
||||
{/* Solid footer outside the scroll area: always visible, never overlaps
|
||||
content (the body above scrolls independently). */}
|
||||
<div className="flex justify-end gap-3 border-t border-border bg-background px-6 py-4">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
Avbryt
|
||||
</Button>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { describe, it, expect, beforeEach, afterEach, beforeAll } from 'vitest'
|
||||
import { isForbiddenOrigin, forbiddenOriginResponse } from '../origin-guard'
|
||||
import type { mcpServerExtension as McpServerExtension } from '../index'
|
||||
|
||||
const ENDPOINT = 'https://app.gnubok.se/api/extensions/ext/mcp-server/mcp'
|
||||
|
||||
@@ -100,12 +101,16 @@ describe('forbiddenOriginResponse', () => {
|
||||
})
|
||||
|
||||
describe('mcp-server apiRoutes origin enforcement', () => {
|
||||
// The dynamic import pulls in the full 9k-line server module; that parse
|
||||
// alone takes ~4s and flirts with the 5s default timeout under full-suite
|
||||
// parallel load. The test is import-bound, not logic-bound: give it
|
||||
// explicit headroom instead of letting machine load decide the outcome.
|
||||
// The dynamic import pulls in the full 9k-line server module; that parse can
|
||||
// take several seconds under full-suite parallel load. It is import-bound,
|
||||
// not logic-bound, so warm it once in beforeAll (with generous headroom)
|
||||
// instead of letting an individual test's clock absorb the import cost.
|
||||
let mcpServerExtension: typeof McpServerExtension
|
||||
beforeAll(async () => {
|
||||
;({ mcpServerExtension } = await import('../index'))
|
||||
}, 60_000)
|
||||
|
||||
it('rejects foreign-Origin requests on every /mcp method before dispatch', async () => {
|
||||
const { mcpServerExtension } = await import('../index')
|
||||
const routes = (mcpServerExtension.apiRoutes ?? []).filter((r) => r.path === '/mcp')
|
||||
expect(routes.map((r) => r.method).sort()).toEqual(['DELETE', 'GET', 'POST'])
|
||||
|
||||
@@ -118,5 +123,5 @@ describe('mcp-server apiRoutes origin enforcement', () => {
|
||||
)
|
||||
expect(res.status, `${route.method} /mcp`).toBe(403)
|
||||
}
|
||||
}, 20_000)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
UpdateInvoiceSchema,
|
||||
CreateCreditNoteSchema,
|
||||
MarkInvoicePaidSchema,
|
||||
CreateRecurringScheduleSchema,
|
||||
// Customer schemas
|
||||
CreateCustomerSchema,
|
||||
// Supplier schemas
|
||||
@@ -66,6 +67,8 @@ import {
|
||||
// Report query schemas
|
||||
VatDeclarationQuerySchema,
|
||||
PaginationQuerySchema,
|
||||
// Employee schemas
|
||||
CreateEmployeeSchema,
|
||||
} from '../schemas'
|
||||
|
||||
// ============================================================
|
||||
@@ -1404,6 +1407,35 @@ describe('UpdateSettingsSchema', () => {
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('default_voucher_series_per_source_type', () => {
|
||||
it('accepts a partial map that omits source types (regression: Zod 4 enum-keyed z.record is exhaustive)', () => {
|
||||
const result = UpdateSettingsSchema.safeParse({
|
||||
default_voucher_series_per_source_type: { manual: 'A', bank_transaction: 'C' },
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts an empty map', () => {
|
||||
expect(
|
||||
UpdateSettingsSchema.safeParse({ default_voucher_series_per_source_type: {} }).success,
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a series value that is not a single A-Z letter', () => {
|
||||
const result = UpdateSettingsSchema.safeParse({
|
||||
default_voucher_series_per_source_type: { manual: 'ab' },
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects an unknown source_type key', () => {
|
||||
const result = UpdateSettingsSchema.safeParse({
|
||||
default_voucher_series_per_source_type: { not_a_source_type: 'A' },
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
@@ -2305,3 +2337,95 @@ describe('Integration with test helpers', () => {
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// Employee bank-account validation (CreateEmployeeSchema)
|
||||
// ============================================================
|
||||
|
||||
describe('CreateEmployeeSchema bank details', () => {
|
||||
const baseEmployee = {
|
||||
first_name: 'Anna',
|
||||
last_name: 'Andersson',
|
||||
personnummer: '199001011234',
|
||||
employment_type: 'employee' as const,
|
||||
employment_start: '2026-01-01',
|
||||
salary_type: 'monthly' as const,
|
||||
monthly_salary: 30000,
|
||||
f_skatt_status: 'a_skatt' as const,
|
||||
is_sidoinkomst: false,
|
||||
tax_table_number: 33,
|
||||
tax_municipality: 'Stockholm',
|
||||
}
|
||||
|
||||
it('accepts an employee with no bank details', () => {
|
||||
const result = CreateEmployeeSchema.safeParse(baseEmployee)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts a valid clearing + account pair', () => {
|
||||
const result = CreateEmployeeSchema.safeParse({
|
||||
...baseEmployee,
|
||||
clearing_number: '6000',
|
||||
bank_account_number: '1234567',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts a 5-digit Swedbank clearing', () => {
|
||||
const result = CreateEmployeeSchema.safeParse({
|
||||
...baseEmployee,
|
||||
clearing_number: '83279',
|
||||
bank_account_number: '1234567',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a malformed clearing number', () => {
|
||||
const result = CreateEmployeeSchema.safeParse({
|
||||
...baseEmployee,
|
||||
clearing_number: '12',
|
||||
bank_account_number: '1234567',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(result.error.issues.some((i) => i.path.includes('clearing_number'))).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects a clearing without an account', () => {
|
||||
const result = CreateEmployeeSchema.safeParse({
|
||||
...baseEmployee,
|
||||
clearing_number: '6000',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(result.error.issues.some((i) => i.path.includes('bank_account_number'))).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('CreateRecurringScheduleSchema send_hour', () => {
|
||||
const base = {
|
||||
customer_id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
name: 'Retainer',
|
||||
day_of_month: 15,
|
||||
items: [{ description: 'Service', quantity: 1, unit_price: 1000 }],
|
||||
}
|
||||
|
||||
it('defaults send_hour to 8 when omitted', () => {
|
||||
const result = CreateRecurringScheduleSchema.safeParse(base)
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) expect(result.data.send_hour).toBe(8)
|
||||
})
|
||||
|
||||
it('accepts a valid send_hour', () => {
|
||||
const result = CreateRecurringScheduleSchema.safeParse({ ...base, send_hour: 14 })
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) expect(result.data.send_hour).toBe(14)
|
||||
})
|
||||
|
||||
it('rejects an out-of-range send_hour', () => {
|
||||
expect(CreateRecurringScheduleSchema.safeParse({ ...base, send_hour: 24 }).success).toBe(false)
|
||||
expect(CreateRecurringScheduleSchema.safeParse({ ...base, send_hour: -1 }).success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
+37
-1
@@ -4,6 +4,7 @@ import { normalizeVatNumber } from '@/lib/vat/vat-number'
|
||||
import { isSaneDateString } from '@/lib/utils'
|
||||
import { countCalendarMonths } from '@/lib/bookkeeping/accruals/compute'
|
||||
import { DimensionsBagSchema } from '@/lib/bookkeeping/dimension-resolver'
|
||||
import { validateEmployeeBankAccount } from '@/lib/salary/payment/bank-account'
|
||||
import type { AuditAction } from '@/types'
|
||||
|
||||
// ============================================================
|
||||
@@ -390,6 +391,19 @@ export const CreateInvoiceSchema = z.object({
|
||||
// Dimensions PR7: invoice-level bag applied to every generated journal line;
|
||||
// items[].dimensions merge over it per revenue line.
|
||||
default_dimensions: DimensionsBagSchema.optional(),
|
||||
// Self-billing (mottagen självfaktura, ML 17 kap 15§): optional. Set
|
||||
// is_self_billed=true to register an invoice the CUSTOMER issued on your
|
||||
// behalf. For your books it is a sale, booked immediately (Debit 1510, Credit
|
||||
// 30xx + 26xx) with the counterparty's number in external_invoice_number: no
|
||||
// number from your own series is consumed (BFL 5 kap 6§), and there is no
|
||||
// draft/send step. When is_self_billed is true, external_invoice_number and
|
||||
// received_date are required (enforced in the route). Leave off for a normal
|
||||
// invoice. A plain optional flag (no schema refine) so UpdateInvoiceSchema's
|
||||
// .omit() keeps working on this object.
|
||||
is_self_billed: z.boolean().optional(),
|
||||
external_invoice_number: z.string().min(1).max(64).optional(),
|
||||
self_billing_agreement_ref: z.string().max(128).optional(),
|
||||
received_date: isoDate.optional(),
|
||||
items: z.array(CreateInvoiceItemSchema).min(1, 'At least one item is required'),
|
||||
})
|
||||
|
||||
@@ -516,6 +530,8 @@ export const CreateRecurringScheduleSchema = z.object({
|
||||
customer_id: uuid,
|
||||
name: z.string().min(1, 'Schedule name is required').max(200),
|
||||
day_of_month: z.number().int().min(1).max(31),
|
||||
// Whole hour (0-23) in Europe/Stockholm at which the invoice is sent.
|
||||
send_hour: z.number().int().min(0).max(23).default(8),
|
||||
payment_terms_days: z.number().int().min(0).max(90).default(30),
|
||||
currency: CurrencySchema.default('SEK'),
|
||||
your_reference: z.string().optional(),
|
||||
@@ -532,6 +548,7 @@ export const UpdateRecurringScheduleSchema = z.object({
|
||||
customer_id: uuid.optional(),
|
||||
name: z.string().min(1).max(200).optional(),
|
||||
day_of_month: z.number().int().min(1).max(31).optional(),
|
||||
send_hour: z.number().int().min(0).max(23).optional(),
|
||||
payment_terms_days: z.number().int().min(0).max(90).optional(),
|
||||
currency: CurrencySchema.optional(),
|
||||
your_reference: z.string().nullable().optional(),
|
||||
@@ -1354,8 +1371,14 @@ export const UpdateSettingsSchema = z.object({
|
||||
// values; values are single uppercase letters A-Z. Read by the engine
|
||||
// (`createDraftEntry`) when no explicit voucher_series is passed, with a
|
||||
// fallback to 'A' for unknown keys.
|
||||
// partialRecord, not record: in Zod 4 an enum-keyed z.record is exhaustive
|
||||
// (every source_type required), so saving a map that omits a source type
|
||||
// (e.g. the newly added 'result_appropriation') fails with "expected string,
|
||||
// received undefined". The map is intentionally sparse: the settings form
|
||||
// sends only the source types the user configured, and the engine falls back
|
||||
// to 'A' for any unmapped key.
|
||||
default_voucher_series_per_source_type: z
|
||||
.record(
|
||||
.partialRecord(
|
||||
JournalEntrySourceTypeSchema,
|
||||
z.string().regex(/^[A-Z]$/, 'Verifikationsserie måste vara en bokstav A-Z'),
|
||||
)
|
||||
@@ -1935,6 +1958,19 @@ export const CreateEmployeeSchema = EmployeeSchemaBase.superRefine((data, ctx) =
|
||||
path: ['vaxa_stod_end'],
|
||||
})
|
||||
}
|
||||
|
||||
// Bank details: validate clearing/kontonummer structure at entry so a typo is
|
||||
// caught here rather than at Bankgirot LB generation. Both empty is allowed.
|
||||
// Update path is validated in the PATCH route (only when the fields actually
|
||||
// change) so legacy employees with incomplete free-text bank data can still
|
||||
// be edited in unrelated ways.
|
||||
for (const bankIssue of validateEmployeeBankAccount(data.clearing_number, data.bank_account_number)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: bankIssue.message,
|
||||
path: [bankIssue.field],
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export const UpdateEmployeeSchema = EmployeeSchemaBase.partial().superRefine((data, ctx) => {
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { validateSwedishAccountChecksum } from '@/lib/bankgiro/account-number'
|
||||
import { luhnCheckDigit } from '@/lib/bankgiro/luhn'
|
||||
|
||||
/** Build a mod10 (Luhn) valid number of `len` digits from a numeric seed. */
|
||||
function luhnValid(prefix: string): string {
|
||||
return prefix + String(luhnCheckDigit(prefix))
|
||||
}
|
||||
|
||||
/** Brute-force the account whose last digit makes the pair check out. */
|
||||
function firstValidAccount(clearing: string, base: string): string | null {
|
||||
for (let d = 0; d <= 9; d++) {
|
||||
const acc = base + d
|
||||
if (validateSwedishAccountChecksum(clearing, acc) === 'valid') return acc
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
describe('validateSwedishAccountChecksum', () => {
|
||||
// Anchors the mod11 algorithm + Type 1 comment 1 against a real, published
|
||||
// valid account (Forex Bank, clearing 9420, account 4172385).
|
||||
it('accepts the real Forex example (Type 1, mod11)', () => {
|
||||
expect(validateSwedishAccountChecksum('9420', '4172385')).toBe('valid')
|
||||
})
|
||||
|
||||
it('rejects the Forex example with one digit changed', () => {
|
||||
expect(validateSwedishAccountChecksum('9420', '4172386')).toBe('invalid')
|
||||
})
|
||||
|
||||
it('accepts a Swedbank clearing written with a hyphen', () => {
|
||||
// Type 1 comment 1 (Swedbank 7xxx). Round-trip a brute-forced valid account.
|
||||
const acc = firstValidAccount('7000', '123456')
|
||||
expect(acc).not.toBeNull()
|
||||
expect(validateSwedishAccountChecksum('7000', acc!)).toBe('valid')
|
||||
})
|
||||
|
||||
it('validates Handelsbanken (Type 2, mod11, 9-digit account)', () => {
|
||||
const acc = firstValidAccount('6000', '12345678')
|
||||
expect(acc).not.toBeNull()
|
||||
// A different final digit must not also validate.
|
||||
const wrong = acc!.slice(0, -1) + ((Number(acc!.slice(-1)) + 1) % 10)
|
||||
expect(validateSwedishAccountChecksum('6000', wrong)).toBe('invalid')
|
||||
})
|
||||
|
||||
it('validates a Nordea personkonto (3300, Type 2, mod10, 10-digit)', () => {
|
||||
const acc = luhnValid('123456789') // 9 + check = 10 digits
|
||||
expect(validateSwedishAccountChecksum('3300', acc)).toBe('valid')
|
||||
expect(validateSwedishAccountChecksum('3300', acc.slice(0, -1) + '0')).toBe(
|
||||
Number(acc.slice(-1)) === 0 ? 'valid' : 'invalid',
|
||||
)
|
||||
})
|
||||
|
||||
it('validates a Swedbank 8xxx account (Type 2, mod10)', () => {
|
||||
const acc = luhnValid('1234567') // 8-digit mod10 account
|
||||
expect(validateSwedishAccountChecksum('8000', acc)).toBe('valid')
|
||||
})
|
||||
|
||||
it('returns unknown for an unmapped clearing rather than guessing', () => {
|
||||
expect(validateSwedishAccountChecksum('9999', '1234567')).toBe('unknown')
|
||||
// 3300-3409 gap (Länsförsäkringar etc.) is deliberately unmapped.
|
||||
expect(validateSwedishAccountChecksum('3405', '1234567')).toBe('unknown')
|
||||
})
|
||||
|
||||
it('returns unknown when nothing usable is entered', () => {
|
||||
expect(validateSwedishAccountChecksum('', '')).toBe('unknown')
|
||||
expect(validateSwedishAccountChecksum('7000', '')).toBe('unknown')
|
||||
expect(validateSwedishAccountChecksum('12', '1234567')).toBe('unknown')
|
||||
})
|
||||
|
||||
it('returns unknown when the account is too long for a Type 1 bank', () => {
|
||||
// Type 1 accounts are max 7 digits; do not warn on an over-length entry.
|
||||
expect(validateSwedishAccountChecksum('5000', '12345678901')).toBe('unknown')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Swedish bank account check-digit validation ("kontrollsiffra"), per the
|
||||
* Bankgirot manual "Bankernas kontonummeruppbyggnad".
|
||||
*
|
||||
* Used ONLY as a non-blocking hint: a passing check digit does not prove the
|
||||
* account exists at the bank, and a clearing we don't recognise returns
|
||||
* 'unknown' (no opinion) rather than a guess. It covers the major, high-volume
|
||||
* banks; everything else falls through to 'unknown' so we never warn on a
|
||||
* valid-but-unmapped account.
|
||||
*
|
||||
* The clearing -> (type, comment) mapping and the two algorithms are
|
||||
* cross-checked against the public jop-io/kontonummer.js reference (MIT) and
|
||||
* verified against a real example account (Forex 9420 / 4172385) in the tests.
|
||||
*
|
||||
* Account structure recap:
|
||||
* Type 1: 4-digit clearing + up to 7-digit account, mod11 check digit last.
|
||||
* comment 1: mod11 over the last 10 digits of clearing+account.
|
||||
* comment 2: mod11 over the full clearing+account (11 digits).
|
||||
* Type 2: check digit lives in the account alone.
|
||||
* comment 1: mod10 over a 10-digit account.
|
||||
* comment 2: mod11 over a 9-digit account (Handelsbanken).
|
||||
* comment 3: mod10 over a 6-10 digit account; the 5-digit 8xxxx clearing
|
||||
* carries its own mod10 check digit too (Swedbank/Sparbanken).
|
||||
*/
|
||||
import { luhnValidate } from './luhn'
|
||||
|
||||
export type AccountChecksumResult = 'valid' | 'invalid' | 'unknown'
|
||||
|
||||
// Swedish "11-modulen" weights. For an N-digit input the last N weights are
|
||||
// used, applied left-to-right, so the rightmost (check) digit gets weight 1.
|
||||
// Valid when the weighted sum is non-zero and divisible by 11.
|
||||
const MOD11_WEIGHTS = [1, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1]
|
||||
|
||||
function mod11Valid(digits: string): boolean {
|
||||
if (digits.length === 0 || digits.length > MOD11_WEIGHTS.length) return false
|
||||
const weights = MOD11_WEIGHTS.slice(MOD11_WEIGHTS.length - digits.length)
|
||||
let sum = 0
|
||||
for (let i = 0; i < digits.length; i++) {
|
||||
sum += Number(digits[i]) * weights[i]
|
||||
}
|
||||
return sum !== 0 && sum % 11 === 0
|
||||
}
|
||||
|
||||
interface ClearingRule {
|
||||
type: 1 | 2
|
||||
comment: 1 | 2 | 3
|
||||
}
|
||||
|
||||
// Ordered resolution: exact-clearing exceptions win over ranges. Only the
|
||||
// major banks are mapped; anything else returns null -> 'unknown'. The bank
|
||||
// names in the comments are for reference; the display name comes from
|
||||
// lookupBankByClearing in lib/salary/payment/bank-account.ts.
|
||||
function resolveClearingRule(clearing4: number): ClearingRule | null {
|
||||
// Nordea personkonto (10-digit mod10 account): exceptions inside the 3xxx range.
|
||||
if (clearing4 === 3300 || clearing4 === 3782) return { type: 2, comment: 1 }
|
||||
|
||||
const inRange = (lo: number, hi: number) => clearing4 >= lo && clearing4 <= hi
|
||||
|
||||
// Type 1, comment 1 (mod11 over the last 10 digits of clearing+account).
|
||||
if (inRange(1100, 1199)) return { type: 1, comment: 1 } // Nordea
|
||||
if (inRange(1200, 1399)) return { type: 1, comment: 1 } // Danske Bank
|
||||
if (inRange(1400, 2099)) return { type: 1, comment: 1 } // Nordea
|
||||
if (inRange(2400, 2499)) return { type: 1, comment: 1 } // Danske Bank
|
||||
if (inRange(3000, 3299)) return { type: 1, comment: 1 } // Nordea
|
||||
if (inRange(3410, 3999)) return { type: 1, comment: 1 } // Nordea
|
||||
if (inRange(5000, 5999)) return { type: 1, comment: 1 } // SEB
|
||||
if (inRange(7000, 7999)) return { type: 1, comment: 1 } // Swedbank
|
||||
if (inRange(9400, 9449)) return { type: 1, comment: 1 } // Forex Bank
|
||||
|
||||
// Type 1, comment 2 (mod11 over the full clearing+account).
|
||||
if (inRange(4000, 4999)) return { type: 1, comment: 2 } // Nordea
|
||||
|
||||
// Type 2, comment 2 (mod11 over a 9-digit account).
|
||||
if (inRange(6000, 6999)) return { type: 2, comment: 2 } // Handelsbanken
|
||||
|
||||
// Type 2, comment 3 (mod10 over the account; 5-digit clearing also mod10).
|
||||
if (inRange(8000, 8999)) return { type: 2, comment: 3 } // Swedbank/Sparbanken
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function digitsOnly(s: string | null | undefined): string {
|
||||
return (s ?? '').replace(/\D/g, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the check digit(s) of a Swedish clearing/account pair.
|
||||
* - 'valid' the check digit is consistent with the bank's rule
|
||||
* - 'invalid' the clearing is recognised but the check digit does not match
|
||||
* - 'unknown' clearing not in the table, or the length is implausible for the
|
||||
* bank (so we stay silent instead of guessing)
|
||||
*/
|
||||
export function validateSwedishAccountChecksum(
|
||||
clearingRaw: string | null | undefined,
|
||||
accountRaw: string | null | undefined,
|
||||
): AccountChecksumResult {
|
||||
const clearing = digitsOnly(clearingRaw)
|
||||
const account = digitsOnly(accountRaw)
|
||||
if (clearing.length < 4 || account.length === 0) return 'unknown'
|
||||
|
||||
const rule = resolveClearingRule(Number(clearing.slice(0, 4)))
|
||||
if (!rule) return 'unknown'
|
||||
|
||||
if (rule.type === 1) {
|
||||
if (account.length > 7) return 'unknown'
|
||||
const full = clearing.slice(0, 4) + account.padStart(7, '0') // 11 digits
|
||||
const input = rule.comment === 1 ? full.slice(-10) : full
|
||||
return mod11Valid(input) ? 'valid' : 'invalid'
|
||||
}
|
||||
|
||||
// Type 2: the check digit lives in the account.
|
||||
if (rule.comment === 1) {
|
||||
if (account.length > 10) return 'unknown'
|
||||
return luhnValidate(account.padStart(10, '0')) ? 'valid' : 'invalid'
|
||||
}
|
||||
if (rule.comment === 2) {
|
||||
if (account.length > 9) return 'unknown'
|
||||
return mod11Valid(account.padStart(9, '0')) ? 'valid' : 'invalid'
|
||||
}
|
||||
// comment 3: Swedbank/Sparbanken. Account is 6-10 digits (mod10); a 5-digit
|
||||
// 8xxxx clearing carries its own mod10 check digit.
|
||||
if (account.length < 6 || account.length > 10) return 'unknown'
|
||||
const accountOk = luhnValidate(account)
|
||||
const clearingOk = clearing.length === 5 ? luhnValidate(clearing) : true
|
||||
return accountOk && clearingOk ? 'valid' : 'invalid'
|
||||
}
|
||||
@@ -6,7 +6,7 @@ vi.mock('next/headers', () => ({
|
||||
cookies: vi.fn(async () => ({ set: mockCookieSet })),
|
||||
}))
|
||||
|
||||
import { setActiveCompany, CompanyContextError } from '../context'
|
||||
import { setActiveCompany, CompanyContextError, getCompanyDisplayName } from '../context'
|
||||
|
||||
type CapturedCall = { table: string; method: string; args: unknown[] }
|
||||
|
||||
@@ -109,3 +109,48 @@ describe('setActiveCompany', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getCompanyDisplayName', () => {
|
||||
it('returns company_settings.company_name and never reads companies when set', async () => {
|
||||
const { supabase, calls } = buildSupabase({
|
||||
company_settings: { maybeSingle: { data: { company_name: 'Ny Firma AB' } } },
|
||||
companies: { maybeSingle: { data: { name: 'Aktiebolaget Grundstenen 000000' } } },
|
||||
})
|
||||
|
||||
const name = await getCompanyDisplayName(supabase as never, 'company-1')
|
||||
|
||||
expect(name).toBe('Ny Firma AB')
|
||||
// companies.name is the frozen onboarding value: it must not be consulted
|
||||
// when the user has set a current name in settings.
|
||||
expect(calls.find((c) => c.table === 'companies')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('falls back to companies.name when company_settings has no row', async () => {
|
||||
const { supabase } = buildSupabase({
|
||||
company_settings: { maybeSingle: { data: null } },
|
||||
companies: { maybeSingle: { data: { name: 'Aktiebolaget Grundstenen 000000' } } },
|
||||
})
|
||||
|
||||
expect(await getCompanyDisplayName(supabase as never, 'company-1')).toBe(
|
||||
'Aktiebolaget Grundstenen 000000',
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to companies.name when company_settings.company_name is empty', async () => {
|
||||
const { supabase } = buildSupabase({
|
||||
company_settings: { maybeSingle: { data: { company_name: '' } } },
|
||||
companies: { maybeSingle: { data: { name: 'Bolaget AB' } } },
|
||||
})
|
||||
|
||||
expect(await getCompanyDisplayName(supabase as never, 'company-1')).toBe('Bolaget AB')
|
||||
})
|
||||
|
||||
it('returns null when neither table resolves a name', async () => {
|
||||
const { supabase } = buildSupabase({
|
||||
company_settings: { maybeSingle: { data: null } },
|
||||
companies: { maybeSingle: { data: null } },
|
||||
})
|
||||
|
||||
expect(await getCompanyDisplayName(supabase as never, 'company-1')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -100,6 +100,40 @@ export async function getCompanyEntityType(
|
||||
return (company?.entity_type as EntityType | undefined) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a company's current display name.
|
||||
*
|
||||
* `company_settings.company_name` is the read-primary source (what the user
|
||||
* edits in Settings and what the invoice PDF renders), with the canonical
|
||||
* `companies.name` as the fallback. `companies.name` is written once at
|
||||
* onboarding (via create_company_with_owner) and never updated afterwards, so
|
||||
* reading it directly shows a stale name after a rename (e.g. a lagerbolag
|
||||
* renamed post-signup). Mirrors getCompanyEntityType and the invoice surfaces.
|
||||
*
|
||||
* Returns null only if the company can't be resolved from either table.
|
||||
*/
|
||||
export async function getCompanyDisplayName(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string
|
||||
): Promise<string | null> {
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('company_name')
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
|
||||
// Truthiness (not != null) so an empty string falls through to companies.name.
|
||||
if (settings?.company_name) return settings.company_name as string
|
||||
|
||||
const { data: company } = await supabase
|
||||
.from('companies')
|
||||
.select('name')
|
||||
.eq('id', companyId)
|
||||
.maybeSingle()
|
||||
|
||||
return (company?.name as string | undefined) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all companies the user is a member of, with their roles.
|
||||
*/
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
computeNextRunDate,
|
||||
computeInitialRunDate,
|
||||
getStockholmDateHour,
|
||||
} from '@/lib/invoices/recurring-schedule-service'
|
||||
|
||||
describe('computeNextRunDate', () => {
|
||||
@@ -63,3 +64,37 @@ describe('computeInitialRunDate', () => {
|
||||
expect(computeInitialRunDate(today, 31)).toBe('2027-02-28')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getStockholmDateHour', () => {
|
||||
it('applies summer offset (CEST, UTC+2)', () => {
|
||||
// 2026-07-06 06:00 UTC -> 08:00 Stockholm
|
||||
expect(getStockholmDateHour(new Date('2026-07-06T06:00:00Z'))).toEqual({
|
||||
date: '2026-07-06',
|
||||
hour: 8,
|
||||
})
|
||||
})
|
||||
|
||||
it('applies winter offset (CET, UTC+1)', () => {
|
||||
// 2026-01-15 06:00 UTC -> 07:00 Stockholm
|
||||
expect(getStockholmDateHour(new Date('2026-01-15T06:00:00Z'))).toEqual({
|
||||
date: '2026-01-15',
|
||||
hour: 7,
|
||||
})
|
||||
})
|
||||
|
||||
it('rolls the date forward across the local midnight boundary', () => {
|
||||
// 2026-07-06 22:30 UTC -> 00:30 Stockholm on 2026-07-07 (summer +2)
|
||||
expect(getStockholmDateHour(new Date('2026-07-06T22:30:00Z'))).toEqual({
|
||||
date: '2026-07-07',
|
||||
hour: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('reports hour 23 (h23 cycle, never 24) late in the local day', () => {
|
||||
// 2026-07-06 21:00 UTC -> 23:00 Stockholm (summer +2)
|
||||
expect(getStockholmDateHour(new Date('2026-07-06T21:00:00Z'))).toEqual({
|
||||
date: '2026-07-06',
|
||||
hour: 23,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -115,6 +115,29 @@ export function computeInitialRunDate(
|
||||
return computeNextRunDate(today, dayOfMonth)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the calendar date (yyyy-mm-dd) and hour (0-23) in Europe/Stockholm
|
||||
* for a given instant. The recurring cron runs in UTC on Vercel, but users
|
||||
* pick a send time in Swedish local time, so we need "what day and hour is it
|
||||
* in Sweden right now". Uses Intl (DST-aware, no extra dependency); en-CA +
|
||||
* hourCycle 'h23' guarantees zero-padded ISO-shaped parts and a 0-23 hour.
|
||||
*/
|
||||
export function getStockholmDateHour(instant: Date): { date: string; hour: number } {
|
||||
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Europe/Stockholm',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
}).formatToParts(instant)
|
||||
const get = (type: string) => parts.find((p) => p.type === type)?.value ?? ''
|
||||
return {
|
||||
date: `${get('year')}-${get('month')}-${get('day')}`,
|
||||
hour: Number(get('hour')),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn one invoice from a schedule. Always creates the invoice; auto_send
|
||||
* additionally renders + emails + flips status + creates JE + archives PDF.
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
/**
|
||||
* Received self-billing invoice (mottagen självfaktura, ML 17 kap 15§).
|
||||
*
|
||||
* A self-billing invoice we RECEIVE is a SALE for us: the customer issued the
|
||||
* document on our behalf, so for our books it is an ordinary customer invoice
|
||||
* (Debit 1510, Credit 30xx + 26xx) and the output VAT lands in our
|
||||
* momsdeklaration. It differs from a normal customer invoice in two ways:
|
||||
* - We never assign a number from our own series (BFL 5 kap 6§): the
|
||||
* counterparty's number lives in external_invoice_number and our own
|
||||
* invoice_number stays null (enforced by invoices_self_billed_numbering).
|
||||
* - There is no send step: under faktureringsmetoden it is booked on
|
||||
* registration; under kontantmetoden it stays unbooked until payment.
|
||||
*
|
||||
* This is the single implementation behind both the internal dashboard route
|
||||
* (/api/invoices/self-billed) and the public v1 invoice create endpoint (which
|
||||
* accepts an optional is_self_billed flag), so the two can never drift.
|
||||
*/
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { getVatRules, getAvailableVatRates } from '@/lib/invoices/vat-rules'
|
||||
import { fetchExchangeRate, convertToSEK } from '@/lib/currency/riksbanken'
|
||||
import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import type { EntityType, Invoice } from '@/types'
|
||||
|
||||
const log = createLogger('self-billed-sale')
|
||||
|
||||
export interface SelfBilledSaleItemInput {
|
||||
description: string
|
||||
quantity: number
|
||||
unit: string
|
||||
unit_price: number
|
||||
vat_rate?: number
|
||||
}
|
||||
|
||||
export interface SelfBilledSaleInput {
|
||||
customer_id: string
|
||||
external_invoice_number: string
|
||||
self_billing_agreement_ref?: string | null
|
||||
invoice_date: string
|
||||
received_date: string
|
||||
due_date: string
|
||||
currency: string
|
||||
notes?: string | null
|
||||
items: SelfBilledSaleItemInput[]
|
||||
}
|
||||
|
||||
export type SelfBilledSaleFailure =
|
||||
| { code: 'customer_not_found'; customerId: string }
|
||||
| { code: 'vat_rule_violation'; attemptedRate: number; allowedRates: number[]; customerType: string }
|
||||
| { code: 'fx_rate_unavailable'; currency: string; invoiceDate: string }
|
||||
| { code: 'insert_failed'; stage?: string; pgCode?: string; pgMessage?: string }
|
||||
| { code: 'items_failed'; pgCode?: string; pgMessage?: string }
|
||||
| { code: 'no_fiscal_period' }
|
||||
|
||||
interface SelfBilledCustomer {
|
||||
id: string
|
||||
name: string
|
||||
customer_type: string
|
||||
vat_number_validated: boolean | null
|
||||
}
|
||||
|
||||
export interface SelfBilledSaleComputedItem {
|
||||
sort_order: number
|
||||
description: string
|
||||
quantity: number
|
||||
unit: string
|
||||
unit_price: number
|
||||
line_total: number
|
||||
vat_rate: number
|
||||
vat_amount: number
|
||||
}
|
||||
|
||||
export interface SelfBilledSaleDraft {
|
||||
customer: SelfBilledCustomer
|
||||
items: SelfBilledSaleComputedItem[]
|
||||
subtotal: number
|
||||
vatAmount: number
|
||||
total: number
|
||||
subtotalSek: number | null
|
||||
vatAmountSek: number | null
|
||||
totalSek: number | null
|
||||
exchangeRate: number | null
|
||||
exchangeRateDate: string | null
|
||||
currency: string
|
||||
vatTreatment: string
|
||||
momsRuta: string | null
|
||||
reverseChargeText: string | null
|
||||
vatRate: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate + cost a received self-billing invoice. No writes, no number
|
||||
* consumption. Used for the dry-run preview and by createSelfBilledSaleInvoice.
|
||||
*/
|
||||
export async function resolveSelfBilledSaleDraft(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
input: SelfBilledSaleInput,
|
||||
): Promise<{ ok: true; draft: SelfBilledSaleDraft } | { ok: false; failure: SelfBilledSaleFailure }> {
|
||||
// The issuer of a self-billing invoice is, in our books, the customer we sold
|
||||
// to. Require an existing customer so VAT rules + reporting work. Project only
|
||||
// the fields used (data minimisation).
|
||||
const { data: customer, error: customerError } = await supabase
|
||||
.from('customers')
|
||||
.select('id, name, customer_type, vat_number_validated')
|
||||
.eq('id', input.customer_id)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
|
||||
if (customerError || !customer) {
|
||||
return { ok: false, failure: { code: 'customer_not_found', customerId: input.customer_id } }
|
||||
}
|
||||
const c = customer as SelfBilledCustomer
|
||||
|
||||
// VAT treatment is driven by who the customer is (domestic / EU reverse charge
|
||||
// / export), exactly like an own-issued invoice.
|
||||
const vatRules = getVatRules(
|
||||
c.customer_type as Parameters<typeof getVatRules>[0],
|
||||
c.vat_number_validated ?? undefined,
|
||||
)
|
||||
const availableRates = getAvailableVatRates(
|
||||
c.customer_type as Parameters<typeof getAvailableVatRates>[0],
|
||||
c.vat_number_validated ?? undefined,
|
||||
)
|
||||
const allowedRates = new Set(availableRates.map((r) => r.rate))
|
||||
|
||||
const items: SelfBilledSaleComputedItem[] = []
|
||||
let vatAmount = 0
|
||||
for (let i = 0; i < input.items.length; i++) {
|
||||
const item = input.items[i]
|
||||
const itemRate = item.vat_rate !== undefined ? item.vat_rate : vatRules.rate
|
||||
if (!allowedRates.has(itemRate)) {
|
||||
return {
|
||||
ok: false,
|
||||
failure: {
|
||||
code: 'vat_rule_violation',
|
||||
attemptedRate: itemRate,
|
||||
allowedRates: Array.from(allowedRates),
|
||||
customerType: c.customer_type,
|
||||
},
|
||||
}
|
||||
}
|
||||
const lineTotal = item.quantity * item.unit_price
|
||||
const lineVat = roundOre((lineTotal * itemRate) / 100)
|
||||
vatAmount += lineVat
|
||||
items.push({
|
||||
sort_order: i,
|
||||
description: item.description,
|
||||
quantity: item.quantity,
|
||||
unit: item.unit,
|
||||
unit_price: item.unit_price,
|
||||
line_total: lineTotal,
|
||||
vat_rate: itemRate,
|
||||
vat_amount: lineVat,
|
||||
})
|
||||
}
|
||||
|
||||
const subtotal = input.items.reduce((sum, item) => sum + item.quantity * item.unit_price, 0)
|
||||
const total = roundOre(subtotal + vatAmount)
|
||||
|
||||
const uniqueRates = new Set(input.items.map((item) => item.vat_rate ?? vatRules.rate))
|
||||
const isMixedRate = uniqueRates.size > 1
|
||||
const vatRate = isMixedRate ? null : (uniqueRates.values().next().value ?? vatRules.rate)
|
||||
|
||||
// Foreign currency: convert using the rate on the INVOICE date (ML 7 kap 7§),
|
||||
// not today's rate. Refuse rather than fall through to a silent 1:1 booking.
|
||||
let exchangeRate: number | null = null
|
||||
let exchangeRateDate: string | null = null
|
||||
let subtotalSek: number | null = null
|
||||
let vatAmountSek: number | null = null
|
||||
let totalSek: number | null = null
|
||||
if (input.currency !== 'SEK') {
|
||||
const rateData = await fetchExchangeRate(
|
||||
input.currency as Parameters<typeof fetchExchangeRate>[0],
|
||||
new Date(input.invoice_date),
|
||||
)
|
||||
if (!rateData) {
|
||||
return { ok: false, failure: { code: 'fx_rate_unavailable', currency: input.currency, invoiceDate: input.invoice_date } }
|
||||
}
|
||||
exchangeRate = rateData.rate
|
||||
exchangeRateDate = rateData.date
|
||||
subtotalSek = convertToSEK(subtotal, exchangeRate)
|
||||
vatAmountSek = convertToSEK(vatAmount, exchangeRate)
|
||||
totalSek = convertToSEK(total, exchangeRate)
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
draft: {
|
||||
customer: c,
|
||||
items,
|
||||
subtotal,
|
||||
vatAmount,
|
||||
total,
|
||||
subtotalSek,
|
||||
vatAmountSek,
|
||||
totalSek,
|
||||
exchangeRate,
|
||||
exchangeRateDate,
|
||||
currency: input.currency,
|
||||
vatTreatment: vatRules.treatment,
|
||||
momsRuta: vatRules.momsRuta,
|
||||
reverseChargeText: vatRules.reverseChargeText || null,
|
||||
vatRate,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a received self-billing invoice: insert the row, its items, and
|
||||
* (under faktureringsmetoden) book the registration entry. Rolls back on any
|
||||
* failure. Returns the created invoice or a structured failure. Re-throws an
|
||||
* unexpected booking error (e.g. a period-lock trigger) after rolling back so
|
||||
* the caller can map it to its structured envelope.
|
||||
*/
|
||||
export async function createSelfBilledSaleInvoice(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
userId: string,
|
||||
input: SelfBilledSaleInput,
|
||||
): Promise<{ ok: true; invoice: Invoice } | { ok: false; failure: SelfBilledSaleFailure }> {
|
||||
const resolved = await resolveSelfBilledSaleDraft(supabase, companyId, input)
|
||||
if (!resolved.ok) return resolved
|
||||
const { draft } = resolved
|
||||
|
||||
const { data: invoice, error: invoiceError } = await supabase
|
||||
.from('invoices')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
company_id: companyId,
|
||||
customer_id: input.customer_id,
|
||||
// No own number: the counterparty's number lives in external_invoice_number.
|
||||
invoice_number: null,
|
||||
is_self_billed: true,
|
||||
external_invoice_number: input.external_invoice_number,
|
||||
self_billing_agreement_ref: input.self_billing_agreement_ref ?? null,
|
||||
received_date: input.received_date,
|
||||
invoice_date: input.invoice_date,
|
||||
due_date: input.due_date,
|
||||
// Booked + awaiting/with payment: never a draft.
|
||||
status: 'sent',
|
||||
currency: input.currency,
|
||||
exchange_rate: draft.exchangeRate,
|
||||
exchange_rate_date: draft.exchangeRateDate,
|
||||
subtotal: draft.subtotal,
|
||||
subtotal_sek: draft.subtotalSek,
|
||||
vat_amount: draft.vatAmount,
|
||||
vat_amount_sek: draft.vatAmountSek,
|
||||
total: draft.total,
|
||||
total_sek: draft.totalSek,
|
||||
remaining_amount: draft.total,
|
||||
vat_treatment: draft.vatTreatment,
|
||||
vat_rate: draft.vatRate,
|
||||
moms_ruta: draft.momsRuta,
|
||||
reverse_charge_text: draft.reverseChargeText,
|
||||
notes: input.notes,
|
||||
document_type: 'invoice',
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (invoiceError || !invoice) {
|
||||
log.error('self-billed invoice insert failed', invoiceError as Error)
|
||||
return {
|
||||
ok: false,
|
||||
failure: { code: 'insert_failed', pgCode: invoiceError?.code, pgMessage: invoiceError?.message },
|
||||
}
|
||||
}
|
||||
|
||||
const itemRows = draft.items.map((item) => ({
|
||||
invoice_id: invoice.id,
|
||||
sort_order: item.sort_order,
|
||||
description: item.description,
|
||||
quantity: item.quantity,
|
||||
unit: item.unit,
|
||||
unit_price: item.unit_price,
|
||||
line_total: item.line_total,
|
||||
vat_rate: item.vat_rate,
|
||||
vat_amount: item.vat_amount,
|
||||
}))
|
||||
|
||||
const { error: itemsError } = await supabase.from('invoice_items').insert(itemRows)
|
||||
if (itemsError) {
|
||||
// Items insert failed: remove the orphaned invoice header.
|
||||
await supabase.from('invoices').delete().eq('id', invoice.id)
|
||||
log.error('self-billed invoice items insert failed; rolled back', itemsError, { invoiceId: invoice.id })
|
||||
return { ok: false, failure: { code: 'items_failed', pgCode: itemsError.code, pgMessage: itemsError.message } }
|
||||
}
|
||||
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('accounting_method, entity_type')
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
const accountingMethod = settings?.accounting_method || 'accrual'
|
||||
const entityType = (settings?.entity_type as EntityType) || 'enskild_firma'
|
||||
|
||||
const { data: completeInvoice } = await supabase
|
||||
.from('invoices')
|
||||
.select('*, customer:customers(*), items:invoice_items(*)')
|
||||
.eq('id', invoice.id)
|
||||
.maybeSingle()
|
||||
|
||||
// Faktureringsmetoden: book the registration entry now (Debit 1510, Credit
|
||||
// 30xx + 26xx). Kontantmetoden: leave unbooked until payment.
|
||||
if (accountingMethod === 'accrual') {
|
||||
if (!completeInvoice) {
|
||||
await supabase.from('invoices').delete().eq('id', invoice.id)
|
||||
log.error('self-billed invoice re-fetch returned no row before booking; rolled back', undefined, {
|
||||
invoiceId: invoice.id,
|
||||
})
|
||||
return { ok: false, failure: { code: 'insert_failed', stage: 'refetch_before_booking' } }
|
||||
}
|
||||
try {
|
||||
const journalEntry = await createInvoiceJournalEntry(
|
||||
supabase,
|
||||
companyId,
|
||||
userId,
|
||||
completeInvoice as Invoice,
|
||||
entityType,
|
||||
draft.customer.name,
|
||||
{ descriptionPrefix: 'Självfaktura', numberOverride: input.external_invoice_number },
|
||||
)
|
||||
if (!journalEntry) {
|
||||
// No open fiscal period: roll back so we never leave an unbooked sale.
|
||||
await supabase.from('invoices').delete().eq('id', invoice.id)
|
||||
return { ok: false, failure: { code: 'no_fiscal_period' } }
|
||||
}
|
||||
const { error: linkError } = await supabase
|
||||
.from('invoices')
|
||||
.update({ journal_entry_id: journalEntry.id })
|
||||
.eq('id', invoice.id)
|
||||
.eq('company_id', companyId)
|
||||
if (linkError) {
|
||||
// The verifikat is committed (immutable): don't roll it back over a
|
||||
// failed convenience link. Log loudly.
|
||||
log.error('self-billed invoice booked but journal_entry_id link failed', linkError, {
|
||||
invoiceId: invoice.id,
|
||||
journalEntryId: journalEntry.id,
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
await supabase.from('invoices').delete().eq('id', invoice.id)
|
||||
log.error('failed to book self-billed invoice; rolled back', err as Error, { invoiceId: invoice.id })
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
const { data: finalInvoice } = await supabase
|
||||
.from('invoices')
|
||||
.select('*, customer:customers(*), items:invoice_items(*)')
|
||||
.eq('id', invoice.id)
|
||||
.maybeSingle()
|
||||
|
||||
const responseInvoice = (finalInvoice ?? completeInvoice ?? invoice) as Invoice
|
||||
|
||||
await eventBus.emit({
|
||||
type: 'invoice.created',
|
||||
payload: { invoice: responseInvoice, companyId, userId },
|
||||
})
|
||||
|
||||
return { ok: true, invoice: responseInvoice }
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { buildManualFilingRows } from '@/lib/reports/vat-manual-filing'
|
||||
import type { VatDeclarationRutor } from '@/types'
|
||||
|
||||
/** All rutor zeroed; override the ones a case cares about. */
|
||||
function makeRutor(overrides: Partial<VatDeclarationRutor> = {}): VatDeclarationRutor {
|
||||
return {
|
||||
ruta05: 0, ruta06: 0, ruta07: 0, ruta08: 0,
|
||||
ruta10: 0, ruta11: 0, ruta12: 0,
|
||||
ruta20: 0, ruta21: 0, ruta22: 0, ruta23: 0, ruta24: 0,
|
||||
ruta30: 0, ruta31: 0, ruta32: 0,
|
||||
ruta35: 0, ruta36: 0, ruta37: 0, ruta38: 0, ruta39: 0, ruta40: 0, ruta41: 0, ruta42: 0,
|
||||
ruta48: 0,
|
||||
ruta49: 0,
|
||||
ruta50: 0, ruta60: 0, ruta61: 0, ruta62: 0,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('buildManualFilingRows', () => {
|
||||
it('lists only populated rutor plus 48, and appends the 49 net last', () => {
|
||||
const rows = buildManualFilingRows(
|
||||
makeRutor({ ruta05: 100000, ruta10: 25000, ruta48: 3200 }),
|
||||
)
|
||||
expect(rows.map((r) => r.ruta)).toEqual(['05', '10', '48', '49'])
|
||||
// Untouched reverse-charge / EU rutor stay out.
|
||||
expect(rows.some((r) => r.ruta === '21')).toBe(false)
|
||||
const net = rows.at(-1)!
|
||||
expect(net).toMatchObject({ ruta: '49', label: 'Moms att betala', amount: 21800, isNet: true })
|
||||
})
|
||||
|
||||
it('renders reverse-charge rutor when set', () => {
|
||||
const rows = buildManualFilingRows(
|
||||
makeRutor({ ruta21: 5000, ruta24: 2000, ruta30: 1250, ruta48: 1250 }),
|
||||
)
|
||||
expect(rows.map((r) => r.ruta)).toEqual(['21', '24', '30', '48', '49'])
|
||||
expect(rows.find((r) => r.ruta === '30')?.label).toBe('Utgående moms 25% (omvänd skattskyldighet)')
|
||||
})
|
||||
|
||||
it('labels ruta 49 "att betala" when the net is positive', () => {
|
||||
const rows = buildManualFilingRows(makeRutor({ ruta10: 9300, ruta48: 0 }))
|
||||
expect(rows.at(-1)).toMatchObject({ ruta: '49', label: 'Moms att betala', amount: 9300 })
|
||||
})
|
||||
|
||||
it('labels ruta 49 "att återfå" when the net is negative, with an absolute amount', () => {
|
||||
const rows = buildManualFilingRows(makeRutor({ ruta48: 4200 }))
|
||||
const net = rows.at(-1)!
|
||||
expect(net).toMatchObject({ ruta: '49', label: 'Moms att återfå', amount: 4200 })
|
||||
expect(net.amount).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
|
||||
it('truncates each ruta to whole kronor (öretal faller bort) and recomputes ruta 49 from the truncated values', () => {
|
||||
// 252,50 kr output VAT -> 252 kr filed (öre dropped, SFL 22:1, not rounded
|
||||
// up to 253); net follows the truncated values.
|
||||
const rows = buildManualFilingRows(makeRutor({ ruta05: 1010, ruta10: 252.5, ruta48: 0 }))
|
||||
expect(rows.find((r) => r.ruta === '10')?.amount).toBe(252)
|
||||
expect(rows.at(-1)).toMatchObject({ ruta: '49', amount: 252 })
|
||||
// No öre anywhere.
|
||||
expect(rows.every((r) => Number.isInteger(r.amount))).toBe(true)
|
||||
})
|
||||
|
||||
it('drops öre on the input ruta too, so a .9 input VAT does not round up the deduction', () => {
|
||||
// 100,90 kr input VAT -> 100 kr deducted; net = 300 output - 100 = 200.
|
||||
const rows = buildManualFilingRows(makeRutor({ ruta10: 300, ruta48: 100.9 }))
|
||||
expect(rows.find((r) => r.ruta === '48')?.amount).toBe(100)
|
||||
expect(rows.at(-1)).toMatchObject({ ruta: '49', amount: 200 })
|
||||
})
|
||||
|
||||
it('keeps ruta 48 and 49 even when everything is zero', () => {
|
||||
const rows = buildManualFilingRows(makeRutor())
|
||||
expect(rows.map((r) => r.ruta)).toEqual(['48', '49'])
|
||||
expect(rows.at(-1)).toMatchObject({ ruta: '49', label: 'Moms att betala', amount: 0 })
|
||||
})
|
||||
|
||||
it('orders rutor by ascending ruta number, with 49 always last (even past import rutor 60-62)', () => {
|
||||
const rows = buildManualFilingRows(makeRutor({ ruta10: 50, ruta60: 100, ruta48: 0 }))
|
||||
// 48 sorts to its numeric position (between 10 and 60); 49 is appended last.
|
||||
expect(rows.map((r) => r.ruta)).toEqual(['10', '48', '60', '49'])
|
||||
// Net includes import output VAT (ruta 60) per the SKV 4700 formula.
|
||||
expect(rows.at(-1)).toMatchObject({ ruta: '49', amount: 150 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,215 @@
|
||||
import {
|
||||
Document,
|
||||
Page,
|
||||
Text,
|
||||
View,
|
||||
StyleSheet,
|
||||
} from '@react-pdf/renderer'
|
||||
import type { CompanySettings } from '@/types'
|
||||
import type { ManualFilingRow } from '@/lib/reports/vat-manual-filing'
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
page: {
|
||||
paddingTop: 40,
|
||||
paddingHorizontal: 40,
|
||||
paddingBottom: 90,
|
||||
fontSize: 10,
|
||||
fontFamily: 'Helvetica',
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: 16,
|
||||
paddingBottom: 14,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#d4d4d4',
|
||||
},
|
||||
titleBlock: { flex: 1 },
|
||||
title: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
color: '#1a1a1a',
|
||||
marginBottom: 4,
|
||||
},
|
||||
subtitle: { fontSize: 11, color: '#333', marginBottom: 2 },
|
||||
period: { fontSize: 10, color: '#666' },
|
||||
companyInfo: { textAlign: 'right' },
|
||||
companyName: { fontSize: 11, fontWeight: 'bold', marginBottom: 2 },
|
||||
companyMeta: { fontSize: 9, color: '#666' },
|
||||
note: {
|
||||
marginBottom: 18,
|
||||
paddingVertical: 6,
|
||||
paddingHorizontal: 10,
|
||||
borderWidth: 0.8,
|
||||
borderColor: '#b45309',
|
||||
backgroundColor: '#fef3c7',
|
||||
borderRadius: 3,
|
||||
},
|
||||
noteText: { fontSize: 8, color: '#78350f', lineHeight: 1.3 },
|
||||
tableHeadRow: {
|
||||
flexDirection: 'row',
|
||||
paddingBottom: 4,
|
||||
marginBottom: 2,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#1a1a1a',
|
||||
},
|
||||
headRuta: { width: 44, fontSize: 8, fontWeight: 'bold', color: '#444' },
|
||||
headLabel: { flex: 1, fontSize: 8, fontWeight: 'bold', color: '#444', paddingRight: 12 },
|
||||
headAmount: { width: 110, fontSize: 8, fontWeight: 'bold', color: '#444', textAlign: 'right' },
|
||||
row: { flexDirection: 'row', paddingVertical: 3 },
|
||||
colRuta: { width: 44, fontFamily: 'Courier', color: '#666' },
|
||||
colLabel: { flex: 1, color: '#1a1a1a', paddingRight: 12 },
|
||||
colAmount: { width: 110, textAlign: 'right', fontFamily: 'Courier', color: '#1a1a1a' },
|
||||
netRow: {
|
||||
flexDirection: 'row',
|
||||
paddingVertical: 6,
|
||||
marginTop: 6,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#1a1a1a',
|
||||
},
|
||||
netLabel: { flex: 1, fontWeight: 'bold', fontSize: 11, paddingLeft: 44 },
|
||||
netAmount: {
|
||||
width: 110,
|
||||
textAlign: 'right',
|
||||
fontFamily: 'Courier',
|
||||
fontWeight: 'bold',
|
||||
fontSize: 11,
|
||||
},
|
||||
footer: {
|
||||
position: 'absolute',
|
||||
bottom: 24,
|
||||
left: 40,
|
||||
right: 40,
|
||||
borderTopWidth: 0.5,
|
||||
borderTopColor: '#d4d4d4',
|
||||
paddingTop: 6,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
footerText: { fontSize: 8, color: '#888' },
|
||||
})
|
||||
|
||||
// Hela kronor: the momsdeklaration is filed in whole kronor, and the rows are
|
||||
// already truncated to whole kronor (öretal faller bort, see
|
||||
// buildManualFilingRows), so format with no decimals.
|
||||
function formatKr(amount: number): string {
|
||||
return new Intl.NumberFormat('sv-SE', { maximumFractionDigits: 0 }).format(amount)
|
||||
}
|
||||
|
||||
function formatOrgNumber(orgNumber: string): string {
|
||||
const cleaned = orgNumber.replace(/\D/g, '')
|
||||
if (cleaned.length === 10) {
|
||||
return `${cleaned.slice(0, 6)}-${cleaned.slice(6)}`
|
||||
}
|
||||
return orgNumber
|
||||
}
|
||||
|
||||
function formatDateSv(iso: string): string {
|
||||
if (!iso) return ''
|
||||
return new Date(iso).toLocaleDateString('sv-SE')
|
||||
}
|
||||
|
||||
interface VatDeclarationPDFProps {
|
||||
rows: ManualFilingRow[]
|
||||
period: { start: string; end: string }
|
||||
periodLabel: string
|
||||
company: CompanySettings
|
||||
generatedAt: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A momsdeklaration document (SKV 4700 layout) for manual filing at
|
||||
* skatteverket.se. Amounts are in hela kronor, matching what the user types
|
||||
* into the form. This is a reading/record copy, NOT a file that is uploaded to
|
||||
* Skatteverket, moms has no file-submission format; the machine channel is the
|
||||
* Skatteverket API. The disclaimer says so explicitly.
|
||||
*
|
||||
* Swedish-only: momsdeklaration ruta labels are Skatteverket form labels.
|
||||
*/
|
||||
export function VatDeclarationPDF({
|
||||
rows,
|
||||
period,
|
||||
periodLabel,
|
||||
company,
|
||||
generatedAt,
|
||||
}: VatDeclarationPDFProps) {
|
||||
const companyDisplayName = company.company_name || ''
|
||||
|
||||
return (
|
||||
<Document>
|
||||
<Page size="A4" style={styles.page}>
|
||||
<View style={styles.header} fixed>
|
||||
<View style={styles.titleBlock}>
|
||||
<Text style={styles.title}>Momsdeklaration</Text>
|
||||
{periodLabel && <Text style={styles.subtitle}>{periodLabel}</Text>}
|
||||
{period.start && period.end && (
|
||||
<Text style={styles.period}>
|
||||
Period: {formatDateSv(period.start)}: {formatDateSv(period.end)}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.companyInfo}>
|
||||
{companyDisplayName && (
|
||||
<Text style={styles.companyName}>{companyDisplayName}</Text>
|
||||
)}
|
||||
{company.org_number && (
|
||||
<Text style={styles.companyMeta}>Org.nr: {formatOrgNumber(company.org_number)}</Text>
|
||||
)}
|
||||
{company.vat_number && (
|
||||
<Text style={styles.companyMeta}>Moms-nr: {company.vat_number}</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.note}>
|
||||
<Text style={styles.noteText}>
|
||||
Underlag för manuell inlämning. Detta är inte en inlämnad deklaration.
|
||||
Logga in på skatteverket.se med BankID, öppna Moms- och
|
||||
arbetsgivardeklarationer och skriv in beloppen nedan. Belopp i hela kronor.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.tableHeadRow} fixed>
|
||||
<Text style={styles.headRuta}>Ruta</Text>
|
||||
<Text style={styles.headLabel}>Beskrivning</Text>
|
||||
<Text style={styles.headAmount}>Belopp (kr)</Text>
|
||||
</View>
|
||||
|
||||
{rows
|
||||
.filter((r) => !r.isNet)
|
||||
.map((r) => (
|
||||
<View key={r.ruta} style={styles.row} wrap={false}>
|
||||
<Text style={styles.colRuta}>{r.ruta}</Text>
|
||||
<Text style={styles.colLabel}>{r.label}</Text>
|
||||
<Text style={styles.colAmount}>{formatKr(r.amount)}</Text>
|
||||
</View>
|
||||
))}
|
||||
|
||||
{rows
|
||||
.filter((r) => r.isNet)
|
||||
.map((r) => (
|
||||
<View key={r.ruta} style={styles.netRow} wrap={false}>
|
||||
<Text style={styles.netLabel}>
|
||||
{r.ruta} {r.label}
|
||||
</Text>
|
||||
<Text style={styles.netAmount}>{formatKr(r.amount)}</Text>
|
||||
</View>
|
||||
))}
|
||||
|
||||
<View style={styles.footer} fixed>
|
||||
<Text style={styles.footerText}>
|
||||
{companyDisplayName}
|
||||
{company.org_number ? ` · ${formatOrgNumber(company.org_number)}` : ''}
|
||||
</Text>
|
||||
<Text
|
||||
style={styles.footerText}
|
||||
render={({ pageNumber, totalPages }) =>
|
||||
`Genererad ${formatDateSv(generatedAt)} · Sida ${pageNumber} av ${totalPages}`
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</Page>
|
||||
</Document>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { VAT_RUTA_LABELS, type VatDeclarationRutor } from '@/types'
|
||||
|
||||
export interface ManualFilingRow {
|
||||
/** Two-digit ruta number, e.g. '05', '10', '49'. */
|
||||
ruta: string
|
||||
label: string
|
||||
/**
|
||||
* Amount in whole kronor (hela kronor), as filed. For ruta 49 (the net) this
|
||||
* is the absolute value; the direction is carried by the label.
|
||||
*/
|
||||
amount: number
|
||||
/** True only for the ruta 49 net row (Moms att betala / återfå). */
|
||||
isNet?: boolean
|
||||
}
|
||||
|
||||
// The output-VAT rutor that sum into ruta 49 per the SKV 4700 formula
|
||||
// (swedish-vat reference, Section G): (10+11+12+30+31+32+60+61+62) - 48 = 49.
|
||||
const OUTPUT_VAT_RUTOR: (keyof VatDeclarationRutor)[] = [
|
||||
'ruta10', 'ruta11', 'ruta12',
|
||||
'ruta30', 'ruta31', 'ruta32',
|
||||
'ruta60', 'ruta61', 'ruta62',
|
||||
]
|
||||
|
||||
/**
|
||||
* Builds the momsdeklaration rows for manual filing at skatteverket.se, in
|
||||
* hela kronor.
|
||||
*
|
||||
* Skatteverket files whole kronor with no öre, so each ruta is truncated to a
|
||||
* whole krona (öretal faller bort per SFL 22 kap 1 §: the öre are dropped, not
|
||||
* rounded to nearest) and ruta 49 (the net) is recomputed from the truncated
|
||||
* output/input rutor, not from the pre-computed öre value, so the document's
|
||||
* arithmetic ties out exactly to what the user types into the form. This
|
||||
* whole-krona truncation is intentional and specific to the filing document; it
|
||||
* is NOT bookkeeping math (nothing here is posted), so the usual öre-precision
|
||||
* money rule (Math.round(x*100)/100) does not apply. It also matches the SRU
|
||||
* income-tax filing path, which drops öre under the same statute.
|
||||
*
|
||||
* Only populated rutor are included, plus ruta 48 and the ruta 49 net (always),
|
||||
* so a manual filer sees every box that needs a value and nothing that doesn't.
|
||||
* Ruta 49 is always rendered last, mirroring its position on the SKV 4700 form.
|
||||
*
|
||||
* Swedish-only by design: these are Skatteverket form labels (VAT_RUTA_LABELS),
|
||||
* which stay Swedish in both locales.
|
||||
*/
|
||||
export function buildManualFilingRows(rutor: VatDeclarationRutor): ManualFilingRow[] {
|
||||
// Truncate toward zero: öretal faller bort (SFL 22 kap 1 §), never round up.
|
||||
const kr = (key: keyof VatDeclarationRutor): number => Math.trunc(rutor[key] ?? 0)
|
||||
|
||||
const outputVat = OUTPUT_VAT_RUTOR.reduce((sum, key) => sum + kr(key), 0)
|
||||
const net = outputVat - kr('ruta48')
|
||||
|
||||
// Every ruta except 49, in ascending form order; 49 is appended last below.
|
||||
const keys = (Object.keys(VAT_RUTA_LABELS) as (keyof VatDeclarationRutor)[])
|
||||
.filter((key) => key !== 'ruta49')
|
||||
.sort((a, b) => Number(a.slice(4)) - Number(b.slice(4)))
|
||||
|
||||
const rows: ManualFilingRow[] = []
|
||||
for (const key of keys) {
|
||||
const amount = kr(key)
|
||||
// Always surface ruta 48 (deductible input VAT); otherwise only populated
|
||||
// rutor so the reference stays short.
|
||||
if (key !== 'ruta48' && amount === 0) continue
|
||||
rows.push({ ruta: key.slice(4), label: VAT_RUTA_LABELS[key], amount })
|
||||
}
|
||||
|
||||
rows.push({
|
||||
ruta: '49',
|
||||
label: net >= 0 ? 'Moms att betala' : 'Moms att återfå',
|
||||
amount: Math.abs(net),
|
||||
isNet: true,
|
||||
})
|
||||
|
||||
return rows
|
||||
}
|
||||
@@ -113,3 +113,17 @@ describe('encryption roundtrip', () => {
|
||||
expect(a).not.toBe(b)
|
||||
})
|
||||
})
|
||||
|
||||
describe('decryptPersonnummer tolerance for unencrypted rows', () => {
|
||||
it('passes a raw 12-digit personnummer through unchanged (no crash)', () => {
|
||||
// A row stored unencrypted (pre-fix v1 create, or a seed) would otherwise
|
||||
// be sliced as iv/ciphertext/tag and throw ERR_CRYPTO_INVALID_AUTH_TAG
|
||||
// ("Invalid authentication tag length: 6"), 500-ing the whole roster.
|
||||
expect(decryptPersonnummer('190001010000')).toBe('190001010000')
|
||||
})
|
||||
|
||||
it('still decrypts genuine ciphertext', () => {
|
||||
const enc = encryptPersonnummer('199001019802')
|
||||
expect(decryptPersonnummer(enc)).toBe('199001019802')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -166,7 +166,7 @@ export async function generateAgiDeclaration(
|
||||
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('org_number, phone, email')
|
||||
.select('company_name, org_number, phone, email')
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
@@ -192,12 +192,15 @@ export async function generateAgiDeclaration(
|
||||
}
|
||||
|
||||
// 4. Build AGI input shapes.
|
||||
// Employer name on the arbetsgivardeklaration follows the current company
|
||||
// name (company_settings.company_name), not the frozen onboarding companies.name.
|
||||
const companyName = settings?.company_name || company.name
|
||||
const companyData: AGICompanyData = {
|
||||
orgNumber: (settings?.org_number || company.org_number || '').trim(),
|
||||
companyName: company.name,
|
||||
companyName,
|
||||
periodYear: run.period_year,
|
||||
periodMonth: run.period_month,
|
||||
contactName: (profile?.full_name || company.name || '').trim(),
|
||||
contactName: (profile?.full_name || companyName || '').trim(),
|
||||
contactPhone: (settings?.phone || '').trim(),
|
||||
contactEmail: (settings?.email || profile?.email || userEmail || '').trim(),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
normalizeBankNumber,
|
||||
isValidClearing,
|
||||
isValidAccount,
|
||||
validateEmployeeBankAccount,
|
||||
lookupBankByClearing,
|
||||
} from '@/lib/salary/payment/bank-account'
|
||||
|
||||
describe('normalizeBankNumber', () => {
|
||||
it('strips spaces and hyphens', () => {
|
||||
expect(normalizeBankNumber('8327-9')).toBe('83279')
|
||||
expect(normalizeBankNumber('1234 5678')).toBe('12345678')
|
||||
})
|
||||
it('handles null/undefined', () => {
|
||||
expect(normalizeBankNumber(null)).toBe('')
|
||||
expect(normalizeBankNumber(undefined)).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isValidClearing', () => {
|
||||
it('accepts 4-digit clearings', () => {
|
||||
expect(isValidClearing('1234')).toBe(true)
|
||||
expect(isValidClearing('6000')).toBe(true)
|
||||
})
|
||||
it('accepts 5-digit Swedbank clearings starting with 8', () => {
|
||||
expect(isValidClearing('83279')).toBe(true)
|
||||
})
|
||||
it('rejects 5-digit clearings not starting with 8', () => {
|
||||
expect(isValidClearing('12345')).toBe(false)
|
||||
})
|
||||
it('rejects too short / non-numeric', () => {
|
||||
expect(isValidClearing('123')).toBe(false)
|
||||
expect(isValidClearing('abcd')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isValidAccount', () => {
|
||||
it('accepts 5-11 digit accounts', () => {
|
||||
expect(isValidAccount('12345')).toBe(true)
|
||||
expect(isValidAccount('1234567')).toBe(true)
|
||||
expect(isValidAccount('17082042825')).toBe(true) // 11-digit Nordea personkonto
|
||||
})
|
||||
it('rejects too short / too long / non-numeric', () => {
|
||||
expect(isValidAccount('1234')).toBe(false)
|
||||
expect(isValidAccount('123456789012')).toBe(false)
|
||||
expect(isValidAccount('12a4567')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateEmployeeBankAccount', () => {
|
||||
it('allows both empty (bank details optional until a salary run)', () => {
|
||||
expect(validateEmployeeBankAccount('', '')).toEqual([])
|
||||
expect(validateEmployeeBankAccount(null, undefined)).toEqual([])
|
||||
})
|
||||
|
||||
it('accepts a valid 4-digit clearing + account pair', () => {
|
||||
expect(validateEmployeeBankAccount('6000', '1234567')).toEqual([])
|
||||
})
|
||||
|
||||
it('accepts a Swedbank clearing written with a hyphen', () => {
|
||||
expect(validateEmployeeBankAccount('8327-9', '1234567')).toEqual([])
|
||||
})
|
||||
|
||||
it('flags a lone clearing as needing an account', () => {
|
||||
const issues = validateEmployeeBankAccount('6000', '')
|
||||
expect(issues.map((i) => i.code)).toContain('account_required')
|
||||
expect(issues[0].field).toBe('bank_account_number')
|
||||
})
|
||||
|
||||
it('flags a lone account as needing a clearing', () => {
|
||||
const issues = validateEmployeeBankAccount('', '1234567')
|
||||
expect(issues.map((i) => i.code)).toContain('clearing_required')
|
||||
})
|
||||
|
||||
it('flags a malformed clearing', () => {
|
||||
const issues = validateEmployeeBankAccount('12', '1234567')
|
||||
expect(issues.map((i) => i.code)).toContain('clearing_format')
|
||||
})
|
||||
|
||||
it('flags a malformed account', () => {
|
||||
const issues = validateEmployeeBankAccount('6000', '12')
|
||||
expect(issues.map((i) => i.code)).toContain('account_format')
|
||||
})
|
||||
})
|
||||
|
||||
describe('lookupBankByClearing', () => {
|
||||
it('maps the major, unambiguous ranges', () => {
|
||||
expect(lookupBankByClearing('5000')).toBe('SEB')
|
||||
expect(lookupBankByClearing('6789')).toBe('Handelsbanken')
|
||||
expect(lookupBankByClearing('7123')).toBe('Swedbank')
|
||||
expect(lookupBankByClearing('3000')).toBe('Nordea')
|
||||
})
|
||||
it('maps a 5-digit Swedbank clearing via its 8xxx prefix', () => {
|
||||
expect(lookupBankByClearing('83279')).toBe('Swedbank/Sparbanken')
|
||||
})
|
||||
it('returns null for unknown ranges rather than guessing', () => {
|
||||
expect(lookupBankByClearing('9999')).toBeNull()
|
||||
expect(lookupBankByClearing('123')).toBeNull()
|
||||
expect(lookupBankByClearing('')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Swedish employee bank-account validation (clearing + kontonummer).
|
||||
*
|
||||
* This is the single source of truth for validating the bank details entered
|
||||
* on the "Anställda" employee form, shared by the client forms and the server
|
||||
* Zod schema / API routes. It mirrors what the payout layer
|
||||
* (`bg-lb-generator.ts` `encodeReceiverAccount`) can actually encode, so a
|
||||
* typo is caught at data entry instead of surfacing ~20 steps later when the
|
||||
* Bankgirot LB file is generated (or never, on the SEPA/pain.001 path).
|
||||
*
|
||||
* Scope (deliberately structural, per the product decision):
|
||||
* - Clearing must be 4 digits, OR 5 digits starting with 8 (Swedbank/
|
||||
* Sparbanken are the only 5-digit clearings in the Swedish system).
|
||||
* - Account number must be 5-11 digits (covers ordinary accounts through the
|
||||
* 11-digit Nordea personkonto that the generator special-cases).
|
||||
* - Both fields are optional together (bank details may be filled in before
|
||||
* the first salary run), but a clearing without an account (or vice versa)
|
||||
* cannot be paid out and is rejected.
|
||||
*
|
||||
* Per-bank mod10/mod11 checksum validation is intentionally NOT done here: it
|
||||
* requires the official per-bank clearing-range table, and getting that table
|
||||
* slightly wrong produces false rejections of valid accounts. It is planned as
|
||||
* a separate, non-blocking "soft warning" follow-up once the data is vetted.
|
||||
*/
|
||||
|
||||
import { validateSwedishAccountChecksum, type AccountChecksumResult } from '@/lib/bankgiro/account-number'
|
||||
|
||||
export type { AccountChecksumResult }
|
||||
|
||||
/** Strip spaces and hyphens so "8327-9" / "1234 5678" become plain digits. */
|
||||
export function normalizeBankNumber(input: string | null | undefined): string {
|
||||
return (input ?? '').replace(/[\s-]/g, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-blocking check-digit ("kontrollsiffra") result for an employee's
|
||||
* clearing/account pair. 'invalid' surfaces an advisory warning in the form,
|
||||
* but never blocks saving: the check digit catches typos, it does not prove
|
||||
* the account exists. Unrecognised clearings return 'unknown' (no warning).
|
||||
*/
|
||||
export function checkEmployeeAccountChecksum(
|
||||
clearing: string | null | undefined,
|
||||
account: string | null | undefined,
|
||||
): AccountChecksumResult {
|
||||
return validateSwedishAccountChecksum(clearing, account)
|
||||
}
|
||||
|
||||
/** Advisory (non-blocking) message shown when the check digit looks wrong. */
|
||||
export const BANK_CHECKSUM_WARNING_SV =
|
||||
'Kontrollsiffran verkar inte stämma. Dubbelkolla numret, du kan spara ändå.'
|
||||
|
||||
/** 4-digit clearing, or a 5-digit Swedbank/Sparbanken clearing starting with 8. */
|
||||
export function isValidClearing(clearing: string): boolean {
|
||||
return /^(\d{4}|8\d{4})$/.test(clearing)
|
||||
}
|
||||
|
||||
/** Account number: 5-11 digits (through Nordea personkonto's 11 digits). */
|
||||
export function isValidAccount(account: string): boolean {
|
||||
return /^\d{5,11}$/.test(account)
|
||||
}
|
||||
|
||||
export type BankIssueCode =
|
||||
| 'clearing_format'
|
||||
| 'account_format'
|
||||
| 'account_required'
|
||||
| 'clearing_required'
|
||||
|
||||
export interface BankIssue {
|
||||
/** Matches the form field name and the Zod path for this issue. */
|
||||
field: 'clearing_number' | 'bank_account_number'
|
||||
code: BankIssueCode
|
||||
/** Swedish message (used server-side and by the hardcoded-Swedish dialog). */
|
||||
message: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Swedish issue messages. The i18n edit page maps `code` to its own
|
||||
* `salary_employee.bank_error_*` keys; the create dialog and the server use
|
||||
* these strings directly (schema messages in this repo are Swedish literals).
|
||||
*/
|
||||
export const BANK_ISSUE_MESSAGES_SV: Record<BankIssueCode, string> = {
|
||||
clearing_format:
|
||||
'Clearingnummer måste vara 4 siffror (eller 5 siffror som börjar med 8 för Swedbank)',
|
||||
account_format: 'Kontonummer måste vara 5-11 siffror',
|
||||
account_required: 'Kontonummer krävs när clearingnummer har angetts',
|
||||
clearing_required: 'Clearingnummer krävs när kontonummer har angetts',
|
||||
}
|
||||
|
||||
function issue(field: BankIssue['field'], code: BankIssueCode): BankIssue {
|
||||
return { field, code, message: BANK_ISSUE_MESSAGES_SV[code] }
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a clearing/account pair. Returns an empty array when valid (which
|
||||
* includes both fields being empty). Inputs may contain spaces/hyphens; they
|
||||
* are normalized before checking.
|
||||
*/
|
||||
export function validateEmployeeBankAccount(
|
||||
clearingRaw: string | null | undefined,
|
||||
accountRaw: string | null | undefined,
|
||||
): BankIssue[] {
|
||||
const clearing = normalizeBankNumber(clearingRaw)
|
||||
const account = normalizeBankNumber(accountRaw)
|
||||
|
||||
// Both empty: bank details are optional until a salary run is approved.
|
||||
if (!clearing && !account) return []
|
||||
|
||||
const issues: BankIssue[] = []
|
||||
|
||||
if (clearing && !isValidClearing(clearing)) {
|
||||
issues.push(issue('clearing_number', 'clearing_format'))
|
||||
}
|
||||
if (account && !isValidAccount(account)) {
|
||||
issues.push(issue('bank_account_number', 'account_format'))
|
||||
}
|
||||
|
||||
// Both-or-neither: a lone clearing or a lone account cannot be paid out.
|
||||
if (clearing && !account) issues.push(issue('bank_account_number', 'account_required'))
|
||||
if (account && !clearing) issues.push(issue('clearing_number', 'clearing_required'))
|
||||
|
||||
return issues
|
||||
}
|
||||
|
||||
/**
|
||||
* Conservative clearing-number -> bank-name lookup, for reassurance next to the
|
||||
* field. Only the major, long-stable, unambiguous ranges are included; any
|
||||
* clearing not in this table returns null (show nothing) rather than a guessed
|
||||
* name. The full authoritative table lands with the checksum follow-up.
|
||||
*
|
||||
* Ranges are matched on the leading 4 digits, so a 5-digit Swedbank clearing
|
||||
* (8xxxx) maps via its 8xxx prefix.
|
||||
*/
|
||||
const BANK_CLEARING_RANGES: ReadonlyArray<{ min: number; max: number; bank: string }> = [
|
||||
{ min: 1100, max: 1199, bank: 'Nordea' },
|
||||
{ min: 1200, max: 1399, bank: 'Danske Bank' },
|
||||
{ min: 1400, max: 2099, bank: 'Nordea' },
|
||||
{ min: 2400, max: 2499, bank: 'Danske Bank' },
|
||||
{ min: 3000, max: 3399, bank: 'Nordea' },
|
||||
{ min: 5000, max: 5999, bank: 'SEB' },
|
||||
{ min: 6000, max: 6999, bank: 'Handelsbanken' },
|
||||
{ min: 7000, max: 7999, bank: 'Swedbank' },
|
||||
{ min: 8000, max: 8999, bank: 'Swedbank/Sparbanken' },
|
||||
{ min: 9500, max: 9549, bank: 'Nordea (Plusgirot)' },
|
||||
{ min: 9960, max: 9969, bank: 'Nordea (Plusgirot)' },
|
||||
]
|
||||
|
||||
/** Bank name for a (partial) clearing number, or null when not confidently known. */
|
||||
export function lookupBankByClearing(clearingRaw: string | null | undefined): string | null {
|
||||
const clearing = normalizeBankNumber(clearingRaw)
|
||||
if (clearing.length < 4) return null
|
||||
const first4 = Number.parseInt(clearing.slice(0, 4), 10)
|
||||
if (Number.isNaN(first4)) return null
|
||||
const hit = BANK_CLEARING_RANGES.find((r) => first4 >= r.min && first4 <= r.max)
|
||||
return hit ? hit.bank : null
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'crypto'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
|
||||
const ALGORITHM = 'aes-256-gcm'
|
||||
const IV_LENGTH = 12
|
||||
const TAG_LENGTH = 16
|
||||
|
||||
const logger = createLogger('salary/personnummer')
|
||||
|
||||
/**
|
||||
* Get the encryption key from environment.
|
||||
* Falls back to a dev-only key for local development.
|
||||
@@ -41,6 +44,19 @@ export function encryptPersonnummer(personnummer: string): string {
|
||||
* Decrypt a personnummer from storage.
|
||||
*/
|
||||
export function decryptPersonnummer(encrypted: string): string {
|
||||
// Tolerate legacy/unencrypted rows. A raw 12-digit personnummer (written by
|
||||
// a path that skipped encryptPersonnummer, e.g. the v1 REST create route
|
||||
// before this fix, or a seed) would otherwise be sliced as iv/ciphertext/tag
|
||||
// and throw ERR_CRYPTO_INVALID_AUTH_TAG ("Invalid authentication tag length:
|
||||
// 6"), 500-ing every decrypt-on-read path (roster, salary runs, payslips,
|
||||
// KU, AGI, MCP). Real ciphertext is 80 hex chars, so a 12-digit match is
|
||||
// unambiguously plaintext. Return it as-is and warn so the backfill can find
|
||||
// and re-encrypt it. Value is never logged. See DECISIONS.md.
|
||||
if (/^\d{12}$/.test(encrypted)) {
|
||||
logger.warn('decryptPersonnummer received an unencrypted personnummer; returning as-is (row needs backfill)')
|
||||
return encrypted
|
||||
}
|
||||
|
||||
const key = getEncryptionKey()
|
||||
const ivHex = encrypted.slice(0, IV_LENGTH * 2)
|
||||
const authTagHex = encrypted.slice(-TAG_LENGTH * 2)
|
||||
|
||||
+65
-3
@@ -278,6 +278,40 @@
|
||||
"type_send_invoice": "Send invoice",
|
||||
"type_mark_invoice_sent": "Mark as sent",
|
||||
"type_match_transaction_invoice": "Invoice match",
|
||||
"type_create_supplier": "New supplier",
|
||||
"type_create_article": "New article",
|
||||
"type_update_article": "Updated article",
|
||||
"type_create_dimension_value": "New dimension value",
|
||||
"type_create_supplier_invoice_from_inbox": "Supplier invoice",
|
||||
"type_create_self_billed_supplier_invoice": "Self-billed invoice",
|
||||
"type_approve_supplier_invoice": "Approval",
|
||||
"type_credit_supplier_invoice": "Supplier credit note",
|
||||
"type_credit_invoice": "Credit invoice",
|
||||
"type_convert_invoice": "Convert invoice",
|
||||
"type_attach_document_to_transaction": "Attach document",
|
||||
"type_link_document_to_voucher": "Link document",
|
||||
"type_link_invoice_voucher": "Link invoice",
|
||||
"type_link_supplier_invoice_voucher": "Link supplier invoice",
|
||||
"type_link_transaction_journal_entry": "Link transaction",
|
||||
"type_uncategorize_transaction": "Undo categorization",
|
||||
"type_retag_line_dimensions": "Update dimensions",
|
||||
"type_match_batch_allocate": "Allocate payment",
|
||||
"type_bulk_book_transactions": "Bulk booking",
|
||||
"type_bulk_book_inbox_items": "Bulk-book documents",
|
||||
"type_close_period": "Close period",
|
||||
"type_lock_period": "Lock period",
|
||||
"type_unlock_period": "Unlock period",
|
||||
"type_set_opening_balances": "Opening balances",
|
||||
"type_run_year_end": "Year-end close",
|
||||
"type_run_currency_revaluation": "Currency revaluation",
|
||||
"type_post_annual_depreciation": "Depreciation",
|
||||
"type_explain_voucher_gap": "Voucher gap",
|
||||
"type_import_sie": "SIE import",
|
||||
"type_undo_sie_import": "Undo SIE import",
|
||||
"type_create_salary_run": "Payroll run",
|
||||
"type_generate_agi": "AGI",
|
||||
"type_submit_vat_declaration": "VAT declaration",
|
||||
"type_submit_agi": "Employer declaration",
|
||||
"origin_agent_chat": "Suggested by the AI assistant via chat",
|
||||
"origin_mcp": "Suggested by an AI assistant via {label}",
|
||||
"origin_api": "Suggested via API integration",
|
||||
@@ -2668,11 +2702,22 @@
|
||||
"schedule_update_failed_title": "Could not update schedule",
|
||||
"schedule_deleted_title": "Schedule removed",
|
||||
"schedule_delete_failed_title": "Could not remove schedule",
|
||||
"delete_confirm": "Remove schedule \"{name}\"? Already created invoices are not affected."
|
||||
"delete_confirm": "Remove schedule \"{name}\"? Already created invoices are not affected.",
|
||||
"run_now": "Create invoice now",
|
||||
"run_now_confirm": "Create an invoice for \"{name}\" now? If the schedule has automatic sending, it is emailed to the customer immediately. The next scheduled run is not affected.",
|
||||
"run_now_success_title": "Invoice created",
|
||||
"run_now_failed_title": "Could not create invoice",
|
||||
"resume_autosend_confirm": "Reactivate \"{name}\"? This resumes automatic monthly emails to the customer.",
|
||||
"send_time": "at {time}"
|
||||
},
|
||||
"invoice_recurring_new": {
|
||||
"back": "Back",
|
||||
"title": "New recurring schedule",
|
||||
"edit_title": "Edit schedule",
|
||||
"save_changes": "Save changes",
|
||||
"saving": "Saving...",
|
||||
"updated_title": "Schedule updated",
|
||||
"update_failed_title": "Could not update schedule",
|
||||
"schedule_card_title": "Schedule",
|
||||
"name_label": "Name",
|
||||
"name_placeholder": "e.g. Monthly retainer Acme AB",
|
||||
@@ -2706,7 +2751,10 @@
|
||||
"validation_unit_required": "Unit required",
|
||||
"validation_customer_required": "Select a customer",
|
||||
"validation_name_required": "Name required",
|
||||
"validation_min_one_row": "At least one row required"
|
||||
"validation_min_one_row": "At least one row required",
|
||||
"send_hour_label": "Send at",
|
||||
"send_hour_hint": "Swedish time",
|
||||
"auto_send_missing_email": "The customer has no email address. Add one to the customer to enable automatic sending."
|
||||
},
|
||||
"invoice_send_dialog": {
|
||||
"title_email": "Send invoice",
|
||||
@@ -3405,6 +3453,10 @@
|
||||
"sum_d": "D: {amount}",
|
||||
"sum_k": "C: {amount}",
|
||||
"add_line": "Add line",
|
||||
"save_as_template": "Save as template",
|
||||
"save_template_disabled_hint": "Fill in at least two lines with an account and amount",
|
||||
"save_template_dialog_title": "Save as booking template",
|
||||
"save_template_dialog_description": "Save this entry as a reusable template. Amounts are stored as ratios of the total, so you enter a fresh amount when applying the template. Review the lines below before saving.",
|
||||
"attachments_label": "Documents",
|
||||
"difference": "Difference: {amount}",
|
||||
"save_draft": "Save as draft",
|
||||
@@ -3483,7 +3535,9 @@
|
||||
"active_count_label": "{active}/{total} active",
|
||||
"no_matches": "No accounts match the search",
|
||||
"no_accounts": "No accounts in the chart",
|
||||
"delete_confirm": "Delete account {number} {name}?",
|
||||
"delete_confirm_title": "Delete account?",
|
||||
"delete_confirm": "Delete account {number} {name}? This cannot be undone.",
|
||||
"delete_confirm_action": "Delete",
|
||||
"toast_account_deleted": "Account deleted",
|
||||
"toast_delete_failed": "Could not delete the account",
|
||||
"toast_update_failed": "Could not update the account",
|
||||
@@ -3504,6 +3558,9 @@
|
||||
"prune_used_note": "{count} accounts are used in your bookkeeping and cannot be deleted. Deactivate them instead if you want to hide them.",
|
||||
"prune_empty": "No unused accounts to clean up — your chart of accounts is already clean.",
|
||||
"prune_confirm": "Delete {count} accounts",
|
||||
"prune_select_all": "Select all unused accounts",
|
||||
"prune_confirm_title": "Delete {count} accounts?",
|
||||
"prune_confirm_body": "The selected accounts will be permanently deleted. This cannot be undone.",
|
||||
"toast_pruned_title": "Chart of accounts cleaned",
|
||||
"toast_pruned_description": "{deleted} accounts deleted",
|
||||
"toast_pruned_with_skipped": "{deleted} accounts deleted, {skipped} skipped",
|
||||
@@ -4753,6 +4810,11 @@
|
||||
"form_clearing_number": "Clearing number",
|
||||
"form_account_number": "Account number",
|
||||
"form_bank_hint": "Required before a salary run can be approved",
|
||||
"bank_error_clearing_format": "Clearing number must be 4 digits (or 5 digits starting with 8 for Swedbank)",
|
||||
"bank_error_account_format": "Account number must be 5-11 digits",
|
||||
"bank_error_account_required": "Account number is required when a clearing number is set",
|
||||
"bank_error_clearing_required": "Clearing number is required when an account number is set",
|
||||
"bank_warn_checksum": "The check digit looks off. Please double-check the number, you can still save.",
|
||||
"form_cancel": "Cancel",
|
||||
"form_save": "Save",
|
||||
"form_saving": "Saving...",
|
||||
|
||||
+65
-3
@@ -278,6 +278,40 @@
|
||||
"type_send_invoice": "Skicka faktura",
|
||||
"type_mark_invoice_sent": "Markera skickad",
|
||||
"type_match_transaction_invoice": "Fakturamatchning",
|
||||
"type_create_supplier": "Ny leverantör",
|
||||
"type_create_article": "Ny artikel",
|
||||
"type_update_article": "Uppdaterad artikel",
|
||||
"type_create_dimension_value": "Nytt dimensionsvärde",
|
||||
"type_create_supplier_invoice_from_inbox": "Leverantörsfaktura",
|
||||
"type_create_self_billed_supplier_invoice": "Självfaktura",
|
||||
"type_approve_supplier_invoice": "Attestering",
|
||||
"type_credit_supplier_invoice": "Leverantörskreditfaktura",
|
||||
"type_credit_invoice": "Kreditfaktura",
|
||||
"type_convert_invoice": "Konvertera faktura",
|
||||
"type_attach_document_to_transaction": "Bifoga underlag",
|
||||
"type_link_document_to_voucher": "Länka underlag",
|
||||
"type_link_invoice_voucher": "Länka faktura",
|
||||
"type_link_supplier_invoice_voucher": "Länka leverantörsfaktura",
|
||||
"type_link_transaction_journal_entry": "Länka transaktion",
|
||||
"type_uncategorize_transaction": "Ångra kategorisering",
|
||||
"type_retag_line_dimensions": "Ändra dimensioner",
|
||||
"type_match_batch_allocate": "Fördela betalning",
|
||||
"type_bulk_book_transactions": "Massbokföring",
|
||||
"type_bulk_book_inbox_items": "Massbokför underlag",
|
||||
"type_close_period": "Stäng period",
|
||||
"type_lock_period": "Lås period",
|
||||
"type_unlock_period": "Lås upp period",
|
||||
"type_set_opening_balances": "Ingående balans",
|
||||
"type_run_year_end": "Bokslut",
|
||||
"type_run_currency_revaluation": "Valutaomvärdering",
|
||||
"type_post_annual_depreciation": "Avskrivning",
|
||||
"type_explain_voucher_gap": "Verifikationslucka",
|
||||
"type_import_sie": "SIE-import",
|
||||
"type_undo_sie_import": "Ångra SIE-import",
|
||||
"type_create_salary_run": "Lönekörning",
|
||||
"type_generate_agi": "AGI",
|
||||
"type_submit_vat_declaration": "Momsdeklaration",
|
||||
"type_submit_agi": "Arbetsgivardeklaration",
|
||||
"origin_agent_chat": "Föreslaget av AI-assistenten via chatt",
|
||||
"origin_mcp": "Föreslaget av AI-assistent via {label}",
|
||||
"origin_api": "Föreslaget via API-integration",
|
||||
@@ -2668,11 +2702,22 @@
|
||||
"schedule_update_failed_title": "Kunde inte uppdatera schema",
|
||||
"schedule_deleted_title": "Schema borttaget",
|
||||
"schedule_delete_failed_title": "Kunde inte ta bort schema",
|
||||
"delete_confirm": "Ta bort schemat \"{name}\"? Redan skapade fakturor påverkas inte."
|
||||
"delete_confirm": "Ta bort schemat \"{name}\"? Redan skapade fakturor påverkas inte.",
|
||||
"run_now": "Skapa faktura nu",
|
||||
"run_now_confirm": "Skapa en faktura för \"{name}\" nu? Om schemat har automatiskt utskick skickas den direkt till kunden. Nästa schemalagda körning påverkas inte.",
|
||||
"run_now_success_title": "Faktura skapad",
|
||||
"run_now_failed_title": "Kunde inte skapa faktura",
|
||||
"resume_autosend_confirm": "Aktivera \"{name}\" igen? Detta återupptar automatiska månatliga utskick till kunden.",
|
||||
"send_time": "kl {time}"
|
||||
},
|
||||
"invoice_recurring_new": {
|
||||
"back": "Tillbaka",
|
||||
"title": "Nytt återkommande schema",
|
||||
"edit_title": "Redigera schema",
|
||||
"save_changes": "Spara ändringar",
|
||||
"saving": "Sparar...",
|
||||
"updated_title": "Schema uppdaterat",
|
||||
"update_failed_title": "Kunde inte uppdatera schema",
|
||||
"schedule_card_title": "Schema",
|
||||
"name_label": "Namn",
|
||||
"name_placeholder": "t.ex. Månadsretainer Acme AB",
|
||||
@@ -2706,7 +2751,10 @@
|
||||
"validation_unit_required": "Enhet krävs",
|
||||
"validation_customer_required": "Välj en kund",
|
||||
"validation_name_required": "Namn krävs",
|
||||
"validation_min_one_row": "Minst en rad krävs"
|
||||
"validation_min_one_row": "Minst en rad krävs",
|
||||
"send_hour_label": "Skicka klockan",
|
||||
"send_hour_hint": "Svensk tid",
|
||||
"auto_send_missing_email": "Kunden saknar e-postadress. Lägg till en e-postadress på kundkortet för att kunna skicka automatiskt."
|
||||
},
|
||||
"invoice_send_dialog": {
|
||||
"title_email": "Skicka faktura",
|
||||
@@ -3405,6 +3453,10 @@
|
||||
"sum_d": "D: {amount}",
|
||||
"sum_k": "K: {amount}",
|
||||
"add_line": "Lägg till rad",
|
||||
"save_as_template": "Spara som mall",
|
||||
"save_template_disabled_hint": "Fyll i minst två konteringsrader med konto och belopp",
|
||||
"save_template_dialog_title": "Spara som bokföringsmall",
|
||||
"save_template_dialog_description": "Spara den här konteringen som en återanvändbar mall. Beloppen sparas som andelar av totalsumman, du anger ett nytt belopp när du använder mallen. Kontrollera raderna nedan innan du sparar.",
|
||||
"attachments_label": "Underlag",
|
||||
"difference": "Differens: {amount}",
|
||||
"save_draft": "Spara som utkast",
|
||||
@@ -3483,7 +3535,9 @@
|
||||
"active_count_label": "{active}/{total} aktiva",
|
||||
"no_matches": "Inga konton matchar sökningen",
|
||||
"no_accounts": "Inga konton i kontoplanen",
|
||||
"delete_confirm": "Vill du ta bort konto {number} {name}?",
|
||||
"delete_confirm_title": "Ta bort konto?",
|
||||
"delete_confirm": "Vill du ta bort konto {number} {name}? Detta går inte att ångra.",
|
||||
"delete_confirm_action": "Ta bort",
|
||||
"toast_account_deleted": "Konto borttaget",
|
||||
"toast_delete_failed": "Kunde inte ta bort kontot",
|
||||
"toast_update_failed": "Kunde inte uppdatera kontot",
|
||||
@@ -3504,6 +3558,9 @@
|
||||
"prune_used_note": "{count} konton används i bokföringen och kan inte tas bort. Inaktivera dem istället om du vill dölja dem.",
|
||||
"prune_empty": "Inga oanvända konton att rensa — din kontoplan är redan ren.",
|
||||
"prune_confirm": "Ta bort {count} konton",
|
||||
"prune_select_all": "Markera alla oanvända konton",
|
||||
"prune_confirm_title": "Ta bort {count} konton?",
|
||||
"prune_confirm_body": "De valda kontona tas bort permanent. Detta går inte att ångra.",
|
||||
"toast_pruned_title": "Kontoplan rensad",
|
||||
"toast_pruned_description": "{deleted} konton togs bort",
|
||||
"toast_pruned_with_skipped": "{deleted} konton togs bort, {skipped} hoppades över",
|
||||
@@ -4753,6 +4810,11 @@
|
||||
"form_clearing_number": "Clearingnummer",
|
||||
"form_account_number": "Kontonummer",
|
||||
"form_bank_hint": "Krävs innan lönekörning kan godkännas",
|
||||
"bank_error_clearing_format": "Clearingnummer måste vara 4 siffror (eller 5 siffror som börjar med 8 för Swedbank)",
|
||||
"bank_error_account_format": "Kontonummer måste vara 5-11 siffror",
|
||||
"bank_error_account_required": "Kontonummer krävs när clearingnummer har angetts",
|
||||
"bank_error_clearing_required": "Clearingnummer krävs när kontonummer har angetts",
|
||||
"bank_warn_checksum": "Kontrollsiffran verkar inte stämma. Dubbelkolla numret, du kan spara ändå.",
|
||||
"form_cancel": "Avbryt",
|
||||
"form_save": "Spara",
|
||||
"form_saving": "Sparar...",
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* One-off backfill: encrypt any employees.personnummer still stored as
|
||||
* plaintext (12 digits) into aes-256-gcm ciphertext, matching the format
|
||||
* written by encryptPersonnummer().
|
||||
*
|
||||
* WHY: the v1 REST create route used to store personnummer unencrypted, which
|
||||
* 500'd every decrypt-on-read path with ERR_CRYPTO_INVALID_AUTH_TAG
|
||||
* ("Invalid authentication tag length: 6"). The code fix stops new plaintext;
|
||||
* this repairs the rows already in the DB and closes the GDPR at-rest gap.
|
||||
* Run AFTER the code fix is deployed.
|
||||
*
|
||||
* Idempotent: only touches rows whose personnummer matches /^\d{12}$/, and each
|
||||
* update is guarded on the exact plaintext value so a re-run or a concurrent
|
||||
* write can never double-encrypt. Safe to re-run.
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx scripts/backfill-encrypt-personnummer.ts # dry run (read-only)
|
||||
* npx tsx scripts/backfill-encrypt-personnummer.ts --confirm # performs the writes
|
||||
*
|
||||
* Reads NEXT_PUBLIC_SUPABASE_URL + SUPABASE_SERVICE_ROLE_KEY +
|
||||
* PERSONNUMMER_ENCRYPTION_KEY from .env.local. Treat .env.local as pointing at
|
||||
* PRODUCTION: the dry run is read-only; --confirm mutates PII.
|
||||
*/
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import { config as dotenv } from 'dotenv'
|
||||
import { resolve } from 'node:path'
|
||||
import { encryptPersonnummer } from '@/lib/salary/personnummer'
|
||||
|
||||
dotenv({ path: resolve(process.cwd(), '.env.local') })
|
||||
|
||||
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL
|
||||
const SERVICE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY
|
||||
if (!SUPABASE_URL || !SERVICE_KEY) {
|
||||
console.error('Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY in .env.local')
|
||||
process.exit(1)
|
||||
}
|
||||
// Refuse to run without the real key: encrypting with the dev fallback key
|
||||
// would make the values unreadable in production.
|
||||
if (!process.env.PERSONNUMMER_ENCRYPTION_KEY) {
|
||||
console.error(
|
||||
'Missing PERSONNUMMER_ENCRYPTION_KEY. Refusing to run so rows are not encrypted with the dev fallback key.',
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const CONFIRM = process.argv.includes('--confirm')
|
||||
const PLAINTEXT = /^\d{12}$/
|
||||
|
||||
const sb = createClient(SUPABASE_URL, SERVICE_KEY, { auth: { persistSession: false } })
|
||||
|
||||
async function main() {
|
||||
const host = new URL(SUPABASE_URL!).host
|
||||
console.log(`Target: ${host} mode: ${CONFIRM ? 'WRITE (--confirm)' : 'DRY RUN (read-only)'}`)
|
||||
|
||||
const { data, error } = await sb
|
||||
.from('employees')
|
||||
.select('id, company_id, personnummer, personnummer_last4')
|
||||
if (error) throw new Error(`select employees: ${error.message}`)
|
||||
|
||||
const rows = data ?? []
|
||||
const plaintextRows = rows.filter((r) => PLAINTEXT.test(String(r.personnummer ?? '')))
|
||||
|
||||
const byCompany = new Map<string, number>()
|
||||
for (const r of plaintextRows) {
|
||||
byCompany.set(r.company_id, (byCompany.get(r.company_id) ?? 0) + 1)
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Scanned ${rows.length} employees; ${plaintextRows.length} plaintext across ${byCompany.size} companies.`,
|
||||
)
|
||||
for (const [companyId, n] of byCompany) console.log(` company ${companyId}: ${n} row(s)`)
|
||||
|
||||
if (plaintextRows.length === 0) {
|
||||
console.log('Nothing to backfill.')
|
||||
return
|
||||
}
|
||||
if (!CONFIRM) {
|
||||
console.log('\nDRY RUN: no writes performed. Re-run with --confirm to encrypt these rows.')
|
||||
return
|
||||
}
|
||||
|
||||
let updated = 0
|
||||
for (const r of plaintextRows) {
|
||||
const plaintext = String(r.personnummer)
|
||||
const last4 = plaintext.slice(-4)
|
||||
const patch: Record<string, string> = { personnummer: encryptPersonnummer(plaintext) }
|
||||
if (r.personnummer_last4 !== last4) patch.personnummer_last4 = last4
|
||||
|
||||
// Guard on the still-plaintext value: makes the update idempotent and safe
|
||||
// against a concurrent write (never double-encrypts).
|
||||
const { error: upErr } = await sb
|
||||
.from('employees')
|
||||
.update(patch)
|
||||
.eq('id', r.id)
|
||||
.eq('personnummer', plaintext)
|
||||
if (upErr) throw new Error(`update ${r.id}: ${upErr.message}`)
|
||||
updated++
|
||||
}
|
||||
|
||||
console.log(`\nEncrypted ${updated} row(s). Re-run without --confirm to verify 0 remain.`)
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -23,6 +23,7 @@
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import { config as dotenv } from 'dotenv'
|
||||
import { resolve } from 'node:path'
|
||||
import { encryptPersonnummer } from '@/lib/salary/personnummer'
|
||||
|
||||
dotenv({ path: resolve(process.cwd(), '.env.local') })
|
||||
|
||||
@@ -639,7 +640,15 @@ async function seedEmployees(ctx: CompanyCtx): Promise<Record<string, string>> {
|
||||
email: 'johan@konsult.se',
|
||||
},
|
||||
]
|
||||
const rows = seeds.map((s) => ({ user_id: ctx.userId, company_id: ctx.companyId, ...s }))
|
||||
// personnummer is stored encrypted at rest (aes-256-gcm); the read paths
|
||||
// decrypt it. Seeding the raw value would 500 the roster / salary flows with
|
||||
// ERR_CRYPTO_INVALID_AUTH_TAG. Encrypt here, keep personnummer_last4 plain.
|
||||
const rows = seeds.map((s) => ({
|
||||
user_id: ctx.userId,
|
||||
company_id: ctx.companyId,
|
||||
...s,
|
||||
personnummer: encryptPersonnummer(s.personnummer),
|
||||
}))
|
||||
const { data, error } = await sb.from('employees').insert(rows).select('id, first_name')
|
||||
if (error) throw new Error(`employees: ${error.message}`)
|
||||
return Object.fromEntries((data ?? []).map((e) => [e.first_name, e.id]))
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
-- Covering index for get_account_usage_counts (kontoplan "Verifikat" column +
|
||||
-- the "Rensa oanvända konton" prune dialog).
|
||||
--
|
||||
-- That RPC aggregates journal_entry_lines by account_number for a company:
|
||||
--
|
||||
-- SELECT l.account_number, count(*)
|
||||
-- FROM journal_entry_lines l
|
||||
-- JOIN journal_entries je ON je.id = l.journal_entry_id
|
||||
-- WHERE je.company_id = $1
|
||||
-- GROUP BY l.account_number;
|
||||
--
|
||||
-- For companies whose entries are a smallish fraction of the table, the planner
|
||||
-- picks a nested loop: scan je by company_id, then for each entry look up its
|
||||
-- lines via idx_journal_entry_lines_entry (journal_entry_id only). Because that
|
||||
-- index does NOT carry account_number, every matched line needs a heap fetch
|
||||
-- just to read the account number. On prod's heaviest company (~50k lines) that
|
||||
-- was ~58k heap-buffer accesses and ~440 ms.
|
||||
--
|
||||
-- Adding account_number to the index makes that inner lookup an INDEX-ONLY scan
|
||||
-- (verified on the staging branch: the node becomes "Index Only Scan using
|
||||
-- idx_journal_entry_lines_entry_account" with a handful of heap fetches), which
|
||||
-- eliminates the heap traffic that dominated the runtime.
|
||||
--
|
||||
-- Plain CREATE INDEX (not CONCURRENTLY): Supabase branching applies migrations
|
||||
-- inside a transaction, where CONCURRENTLY is not allowed. The build takes a
|
||||
-- brief write lock on journal_entry_lines; acceptable for a deploy migration.
|
||||
-- journal_entry_lines is append-mostly, so no ongoing maintenance concern.
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_journal_entry_lines_entry_account
|
||||
ON public.journal_entry_lines (journal_entry_id, account_number);
|
||||
@@ -0,0 +1,36 @@
|
||||
-- Migration: recurring_invoice_schedules.send_hour + one-time safety pause
|
||||
--
|
||||
-- Context: the daily cron that spawns recurring invoices was accidentally
|
||||
-- removed from vercel.json on 2026-05-22 (commit #559), so no recurring
|
||||
-- schedule has run since. We are re-enabling it, this time as an hourly cron
|
||||
-- with a user-chosen send hour.
|
||||
--
|
||||
-- Two changes, both one-time:
|
||||
--
|
||||
-- 1. send_hour: the whole hour (0-23, Europe/Stockholm) at which the schedule
|
||||
-- should send. Default 08 = 08:00. Cron runs hourly and only fires
|
||||
-- schedules matching the current Stockholm hour.
|
||||
--
|
||||
-- 2. Safety pause: because the cron has been dark for weeks, users may have
|
||||
-- forgotten schedules they set up. Silently resuming automatic emails to
|
||||
-- their customers would send invoices "behind their back". So we pause
|
||||
-- every schedule that exists at deploy time; a user must consciously
|
||||
-- reactivate one (or click "Skapa faktura nu") to resume sending. Rows
|
||||
-- created AFTER this migration default to 'active' and are unaffected.
|
||||
-- This is deliberate and matches the product decision (see DECISIONS.md).
|
||||
|
||||
ALTER TABLE public.recurring_invoice_schedules
|
||||
ADD COLUMN send_hour SMALLINT NOT NULL DEFAULT 8
|
||||
CHECK (send_hour BETWEEN 0 AND 23);
|
||||
|
||||
-- One-time: pause all pre-existing active schedules so nothing auto-sends
|
||||
-- until the user knowingly turns it back on. last_run_warning surfaces the
|
||||
-- reason inline (the list view renders it as a warning tooltip); it is
|
||||
-- cleared on the next successful run after the user reactivates.
|
||||
UPDATE public.recurring_invoice_schedules
|
||||
SET
|
||||
status = 'paused',
|
||||
last_run_warning = 'Automatiska utskick pausades av säkerhetsskäl. Aktivera schemat igen för att återuppta månatliga utskick till kunden.'
|
||||
WHERE status = 'active';
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -1062,6 +1062,9 @@ export interface RecurringInvoiceSchedule {
|
||||
// Monthly cadence, day-of-month 1-31. Clamped to last day of month in
|
||||
// shorter months (handled by computeNextRunDate).
|
||||
day_of_month: number
|
||||
// Whole hour (0-23) in Europe/Stockholm time at which the schedule sends.
|
||||
// The hourly cron only fires schedules matching the current Stockholm hour.
|
||||
send_hour: number
|
||||
payment_terms_days: number
|
||||
|
||||
currency: Currency
|
||||
|
||||
+5
-1
@@ -4,7 +4,11 @@
|
||||
"path": "/api/deadlines/status/cron",
|
||||
"schedule": "0 6 * * *"
|
||||
},
|
||||
{
|
||||
{
|
||||
"path": "/api/invoices/recurring/cron",
|
||||
"schedule": "0 * * * *"
|
||||
},
|
||||
{
|
||||
"path": "/api/tax-deadlines/cron",
|
||||
"schedule": "0 0 2 1 *"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user