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
@@ -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'
+11 -33
View File
@@ -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 })
})
})
+93
View File
@@ -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')
})
})
+119 -19
View File
@@ -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,
+15 -1
View File
@@ -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,
+74 -252
View File
@@ -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' } }),
)
})
})
+14 -2
View File
@@ -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}"`,
},
})
},
)
+16
View File
@@ -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)
})
})
+6 -3
View File
@@ -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