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:
Mattsson
2026-07-07 01:14:59 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 7f24ede6c0
commit 3a88b53fd9
69 changed files with 4554 additions and 615 deletions
+124
View File
@@ -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
View File
@@ -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')
})
})
+126
View File
@@ -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'
}
+46 -1
View File
@@ -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()
})
})
+34
View File
@@ -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.
+365
View File
@@ -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>
)
}
+74
View File
@@ -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
}
+14
View File
@@ -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')
})
})
+6 -3
View File
@@ -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()
})
})
+155
View File
@@ -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
}
+16
View File
@@ -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)