Part of #2059 (Part 2, the hardening bug). The FK on invoice_items.article_id proves the article exists, not that it belongs to the writing company: FK validation ignores RLS, and the v1 routes run on the service-role client with no RLS at all. Only the two MCP commit executors checked tenancy; the cookie POST/PATCH, v1 POST/PATCH, webshop and sales-order writers passed items[].article_id straight through the builder. Move the check to the one point every writer converges on: buildInvoiceWriteData collects the distinct article ids from product lines, runs one select scoped on company_id, and refuses with the new INVOICE_CREATE_ARTICLE_INVALID (400, Swedish message via the structured-error registry) on any miss. The MCP executor checks stay as the tamper gate for staged rows. Tests: builder unit cases (miss refused with details, dedupe + happy path, no article ids means no query, DB error surfaces as dbError), cookie PATCH and v1 POST refusal cases, and the existing v1 persist + MCP update tests now answer the builder's scoped select. Claude-Session: https://claude.ai/code/session_019SaJfqNi4VmsG8FMKq99G6 Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
712 lines
32 KiB
TypeScript
712 lines
32 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, customer.country)
|
|
// 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, customer.country)
|
|
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 } }
|
|
}
|
|
}
|
|
|
|
// Article linkage is a tenancy invariant: the FK on invoice_items.article_id
|
|
// proves the article EXISTS, not that it belongs to THIS company. FK
|
|
// validation is internal to Postgres and ignores RLS, and the v1 routes run
|
|
// on the service-role client with no RLS at all, so a body carrying another
|
|
// company's article UUID would otherwise persist a cross-tenant reference.
|
|
// Every invoice write path converges here (cookie POST/PATCH, v1 POST/PATCH,
|
|
// webshop, sales-order conversion, MCP update), so one scoped select covers
|
|
// them all. The MCP executors keep their own pre-check as the tamper gate
|
|
// for staged rows. Text rows never persist an article (mapped to null below).
|
|
const articleIds = Array.from(
|
|
new Set(
|
|
items
|
|
.filter((item) => item.line_type !== 'text')
|
|
.map((item) => item.article_id)
|
|
.filter((a): a is string => !!a),
|
|
),
|
|
)
|
|
if (articleIds.length > 0) {
|
|
const { data: articleRows, error: articlesError } = await supabase
|
|
.from('articles')
|
|
.select('id')
|
|
.eq('company_id', companyId)
|
|
.in('id', articleIds)
|
|
|
|
if (articlesError) {
|
|
return { ok: false, dbError: articlesError }
|
|
}
|
|
const foundArticleIds = new Set((articleRows ?? []).map((a) => a.id))
|
|
const invalidArticleIds = articleIds.filter((a) => !foundArticleIds.has(a))
|
|
if (invalidArticleIds.length > 0) {
|
|
return { ok: false, code: 'INVOICE_CREATE_ARTICLE_INVALID', details: { invalidArticleIds } }
|
|
}
|
|
}
|
|
|
|
// 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 }
|
|
}
|