Add/api and invoice (#911)
* feat(salary): validate employee clearing/kontonummer at entry Bank details on the "Anställda" form had no structural validation, so a typo in clearing/kontonummer was saved silently and only surfaced at Bankgirot LB generation (or never, on the SEPA path). Adds a shared validator (lib/salary/payment/bank-account.ts) wired into the create dialog, edit page, CreateEmployeeSchema, and the PATCH route: 4-digit clearing or 5-digit Swedbank (8xxxx), 5-11 digit account, both-or-neither. Mirrors encodeReceiverAccount so entry-time validation matches what the payout layer can encode. Update validates only when a bank field actually changes, so legacy free-text data stays editable. Includes a conservative clearing to bank-name hint (null for unknown ranges). Per-bank mod10/mod11 checksum deferred to a soft-warning follow-up. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(chart-of-accounts): styled delete warnings and bulk select-all Replace the native window.confirm() on single-account delete with the styled DestructiveConfirmDialog, and add to the prune dialog a master 'select all unused accounts' checkbox plus an explicit confirmation step before bulk deletion. New sv/en strings for the confirm titles and actions. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(salary): encrypt personnummer on v1 employee create; tolerate legacy plaintext on read The v1 REST create route stored personnummer unencrypted, which then threw ERR_CRYPTO_INVALID_AUTH_TAG on every decrypt-on-read path and 500'd the employees roster. Encrypt on write in v1 create, decrypt on read in the v1 list/detail/patch responses, and make decryptPersonnummer pass a raw 12-digit value through with a warn so a legacy plaintext row can't take the roster down. Encrypt seeded personnummer. Add a gated, idempotent backfill for existing rows. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(bookkeeping): save a manual entry as a reusable template Add a "Spara som mall" action to the manual journal-entry form next to the existing "Anvand mall" picker, so users can capture a booking pattern the moment they work it out. Opens the shared TemplateForm (create mode) pre-seeded from the current lines via deriveTemplateLinesFromBooking, and saves through the existing POST /api/settings/booking-templates. Rendered in both the mobile and desktop layouts and in create + edit modes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(pending): label all staged operation types The Granskning list rendered the raw snake_case operation_type (e.g. create_supplier_invoice_from_inbox) for any type missing from the label map, which hogs the meta row and wraps awkwardly on mobile. Add short sv/en labels for all operation types in OPERATION_RISK_TIERS, plus a humanized fallback for future ones, and simplify the label map to a plain operation_type -> i18n-key record (the icon/variant fields were dead). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(reports): let users file moms without a Skatteverket connection The momsdeklaration was never gated on the Skatteverket connection (it renders from the bookkeeping), but the not-connected "Anslut med BankID" card read as a wall. Make manual filing a first-class path: - Add a "Lämna in din momsdeklaration" card under the report with a PDF download (SKV 4700 layout, hela kronor) and a skatteverket.se link. - Add a momsdeklaration PDF route + template; buildManualFilingRows() rounds each ruta to whole kronor and recomputes ruta 49 per the SKV 4700 formula so it ties out. The PDF is a read/record copy, not a submission file (moms has no upload channel). - Offer PDF alongside Excel in the report's export menu. - Reframe the not-connected SkatteverketPanel to "Skicka direkt till Skatteverket (valfritt)". Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(salary): compact new-employee dialog and warn on bad account check digit Redesign NewEmployeeDialog into a compact layout: borderless sections split by hairline dividers (no per-section cards), a fixed header + scrolling body + solid footer (fixes content showing through the old sticky bar), and denser grids. EmployeeTaxCard gains a `flat` variant so the dialog can host it without card chrome; the edit page keeps the boxed version. Add non-blocking Swedish account check-digit validation (lib/bankgiro/account-number.ts): mod10 (reuses luhn) + mod11, with a clearing->method table from the Bankgirot "Bankernas kontonummeruppbyggnad" spec, cross-checked against jop-io/kontonummer.js and verified against a real account (Forex 9420/4172385). Surfaced as a soft warning in both employee forms; unrecognised clearings return 'unknown' so we never warn on a valid but unmapped account. Never blocks saving. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(invoices): configurable send time + editing for recurring invoices Re-register the accidentally-removed recurring cron (now hourly) and add a per-schedule send hour (Europe/Stockholm, DST-aware). The cron never sends for a past date, and the enabling migration pauses every existing schedule on deploy so nothing auto-sends behind a user's back; users reactivate consciously (with a confirm) or click "Skapa faktura nu" to send this month on demand. Automatic sending now requires a customer email. Adds a full edit flow (row click opens the prefilled form, PATCH), fixing the row-click 404. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(invoices): configure självfaktura via the invoice API Add an optional is_self_billed flag (plus external_invoice_number, self_billing_agreement_ref, received_date) to the public invoice-create endpoint so callers can register a received self-billing invoice (mottagen självfaktura, ML 17 kap 15§) via the API. It was previously only reachable from the internal dashboard route, so it was missing from the API docs. Extract the booking into a shared service (lib/invoices/self-billed-sale.ts) and refactor the internal /api/invoices/self-billed route to a thin wrapper over it, so the dashboard and the API cannot drift. Books as a sale (Debit 1510 / Credit 30xx+26xx) with the counterparty's number; no own number is consumed. Fields are plain optionals (no schema refine) so UpdateInvoiceSchema.omit() keeps working; required-when-self-billed is enforced in the route. Documented in the endpoint registry. No migration (columns already exist). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(settings): allow a partial voucher-series-per-source-type map In Zod 4 an enum-keyed z.record is exhaustive (every source_type required), so saving a default_voucher_series_per_source_type map that omits a source type (e.g. the newly added result_appropriation) failed with "expected string, received undefined". Use partialRecord so the map can be sparse; the engine falls back to series 'A' for any unmapped key. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(salary): resolve employer name via getCompanyDisplayName Payslip PDFs, the payslip email, AGI, KU10, and the BG/LB + SEPA payment files now resolve the employer name through getCompanyDisplayName (company_settings.company_name, falling back to companies.name), matching how invoices already display it. Read-side coalesce, so no migration or backfill: companies.name is write-once at onboarding and not authoritative for these surfaces. The sidebar company switcher uses the same coalesce for the non-active companies in the list. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * perf(kontoplan): index-only account usage counts + lighter reference load Add a covering index on journal_entry_lines (journal_entry_id, account_number) so get_account_usage_counts becomes an index-only scan (prod worst case ~440ms). Slim /api/bookkeeping/accounts/reference to return only the company's activation rows and merge against the client-bundled BAS_REFERENCE instead of re-sending the full ~1,300-account catalog every load, and defer the BAS catalog + usage counts off the first-paint critical path in ChartOfAccountsManager. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * i18n(salary): add bank-account checksum warning string sv/en strings for the employee bank-account (clearing/kontonummer) soft checksum warning shown by the create/edit forms. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: update decision log Append the 2026-07-06/07 decision entries (salary employer-name coalesce, sidebar switcher, employees API personnummer fix, kontoplan load optimization, momsdeklaration manual filing, recurring invoices resend + reactivation + editing, "spara som mall", voucher-series partial map, and självfaktura via the invoice API). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: address compliance-review findings on recurring invoices + moms filing - recurring cron: close the double-send window with an atomic compare-and-set claim on last_run_at (release-on-failure) so two overlapping hourly runs can't both spawn from the same stale batch row - recurring edit dialog: force auto_send=false whenever the effective customer has no email, so a disabled-but-checked box can't PATCH auto_send=true after the async customer load - momsdeklaration manual-filing: truncate rutor to whole kronor (öretal faller bort per SFL 22 kap 1 §) instead of round-to-nearest, matching the SRU path Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
7f24ede6c0
commit
3a88b53fd9
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user