Files
accounted/lib/invoices/build-invoice-write.ts
T
MattssonandClaude Fable 5.1 1c82baf553 feat(invoices): offert (quote) document type with own OF-series, decisions, conversion, MCP and v1 (#2163)
* fix(invoices): reminders, AR ledger, AR reconciliation and deadlines only read fakturor

The overdue-reminder run, the kundreskontra, the 1510 reconciliation and the
deadlines page selected invoices by status alone. A sent proforma past its
due date was chased with a betalningspaminnelse and flipped to 'overdue',
and it appeared as a receivable. All four now filter document_type =
'invoice', which is also the precondition for adding quotes (offert): a
quote carries a date but never a receivable.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W45yD8NfQ97JhyYaXpzN56

* feat(invoices): offert (quote) document type with its own OF-series, decisions and conversion

Adds document_type 'quote' with valid_until, quote_status (open / accepted /
declined; expired is derived from valid_until, never stored) and
quote_decided_at. Quotes are numbered OF-nnn at insert from
company_settings.next_quote_number via generate_quote_number(), the same
pattern as delivery notes, so a declined quote never leaves a hole in the
F-series the way a proforma does. The column next_quote_number already
existed on prod and staging without a migration; the migration adopts it.

Engine: build-invoice-write writes the quote columns and keeps
remaining_amount at 0; the draft editor refuses accepted or declined
quotes; PATCH refuses changing a quote's or delivery note's document type
since the number belongs to the series; mark-paid refuses quotes.

New POST /api/invoices/[id]/quote-status records the decision and locks
once an invoice exists. Conversion is extracted into
lib/invoices/convert-to-invoice.ts (one implementation for the route and
the MCP staged commit, which had drifted): a converted quote stays and
flips to accepted, the invoice links back via converted_from_id and gets
its due date from the customer's payment terms; a declined or already
invoiced quote is refused. next-number previews the OF-series for quotes.

Migration applied to the staging branch and registered as 20260902140000;
the pg test runs in CI (pg-real).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W45yD8NfQ97JhyYaXpzN56

* feat(invoices): quote PDF, email and filename surfaces

The customer-facing surfaces get a quote sibling for every proforma branch:
PDF title OFFERT / QUOTE with Offertdatum and Giltig till instead of the
due date, a notice that the document is not an invoice or a payment
request, and no payment box, OCR, bankgiro, Swish, QR or payment link.
The email says the quote is attached and valid until the expiry, drops
the payment details and pay-online button, and asks about the quote
rather than the invoice. Filenames read "Offert nr OF-001". Seller VAT
number and payment accounts are skipped for quotes as for proformas:
a quote is not a faktura under ML 17 kap.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W45yD8NfQ97JhyYaXpzN56

* feat(invoices): offert in the editor, list and detail pages

Editor: "Offert" document type with a required "Giltig till" field
(default today + 30 days) in place of the due date; the wire body mirrors
it into due_date so the shared schema is satisfied. Payment link, ROT/RUT,
periodisering and the bank box are already gated on real invoices. The
type cannot be switched on an existing quote (its OF-number belongs to
the series).

List: an Offerter tab beside Proforma, "Ny offert" in the split button,
and a status column that shows the decision or the derived expiry:
Utgången and Avböjd are exception chips, Öppen and Accepterad muted text.

Detail: Acceptera and Skapa faktura in the header, Avböj in the overflow
menu; an expired quote asks before accepting or invoicing (bypassable);
once an invoice exists the page links to it as Fakturerad and hides the
decision actions. Strings in both sv and en.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W45yD8NfQ97JhyYaXpzN56

* feat(mcp,v1): expose offert on the MCP tools and the v1 REST surface

MCP: create_invoice takes document_type quote with a required valid_until
and allocates the OF-number at insert; the convert tool keeps its id and
accepts quotes with the registry refusal codes; new set_quote_status;
list_invoices and get_invoice expose valid_until and the effective quote
status, including a derived expired filter. The tools/list payload stays
under its ceiling without a ledger change. The MCP staged convert now
uses the shared converter.

v1: POST /invoices/{id}/quote-status (registered in the endpoint registry,
scope map and route loader), valid_until and quote_status in the list,
create and detail shapes, and a quote_status list filter. Skill atoms
mention offert. Decision log lines for the own number series, derived
expiry, accepted-not-cancelled conversion and the header action layout.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W45yD8NfQ97JhyYaXpzN56

* test(invoices): pass route params and period id in the new quote tests

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W45yD8NfQ97JhyYaXpzN56

* refactor(invoices): literal update payloads in the converter so the phantom-column guard can read them

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W45yD8NfQ97JhyYaXpzN56

* fix(invoices): close the quote review findings in one pass

Skeptics (correctness, compliance, regression) and CodeRabbit on #2163:

- quote_status is no longer a write-builder output, so a v1 PATCH or MCP
  update_invoice can never reset a recorded accept/decline; new quotes are
  opened by the invoices_quote_defaults trigger (20260902141000), which
  also keeps due_date and valid_until equal. v1 PATCH and the MCP update
  executor now use the shared editable-draft predicate.
- One live invoice per converted source, enforced by a partial unique
  index; the converter maps 23505 to INVOICE_QUOTE_ALREADY_INVOICED and
  both quote-status routes compare-and-set on the decision they read.
- MCP-created quotes carry remaining_amount 0; mark-paid, transaction
  match and voucher link refuse non-invoices on the MCP staging tools,
  the executors and the dashboard link route.
- Conversion of a foreign-currency source refetches the rate for the
  conversion day (ML 8 kap 21-23 paragraphs) and fails closed without one;
  0-day payment terms mean due on receipt.
- bulk-create refuses quotes per item; list_invoices rejects a
  quote_status filter combined with another document_type; an omitted
  document_type on PATCH means unchanged.
- attention, push notifications, open-AR count, FX revaluation, year-end
  and accrual auto-detect and bank-match suggestions only read fakturor.
- Quote PDF and email print Summa / Total instead of Att betala.
- Regenerated skills/accounted-api for the new v1 endpoint.

Declined with reasons in DECISIONS.md: NOT VALID + VALIDATE and CONCURRENTLY
on the migrations (repo precedent, 13.8k rows, transactional apply);
re-validating VAT treatment at conversion (the converted invoice is a
draft the user reviews; follow-up).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0111fYAUxKtpxU1BHiBioqzs

* fix(invoices): second review round: migration versions, order links, batch allocation, races

- Migrations renamed to 20260902220000 / 20260902221000: #2166 shipped its
  own 20260902141000 to prod while this PR was in review and prod's head
  moved past both files; below-head versions are skipped by branching,
  which would have left the quote trigger off prod. Staging rows renamed.
- Quote lines never carry sales_order_item_id (an offer must not count as
  invoiced kundorder quantity); the converter carries a proforma line's
  order link onto the invoice.
- Converter compare-and-sets the source (proforma cancel, quote accept):
  a concurrent cancel, proforma-to-order conversion or decision removes
  the orphan invoice with INVOICE_CONVERT_SOURCE_CHANGED instead of a
  second document for the same sale.
- MCP set_quote_status gets the same compare-and-set as the HTTP routes;
  0-row updates report INVOICE_QUOTE_CHANGED_CONCURRENTLY everywhere.
  quote-status (dashboard, v1, MCP) accepts valid_until so an expired
  sent quote can be reopened, as the docs promised.
- MCP mark-paid refuses only quotes, parity with the dashboard route
  (a sent proforma marked paid is a supported prepayment record).
- Batch allocation (dashboard route and MCP tool) refuses non-invoices
  before the RPC, which gates on status alone.
- Customer AR drill-down, v1 customer open invoices and archive guard,
  and the calendar feed read fakturor only.
- Draft quote PDF says "UTKAST" instead of "not a valid invoice"; the
  editor locks the document type on existing quotes and delivery notes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0111fYAUxKtpxU1BHiBioqzs

* chore(invoices): use roundOre in the quote MCP summaries and FX test after main tightened the guard baseline

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0111fYAUxKtpxU1BHiBioqzs

* fix(invoices): third review round: atomic decision lock, viewer gate, lookup errors, quote payment terms

- 20260902222000: BEFORE UPDATE trigger locks an accepted quote while a
  live converted invoice exists (the compare-and-set in the three decision
  writers could still be beaten by a conversion landing in between); the
  routes and the MCP tool map the raise to 409 INVOICE_QUOTE_ALREADY_INVOICED.
  generate_quote_number now also requires a non-viewer membership so a
  viewer's session token cannot burn OF-numbers through PostgREST.
- Converter checks quote eligibility before the Riksbanken call and treats
  a failed company_settings read as a failure instead of a 30-day default.
- Re-sending the same decision keeps quote_decided_at (idempotent).
- gnubok_find_voucher_candidates_for_invoice refuses non-invoices like its
  write sibling; the dashboard link route surfaces a failed lookup.
- Late-fee and credit-term texts never print on a quote.
Applied and registered on staging; pg tests added.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0111fYAUxKtpxU1BHiBioqzs

* fix(invoices): review nits: fail-closed batch lookup, dry-run expiry, quote heading, quote-date CHECK

- match-batch surfaces a failed document lookup instead of allocating.
- v1 quote-status dry-run preview carries the new valid_until.
- Quote PDF heading reads Offertinformation / Quote information.
- 20260902222000 also pins the date invariants the trigger maintains as a
  CHECK: a quote always has valid_until = due_date, nothing else has one.
  Applied on staging.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0111fYAUxKtpxU1BHiBioqzs

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 22:40:25 +02:00

678 lines
31 KiB
TypeScript

import type { SupabaseClient } from '@supabase/supabase-js'
import type { Currency, Customer, InvoiceDocumentType } from '@/types'
import { getVatRules, getPermittedVatRates } from '@/lib/invoices/vat-rules'
import { isBalanceSheetAccount } from '@/lib/invoices/posting-account'
import { computeLineNet } from '@/lib/invoices/line-amounts'
import { fetchExchangeRate, convertToSEK } from '@/lib/currency/riksbanken'
import { DEFAULT_DEFERRED_REVENUE_ACCOUNT } from '@/lib/bookkeeping/accruals/account-suggestions'
import {
computeDeduction,
computeInvoiceDeductionTotal,
validateInvoice as validateRotRut,
} from '@/lib/invoices/rot-rut-rules'
import {
encryptPersonnummer,
expandPersonnummerTo12,
extractLast4,
validatePersonnummer,
} from '@/lib/salary/personnummer'
import { revealStoredCustomerPersonalNumber } from '@/lib/customers/protect-personal-number'
/**
* Shared invoice write-builder.
*
* Encapsulates the validation + computation that is IDENTICAL whether an
* invoice (or proforma / delivery note) is being created (POST /api/invoices)
* or a draft is being edited in place (PATCH /api/invoices/[id]):
*
* - per-customer VAT rule gating (allowed rates) + not-VAT-registered zeroing
* - periodisering (accrual) guards
* - subtotal / per-rate VAT / total
* - per-line revenue-account override validation against chart_of_accounts
* - server-side ROT/RUT compute + personnummer encryption (never trust client)
* - mixed-rate detection, currency → SEK conversion
* - the invoice_items row mapping
*
* It intentionally does NOT allocate an invoice number or emit events: those
* differ between create and update and stay in the route handlers. The returned
* `invoiceFields` exclude `user_id`, `company_id`, `invoice_number` and `status`;
* the caller merges those. Returned `items` carry no `invoice_id`: the caller
* adds it once the invoice row id is known.
*/
// The validated line shape (a superset of what create/update schemas produce).
export interface InvoiceWriteItemInput {
line_type?: 'product' | 'text'
description: string
quantity: number
unit: string
unit_price: number
/** Percentage discount on the line (0-100). Omitted/null = 0; line_total
* and vat_amount are computed NET of it (lib/invoices/line-amounts.ts). */
discount_percent?: number | null
vat_rate?: number
article_id?: string | null
revenue_account?: string | null
/** Kundorder line this invoice line was created from; round-tripped on
* edit so the order's derived invoiced quantity never loses a link. */
sales_order_item_id?: string | null
deduction_type?: 'rot' | 'rut' | null
labor_hours?: number | null
work_type?: string | null
housing_designation?: string | null
apartment_number?: string | null
brf_org_number?: string | null
accrual_period_start?: string | null
accrual_period_end?: string | null
accrual_balance_account?: string | null
/** Dimensions PR7: per-item bag merged over the invoice default at booking. */
dimensions?: Record<string, string>
}
export interface InvoiceWriteInput {
customer_id: string
invoice_date: string
due_date: string
delivery_date?: string | null
/** Quotes only: expiry date. Mirrored into due_date (NOT NULL) for quotes. */
valid_until?: string | null
currency: Currency
your_reference?: string
our_reference?: string
/** Fakturamärkning: buyer-required marking, separate from your_reference. */
invoice_marking?: string
notes?: string
/** Optional https payment link (schema-validated). Omitted/empty → null. */
payment_link_url?: string
/** Per-invoice opt-out for the automatic Stripe payment link. Omitted → true. */
payment_link_auto?: boolean
/** Per-invoice öresavrundning override (display-only). Omitted → null (inherit company setting). */
ore_rounding?: boolean
deduction_personnummer?: string
deduction_housing_designation?: string
/** ROT i bostadsrätt: lägenhetsnummer + föreningens orgnr instead of fastighetsbeteckning. */
deduction_apartment_number?: string
deduction_brf_org_number?: string
/** Dimensions PR7: invoice-level bag applied to every generated journal line. */
default_dimensions?: Record<string, string>
items: InvoiceWriteItemInput[]
}
// The computed invoice-row fields shared by create and update. Deliberately
// untyped-strict (Record) so it slots straight into a Supabase insert/update;
// every value is computed here from validated input.
export type InvoiceWriteFields = {
customer_id: string
invoice_date: string
due_date: string
delivery_date: string | null
valid_until: string | null
currency: Currency
exchange_rate: number | null
exchange_rate_date: string | null
subtotal: number
subtotal_sek: number | null
vat_amount: number
vat_amount_sek: number | null
total: number
total_sek: number | null
remaining_amount: number
vat_treatment: string
vat_rate: number | null
moms_ruta: string | null
reverse_charge_text: string | null
your_reference: string | null | undefined
our_reference: string | null | undefined
invoice_marking: string | null
notes: string | null | undefined
payment_link_url: string | null
payment_link_auto: boolean
ore_rounding: boolean | null
document_type: InvoiceDocumentType
deduction_total: number
deduction_personnummer_encrypted: string | null
deduction_personnummer_last4: string | null
default_dimensions: Record<string, string>
}
export type InvoiceWriteItemRow = {
sort_order: number
line_type: 'product' | 'text'
description: string
quantity: number
unit: string
unit_price: number
discount_percent: number
line_total: number
vat_rate: number
vat_amount: number
article_id: string | null
revenue_account: string | null
sales_order_item_id: string | null
deduction_type: 'rot' | 'rut' | null
deduction_amount: number
labor_hours: number | null
work_type: string | null
housing_designation: string | null
apartment_number: string | null
brf_org_number: string | null
accrual_period_start: string | null
accrual_period_end: string | null
accrual_balance_account: string | null
dimensions: Record<string, string>
}
export type BuildInvoiceWriteResult =
| { ok: true; invoiceFields: InvoiceWriteFields; items: InvoiceWriteItemRow[] }
// Domain validation failure: map via errorResponseFromCode(code, { details }).
| { ok: false; code: string; details?: Record<string, unknown> }
// Unexpected DB error from an internal lookup: map via errorResponse(dbError).
| { ok: false; dbError: unknown }
export async function buildInvoiceWriteData(params: {
supabase: SupabaseClient
companyId: string
customer: Customer
documentType: InvoiceDocumentType
input: InvoiceWriteInput
/**
* Update path only: the stored encrypted personnummer of the draft being
* edited. The plaintext is never rehydratable client-side (only _last4 is),
* so an edit that leaves the field empty keeps these stored values instead
* of failing ROT/RUT validation or wiping the ciphertext.
*/
existingPersonnummer?: { encrypted: string; last4: string | null } | null
}): Promise<BuildInvoiceWriteResult> {
const { supabase, companyId, customer, documentType, input, existingPersonnummer } = params
const items = input.items
const vatRules = getVatRules(customer.customer_type, customer.vat_number_validated)
// Gate on the PERMITTED set, not the picker default. Under huvudregeln
// (ML 6 kap. 34 §) a service to a foreign business is taxed where the buyer
// is established, so 0% is the default; but the ML 6 kap. exceptions taxed
// where the supply is performed (fastighetstjänster, persontransporter,
// korttidsuthyrning of vehicles, restaurang/catering, admission to cultural
// and sports events) carry Swedish VAT even to a German or a US company.
// Refusing every non-zero rate made a Stockholm hotel night or a conference
// ticket impossible to invoice. The default is still 0% (vatRules.rate is
// the fallback below), so a Swedish rate only lands here when set explicitly.
const permittedRates = getPermittedVatRates(customer.customer_type, customer.vat_number_validated)
const allowedRates = new Set(permittedRates.map((r) => r.rate))
// VAT registration gate (defense in depth: the invoice form already hides
// the Moms column when vat_registered is false). A non-momsregistrerad
// company books no output VAT: zero every line rate so the sale lands as
// momsfri (treatment 'exempt' → revenue 3004/3100, no 2611). 0% is a valid
// rate for every customer type, so the allowedRates guard below still passes.
const { data: vatSettings } = await supabase
.from('company_settings')
.select('vat_registered')
.eq('company_id', companyId)
.maybeSingle()
const notVatRegistered = vatSettings?.vat_registered === false
if (notVatRegistered && documentType !== 'delivery_note') {
for (const item of items) item.vat_rate = 0
}
// Periodisering guards. The line schema already validates the period shape;
// here we gate the flows where deferral has no meaning: cash method
// (recognition at payment), reverse charge/export (3308/3305 must reflect the
// full sale for ruta 39/40), and non-invoice document types.
const hasAccrualItems = items.some(
(item) => item.accrual_period_start && item.accrual_period_end,
)
if (hasAccrualItems) {
if (documentType !== 'invoice') {
return { ok: false, code: 'INVOICE_CREATE_ACCRUAL_INVALID', details: { reason: 'document_type', documentType } }
}
if (vatRules.treatment === 'reverse_charge' || vatRules.treatment === 'export') {
return { ok: false, code: 'INVOICE_CREATE_ACCRUAL_INVALID', details: { reason: 'vat_treatment', vatTreatment: vatRules.treatment } }
}
const { data: methodSettings } = await supabase
.from('company_settings')
.select('accounting_method')
.eq('company_id', companyId)
.maybeSingle()
if ((methodSettings?.accounting_method || 'accrual') !== 'accrual') {
return { ok: false, code: 'INVOICE_CREATE_ACCRUAL_INVALID', details: { reason: 'accounting_method' } }
}
}
// Free-text rows carry no amounts and are excluded from totals + VAT.
// Line totals are net of any per-line discount (rabatt i procent).
const subtotal = items.reduce(
(sum, item) =>
item.line_type === 'text'
? sum
: sum + computeLineNet(item.quantity, item.unit_price, item.discount_percent),
0,
)
let vatAmount = 0
if (documentType !== 'delivery_note') {
for (const item of items) {
if (item.line_type === 'text') continue
const itemRate = item.vat_rate !== undefined ? item.vat_rate : vatRules.rate
if (!allowedRates.has(itemRate)) {
return {
ok: false,
code: 'INVOICE_CREATE_VAT_RULE_VIOLATION',
details: {
attemptedRate: itemRate,
allowedRates: Array.from(allowedRates),
customerType: customer.customer_type,
},
}
}
// A class 1-2 (balance-sheet) posting override is only valid on
// zero-VAT lines (deposits, advances, outlays). On a VAT-bearing line
// it would divert the tax base away from a 3xxx account and understate
// ruta 05 of the momsdeklaration (ML 17 kap 24§).
if (
item.revenue_account &&
isBalanceSheetAccount(item.revenue_account) &&
itemRate > 0
) {
return {
ok: false,
code: 'INVOICE_CREATE_POSTING_ACCOUNT_VAT_CONFLICT',
details: { account: item.revenue_account, vatRate: itemRate },
}
}
const lineTotal = computeLineNet(item.quantity, item.unit_price, item.discount_percent)
vatAmount += Math.round(lineTotal * itemRate / 100 * 100) / 100
}
}
const total = documentType === 'delivery_note' ? 0 : subtotal + vatAmount
// Validate any per-line posting-account override against the company's chart
// of accounts. The legacy field name is revenue_account, but balance-sheet
// accounts are valid for deposits, customer advances, and genuine outlays.
// Zod already constrains the shape to classes 1-3; here we
// confirm each is a real, active account so a typo or unsuitable account
// can never be booked. Never trust the client.
const overrideAccounts = Array.from(
new Set(
items
.map((item) => item.revenue_account)
.filter((a): a is string => !!a),
),
)
if (overrideAccounts.length > 0) {
const { data: validAccounts, error: accountsError } = await supabase
.from('chart_of_accounts')
.select('account_number')
.eq('company_id', companyId)
.gte('account_class', 1)
.lte('account_class', 3)
.eq('is_active', true)
.in('account_number', overrideAccounts)
if (accountsError) {
return { ok: false, dbError: accountsError }
}
const validSet = new Set((validAccounts ?? []).map((a) => a.account_number))
const invalid = overrideAccounts.filter((a) => !validSet.has(a))
if (invalid.length > 0) {
return { ok: false, code: 'INVOICE_CREATE_REVENUE_ACCOUNT_INVALID', details: { invalidAccounts: invalid } }
}
}
// ROT/RUT-avdrag: validate prerequisites and compute the per-item +
// invoice-level deduction. Computed server-side (never trusted from the
// client) so a tampered request can't expand the 1513 receivable. Skipped
// entirely for proformas, delivery notes, and quotes: those documents don't
// post journal entries and have no deduction model.
let deductionTotal = 0
let deductionPersonnummerEncrypted: string | null = null
let deductionPersonnummerLast4: string | null = null
if (documentType === 'invoice') {
// Housing info satisfies the ROT requirement in either of two shapes
// (Begaran.xsd V6): fastighetsbeteckning (småhus/ägarlägenhet) OR
// lägenhetsnummer + bostadsrättsföreningens orgnr (bostadsrätt).
const fastighetProvided = !!input.deduction_housing_designation?.trim()
const apartmentProvided = !!input.deduction_apartment_number?.trim()
const brfProvided = !!input.deduction_brf_org_number?.trim()
if ((apartmentProvided || brfProvided) && !(apartmentProvided && brfProvided)) {
return {
ok: false,
code: 'INVOICE_CREATE_ROT_RUT_VALIDATION',
details: {
errors: ['För bostadsrätt krävs både lägenhetsnummer och föreningens organisationsnummer.'],
warnings: [],
},
}
}
const housingProvided = fastighetProvided || (apartmentProvided && brfProvided)
const personnummerRaw = input.deduction_personnummer?.trim() || ''
const validateInput = items.map((item) => ({
unit_price: item.unit_price,
quantity: item.quantity,
discount_percent: item.discount_percent ?? 0,
deduction_type: item.deduction_type ?? null,
// The deduction base is arbetskostnaden inkl. moms (HUSFL 6-9 §§), so
// the validator and total need the same per-line rate the item rows
// below are stored with.
vat_rate: item.vat_rate !== undefined ? item.vat_rate : vatRules.rate,
labor_hours: item.labor_hours ?? null,
work_type: item.work_type ?? null,
housing_designation: item.housing_designation ?? null,
}))
// Editing a draft: the stored personnummer only exists as ciphertext, so
// the client cannot resend it. An empty field on an invoice that still has
// deduction lines means "keep the stored one", not "remove it".
const hasDeductionItems = validateInput.some((item) => item.deduction_type != null)
const keepStoredPersonnummer =
personnummerRaw.length === 0 && hasDeductionItems && !!existingPersonnummer
// Neither typed nor stored on the draft: fall back to the personnummer on
// the customer card (kundkortet). It lives on customers.personal_number as
// ciphertext (or a legacy plaintext row) in 10- or 12-digit form; the
// Skatteverket claim needs 12 digits, so expand and Luhn-validate before
// counting it as provided. Anything unreadable, inexpandable or invalid is
// treated as absent: the validator below then asks the user to type one,
// which beats surfacing an "invalid personnummer" error for a value they
// never entered.
// Individual-only: ROT/RUT is a privatperson deduction (HUSFL), and
// customers.personal_number is individual-only in the Zod schemas but not
// in the DB, so a stray value on a business row (legacy import, direct
// write) must never be claimed on implicitly. A typed personnummer is
// unaffected: the user is stating it explicitly.
let customerCardPersonnummer: string | null = null
if (
personnummerRaw.length === 0 &&
hasDeductionItems &&
!keepStoredPersonnummer &&
customer.customer_type === 'individual'
) {
try {
const revealed = revealStoredCustomerPersonalNumber(customer.personal_number)
const expanded = revealed ? expandPersonnummerTo12(revealed) : null
if (expanded && validatePersonnummer(expanded).valid) {
customerCardPersonnummer = expanded
}
} catch {
// Undecryptable customer value: same as absent.
}
}
const personnummerProvided =
personnummerRaw.length > 0 || keepStoredPersonnummer || customerCardPersonnummer !== null
// The invoice currency decides whether the item amounts can be compared
// against the kronor ceilings at all. The booking rate is fetched further
// down (the write needs the invoice totals first), so a foreign-currency
// invoice reports "cap could not be checked" instead of measuring a
// foreign figure against 50 000 kr.
const validation = validateRotRut(validateInput, personnummerProvided, housingProvided, {
currency: input.currency,
})
if (validation.errors.length > 0) {
return {
ok: false,
code: 'INVOICE_CREATE_ROT_RUT_VALIDATION',
details: { errors: validation.errors, warnings: validation.warnings },
}
}
// Compute and (when present) encrypt the personnummer. The plaintext value
// never touches the DB: only the AES-256-GCM ciphertext + the last four
// digits go into invoices columns.
deductionTotal = computeInvoiceDeductionTotal(validateInput)
if (keepStoredPersonnummer && existingPersonnummer) {
deductionPersonnummerEncrypted = existingPersonnummer.encrypted
deductionPersonnummerLast4 = existingPersonnummer.last4
} else if (personnummerRaw.length > 0) {
const pnValid = validatePersonnummer(personnummerRaw)
if (!pnValid.valid) {
return { ok: false, code: 'INVOICE_CREATE_ROT_RUT_PERSONNUMMER_INVALID', details: { error: pnValid.error } }
}
deductionPersonnummerEncrypted = encryptPersonnummer(personnummerRaw)
deductionPersonnummerLast4 = extractLast4(personnummerRaw)
} else if (customerCardPersonnummer) {
// Already expanded to 12 digits and Luhn-validated above.
deductionPersonnummerEncrypted = encryptPersonnummer(customerCardPersonnummer)
deductionPersonnummerLast4 = extractLast4(customerCardPersonnummer)
}
}
const uniqueRates = new Set(
items
.filter((item) => item.line_type !== 'text')
.map((item) => item.vat_rate ?? vatRules.rate),
)
const isMixedRate = uniqueRates.size > 1
// Reverse-charge / export notation must describe what the invoice actually
// does. With a taxed-where-performed line now permitted (see the gate above),
// an invoice to a foreign business can carry only Swedish VAT: that supply is
// neither reverse-charged nor exported, so the header must not claim it is.
// "Omvänd betalningsskyldighet" (ML 17 kap 24 § p.11) next to charged Swedish
// VAT is a false statement: it tells the buyer to self-assess tax the seller
// already collected, and the buyer then cannot deduct it either.
//
// A mixed invoice (0% consulting + 12% hotel) keeps the notation: its
// zero-rated lines genuinely ARE reverse-charged, and the notation is
// required whenever the buyer is liable for any part. The per-rate booking
// splits them correctly on its own (generatePerRateLines only applies the
// invoice-level treatment to rate-0 lines), so 3308 and 3002/2621 both land
// in the right ruta.
const isSpecialTreatment =
vatRules.treatment === 'reverse_charge' || vatRules.treatment === 'export'
// No priced lines at all (text-only document) charges nothing either way:
// keep the customer's treatment rather than restamping it as domestic.
const hasZeroRatedLine = uniqueRates.size === 0 || uniqueRates.has(0)
const headerRules =
!isSpecialTreatment || hasZeroRatedLine ? vatRules : getVatRules('swedish_business')
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') {
// Rate date = the taxable event, not "today". ML 8 kap 21-23 §: the rate
// to use is the one "at time of taxable event (delivery/supply date or
// advance payment date, not invoice date unless same)". delivery_date is
// exactly that date when it is set (ML 17 kap 24 § p.7 requires it on the
// invoice whenever it differs from the invoice date); otherwise the two
// coincide and invoice_date is the taxable event. Stamping today's rate on
// a back-dated invoice booked the receivable (1510) and the output VAT
// (2611) at the wrong SEK value.
//
// `supabase` is passed so the shared exchange_rates cache is consulted on
// BOTH legs: the read-through before calling Riksbanken, and the
// last-cached-observation fallback when Riksbanken 429s. Without it a
// single transient rate limit left the invoice with a permanently NULL
// exchange_rate, which resolveSekAmount() then books 1:1 as if the foreign
// amount were kronor. The transaction ingest path has always passed it.
const rateDate = input.delivery_date || input.invoice_date
const rateData = await fetchExchangeRate(input.currency, new Date(rateDate), supabase)
if (rateData) {
exchangeRate = rateData.rate
exchangeRateDate = rateData.date
subtotalSek = convertToSEK(subtotal, exchangeRate)
vatAmountSek = convertToSEK(vatAmount, exchangeRate)
totalSek = convertToSEK(total, exchangeRate)
}
} else {
// SEK invoice: the *_sek twins equal their invoice-currency counterparts
// (rate 1) instead of staying NULL. The staged-operations commit path
// (lib/pending-operations/commit.ts, sekRate = 1) already writes them this
// way, and leaving them NULL here made the same invoice row differ by
// creation path, blanking SEK-reporting readers (KPI, AR ledger, full
// archive export). A failed Riksbanken fetch on a foreign-currency
// invoice still stores NULL above: that is a genuinely unknown value.
subtotalSek = Math.round(subtotal * 100) / 100
vatAmountSek = Math.round(vatAmount * 100) / 100
totalSek = Math.round(total * 100) / 100
}
// A quote has no due date, only an expiry. due_date is NOT NULL on the
// table and every date-ordered reader sorts on it, so it mirrors
// valid_until; valid_until stays the authoritative column.
const validUntil = documentType === 'quote' ? (input.valid_until ?? input.due_date) : null
const invoiceFields: InvoiceWriteFields = {
customer_id: input.customer_id,
invoice_date: input.invoice_date,
due_date: validUntil ?? input.due_date,
delivery_date: input.delivery_date ?? null,
valid_until: validUntil,
// quote_status is deliberately NOT a builder output: this object is
// spread into both inserts and draft updates, and a recorded accept or
// decline must never be overwritten by an edit. New quotes start open via
// the invoices_quote_defaults trigger (20260902221000); decisions are
// written only by the quote-status routes and the converter.
currency: input.currency,
exchange_rate: exchangeRate,
exchange_rate_date: exchangeRateDate,
subtotal: documentType === 'delivery_note' ? 0 : subtotal,
subtotal_sek: documentType === 'delivery_note' ? null : subtotalSek,
vat_amount: vatAmount,
vat_amount_sek: documentType === 'delivery_note' ? null : vatAmountSek,
total,
total_sek: documentType === 'delivery_note' ? null : totalSek,
// remaining_amount = total - deduction for real invoices so open-invoice
// queries treat them as fully unpaid for the CUSTOMER's share: the
// Skatteverket portion is on 1513 and clears when the agency pays out.
// Proformas / delivery notes / quotes have no payment obligation → keep 0.
remaining_amount: documentType === 'invoice' ? total - deductionTotal : 0,
vat_treatment: notVatRegistered ? 'exempt' : headerRules.treatment,
vat_rate: documentType === 'delivery_note' ? 0 : (isMixedRate ? null : (uniqueRates.values().next().value ?? vatRules.rate)),
moms_ruta: notVatRegistered ? null : headerRules.momsRuta,
reverse_charge_text: notVatRegistered ? null : (headerRules.reverseChargeText || null),
your_reference: input.your_reference,
our_reference: input.our_reference,
// Always a concrete value so a draft edit that cleared the field NULLs
// the column (supabase-js drops undefined keys).
invoice_marking: input.invoice_marking?.trim() || null,
notes: input.notes,
// Always a concrete value (never undefined) so a draft edit that cleared
// the field actually NULLs the column: supabase-js drops undefined keys.
payment_link_url: input.payment_link_url?.trim() || null,
// Automation opt-out for the Stripe payment link; default on. The form
// always sends the field, so a draft edit that unticked it persists false.
payment_link_auto: input.payment_link_auto ?? true,
// Display-only öresavrundning override; null inherits company_settings.ore_rounding.
ore_rounding: input.ore_rounding ?? null,
document_type: documentType,
deduction_total: deductionTotal,
deduction_personnummer_encrypted: deductionPersonnummerEncrypted,
deduction_personnummer_last4: deductionPersonnummerLast4,
// Dimensions PR7: stored as-is; the generators coerce + merge at booking.
default_dimensions: input.default_dimensions ?? {},
}
const itemRows: InvoiceWriteItemRow[] = items.map((item, index) => {
// Free-text / blank rows carry no amounts and never book: store the
// description only and zero everything else. Keys must match the product
// branch exactly so a bulk insert isn't rejected for differing key sets.
if (item.line_type === 'text') {
return {
sort_order: index,
line_type: 'text',
description: item.description ?? '',
quantity: 0,
unit: '',
unit_price: 0,
discount_percent: 0,
line_total: 0,
vat_rate: 0,
vat_amount: 0,
article_id: null,
revenue_account: null,
sales_order_item_id: null,
deduction_type: null,
deduction_amount: 0,
labor_hours: null,
work_type: null,
housing_designation: null,
apartment_number: null,
brf_org_number: null,
accrual_period_start: null,
accrual_period_end: null,
accrual_balance_account: null,
dimensions: {},
}
}
const itemRate = item.vat_rate !== undefined ? item.vat_rate : vatRules.rate
const discountPercent = item.discount_percent ?? 0
const lineTotal = computeLineNet(item.quantity, item.unit_price, discountPercent)
const itemVat = documentType === 'delivery_note' ? 0 : Math.round(lineTotal * itemRate / 100 * 100) / 100
// ROT/RUT deduction is recomputed server-side so a tampered client can't
// expand the 1513 receivable beyond the rules. Non-invoice document types
// never carry deduction_type.
const deductionType = documentType === 'invoice' ? (item.deduction_type ?? null) : null
const deductionAmount = deductionType
? computeDeduction({
unit_price: item.unit_price,
quantity: item.quantity,
discount_percent: discountPercent,
deduction_type: deductionType,
vat_rate: itemRate,
})
: 0
return {
sort_order: index,
line_type: 'product',
description: item.description,
quantity: item.quantity,
unit: item.unit,
unit_price: item.unit_price,
discount_percent: discountPercent,
line_total: lineTotal,
vat_rate: itemRate,
vat_amount: itemVat,
// Article linkage. revenue_account is frozen-copied here so a later
// article edit never re-books this line; null falls through to the
// VAT-treatment-derived account in generatePerRateLines().
article_id: item.article_id ?? null,
revenue_account: item.revenue_account ?? null,
// Only a faktura consumes kundorder quantity: a quote or proforma line
// linked to an order item would mark the order invoiced without any
// invoice existing (sales_order_invoiced_quantities counts by status).
sales_order_item_id: documentType === 'invoice' ? (item.sales_order_item_id ?? null) : null,
deduction_type: deductionType,
deduction_amount: deductionAmount,
labor_hours: documentType === 'invoice' ? (item.labor_hours ?? null) : null,
work_type: documentType === 'invoice' ? (item.work_type ?? null) : null,
// Property info: per-line value wins, else the invoice-level claim-card
// value is stamped onto every deduction line so the Skatteverket file
// generator can read it off the line later. Non-deduction lines carry
// no property data (privacy by default).
housing_designation:
documentType === 'invoice' && deductionType
? (item.housing_designation ?? input.deduction_housing_designation?.trim() ?? null) || null
: null,
apartment_number:
documentType === 'invoice' && deductionType
? (item.apartment_number ?? input.deduction_apartment_number?.trim() ?? null) || null
: null,
brf_org_number:
documentType === 'invoice' && deductionType
? (item.brf_org_number ?? input.deduction_brf_org_number?.trim() ?? null) || null
: null,
// Periodisering (förutbetald intäkt): frozen onto the line. The schedule
// itself is created when the invoice is sent/booked. ROT/RUT lines never
// defer (schema-enforced); the guard above restricted this to real
// invoices under faktureringsmetoden.
accrual_period_start:
documentType === 'invoice' && !deductionType
? (item.accrual_period_start ?? null)
: null,
accrual_period_end:
documentType === 'invoice' && !deductionType
? (item.accrual_period_end ?? null)
: null,
accrual_balance_account:
documentType === 'invoice' && !deductionType && item.accrual_period_start && item.accrual_period_end
? (item.accrual_balance_account ?? DEFAULT_DEFERRED_REVENUE_ACCOUNT)
: null,
dimensions: item.dimensions ?? {},
}
})
return { ok: true, invoiceFields, items: itemRows }
}