Files
accounted/lib/invoices/self-billed-sale.ts
T
MattssonandClaude Fable 5.1 3918ff6620 fix(customers): make country ISO-2 everywhere and check it against the customer type (#2241)
* fix(customers): make country ISO-2 everywhere and check it against the customer type (#2025, #2028)

customers.country and suppliers.country were read as ISO codes by the
periodisk sammanstallning (SKV 5740), Peppol and the provider importers but
written as English names by the customer form and the v1 API, so a correct
German customer produced GERMANY811234567 in the SKV file plus two false
warnings, and an EU customer saved with land Sverige got reverse charge with
nothing objecting until after the invoice was sent.

- lib/vat/country-codes.ts: one helper that normalises codes and the
  Swedish/English names the writers used to store, the country-vs-type
  rule (swedish_business = SE, eu_business = EU member other than SE that
  matches the VAT prefix, non_eu_business = outside the EU), and the
  reverse-charge country gate.
- Writers: customer form and supplier form get a country select; internal
  REST, v1 REST, bulk-create, MCP create/update, CSV/Excel import and the
  provider migration mapper normalise to a code and refuse unknown text;
  the consistency rule is a form error and an API 400
  (CUSTOMER_COUNTRY_MISMATCH on update). An omitted country is SE for
  Swedish types, derived from the VAT prefix for eu_business, required
  for non_eu_business.
- vat-rules.ts: getVatRules and friends take the country as a third
  argument and grant reverse charge only for an EU country other than SE;
  every invoice/sales-order/MCP call site passes customer.country.
- periodisk sammanstallning reads legacy names through the same helper.
- Migration 20260903170000: normalize_country_code() SQL twin, country_raw
  rollback column on both tables, backfill of every non-code row; unknown
  text is left as-is. pg-real test for the function.

Closes #2025, closes #2028

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

* fix(customers): keep reverse charge for defaulted-SE EU rows, gate the country rule on the fields it reads, fix build

Skeptic and CI findings on #2241, one pass:

- Migration step 4: eu_business rows whose country was null or only the old
  writer default (SE) while the VAT number names another EU member take the
  country from the prefix. The pre-2026-09 rules granted reverse charge on
  type + VIES validation alone, so these rows invoiced at 0% and would have
  flipped to 25% on the next invoice. country_raw = '' marks a null origin;
  rollback uses nullif(country_raw, '').
- countryPermitsReverseCharge refuses SE only: a VIES-validated number
  outweighs a non-EU address (Swiss company registered in DE, Monaco with a
  FR number, Northern Ireland XI).
- checkCountryConsistency: an eu_business outside the EU VAT area is
  accepted when the VAT prefix is an EU-trade registration (incl. XI);
  Monaco maps to the FR prefix.
- Internal PATCH, MCP update and the commit executor judge the country rule
  only when customer_type, country or vat_number is part of the update, so
  a contradictory legacy row can still change its email (v1 already did).
- Webshop-order customers get the order's billing country; spreadsheet
  import derives a missing country from the type and flags contradictions
  (parser row error + execute schema refine).
- Build: v1 [id] route typed the existing row through a narrowed alias
  (never) and passed messageSv/messageEn the v1 error context lacks; the
  self-billed customer projection lacked country.
- Checks: regenerated skills/accounted-api (customer example country SE).
- New parity test holds the migration's SQL name table to the TS table.
- DECISIONS.md: correct migration version and the revised rule.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 18:09:46 +02:00

377 lines
14 KiB
TypeScript

/**
* Received self-billing invoice (mottagen självfaktura, ML 17 kap 15§).
*
* A self-billing invoice we RECEIVE is a SALE for us: the customer issued the
* document on our behalf, so for our books it is an ordinary customer invoice
* (Debit 1510, Credit 30xx + 26xx) and the output VAT lands in our
* momsdeklaration. It differs from a normal customer invoice in two ways:
* - We never assign a number from our own series (BFL 5 kap 6§): the
* counterparty's number lives in external_invoice_number and our own
* invoice_number stays null (enforced by invoices_self_billed_numbering).
* - There is no send step: under faktureringsmetoden it is booked on
* registration; under kontantmetoden it stays unbooked until payment.
*
* This is the single implementation behind both the internal dashboard route
* (/api/invoices/self-billed) and the public v1 invoice create endpoint (which
* accepts an optional is_self_billed flag), so the two can never drift.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import { getVatRules, getPermittedVatRates } from '@/lib/invoices/vat-rules'
import { fetchExchangeRate, convertToSEK } from '@/lib/currency/riksbanken'
import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
import { eventBus } from '@/lib/events'
import { roundOre } from '@/lib/money'
import { createLogger } from '@/lib/logger'
import type { EntityType, Invoice } from '@/types'
const log = createLogger('self-billed-sale')
export interface SelfBilledSaleItemInput {
description: string
quantity: number
unit: string
unit_price: number
vat_rate?: number
}
export interface SelfBilledSaleInput {
customer_id: string
external_invoice_number: string
self_billing_agreement_ref?: string | null
invoice_date: string
received_date: string
due_date: string
currency: string
notes?: string | null
items: SelfBilledSaleItemInput[]
}
export type SelfBilledSaleFailure =
| { code: 'customer_not_found'; customerId: string }
| { code: 'vat_rule_violation'; attemptedRate: number; allowedRates: number[]; customerType: string }
| { code: 'fx_rate_unavailable'; currency: string; invoiceDate: string }
| { code: 'insert_failed'; stage?: string; pgCode?: string; pgMessage?: string }
| { code: 'items_failed'; pgCode?: string; pgMessage?: string }
| { code: 'no_fiscal_period' }
interface SelfBilledCustomer {
id: string
name: string
customer_type: string
vat_number_validated: boolean | null
/** ISO 3166-1 alpha-2; gates reverse charge together with the two above. */
country?: string | null
}
export interface SelfBilledSaleComputedItem {
sort_order: number
description: string
quantity: number
unit: string
unit_price: number
line_total: number
vat_rate: number
vat_amount: number
}
export interface SelfBilledSaleDraft {
customer: SelfBilledCustomer
items: SelfBilledSaleComputedItem[]
subtotal: number
vatAmount: number
total: number
subtotalSek: number | null
vatAmountSek: number | null
totalSek: number | null
exchangeRate: number | null
exchangeRateDate: string | null
currency: string
vatTreatment: string
momsRuta: string | null
reverseChargeText: string | null
vatRate: number | null
}
/**
* Validate + cost a received self-billing invoice. No writes, no number
* consumption. Used for the dry-run preview and by createSelfBilledSaleInvoice.
*/
export async function resolveSelfBilledSaleDraft(
supabase: SupabaseClient,
companyId: string,
input: SelfBilledSaleInput,
): Promise<{ ok: true; draft: SelfBilledSaleDraft } | { ok: false; failure: SelfBilledSaleFailure }> {
// The issuer of a self-billing invoice is, in our books, the customer we sold
// to. Require an existing customer so VAT rules + reporting work. Project only
// the fields used (data minimisation).
const { data: customer, error: customerError } = await supabase
.from('customers')
.select('id, name, customer_type, vat_number_validated, country')
.eq('id', input.customer_id)
.eq('company_id', companyId)
.maybeSingle()
if (customerError || !customer) {
return { ok: false, failure: { code: 'customer_not_found', customerId: input.customer_id } }
}
const c = customer as SelfBilledCustomer
// VAT treatment is driven by who the customer is (domestic / EU reverse charge
// / export), exactly like an own-issued invoice.
const vatRules = getVatRules(
c.customer_type as Parameters<typeof getVatRules>[0],
c.vat_number_validated ?? undefined,
c.country,
)
// Gate on the PERMITTED set, not the picker default, exactly like
// buildInvoiceWriteData: the ML 6 kap. supplies taxed where they are performed
// (hotel/restaurang 12%, persontransport and event admission 6%,
// fastighetstjänst and korttidsuthyrning 25%) carry Swedish VAT even to a
// foreign business customer, and a self-bill transcribes what the customer
// actually invoiced in our name. The default is still 0% (vatRules.rate is the
// fallback below), so a Swedish rate only lands here when the line carried it.
const permittedRates = getPermittedVatRates(
c.customer_type as Parameters<typeof getPermittedVatRates>[0],
c.vat_number_validated ?? undefined,
c.country,
)
const allowedRates = new Set(permittedRates.map((r) => r.rate))
const items: SelfBilledSaleComputedItem[] = []
let vatAmount = 0
for (let i = 0; i < input.items.length; i++) {
const item = input.items[i]
const itemRate = item.vat_rate !== undefined ? item.vat_rate : vatRules.rate
if (!allowedRates.has(itemRate)) {
return {
ok: false,
failure: {
code: 'vat_rule_violation',
attemptedRate: itemRate,
allowedRates: Array.from(allowedRates),
customerType: c.customer_type,
},
}
}
const lineTotal = item.quantity * item.unit_price
const lineVat = roundOre((lineTotal * itemRate) / 100)
vatAmount += lineVat
items.push({
sort_order: i,
description: item.description,
quantity: item.quantity,
unit: item.unit,
unit_price: item.unit_price,
line_total: lineTotal,
vat_rate: itemRate,
vat_amount: lineVat,
})
}
const subtotal = input.items.reduce((sum, item) => sum + item.quantity * item.unit_price, 0)
const total = roundOre(subtotal + vatAmount)
const uniqueRates = new Set(input.items.map((item) => item.vat_rate ?? vatRules.rate))
const isMixedRate = uniqueRates.size > 1
const vatRate = isMixedRate ? null : (uniqueRates.values().next().value ?? vatRules.rate)
// Foreign currency: convert using the rate on the INVOICE date (ML 7 kap 7§),
// not today's rate. Refuse rather than fall through to a silent 1:1 booking.
let exchangeRate: number | null = null
let exchangeRateDate: string | null = null
let subtotalSek: number | null = null
let vatAmountSek: number | null = null
let totalSek: number | null = null
if (input.currency !== 'SEK') {
const rateData = await fetchExchangeRate(
input.currency as Parameters<typeof fetchExchangeRate>[0],
new Date(input.invoice_date),
)
if (!rateData) {
return { ok: false, failure: { code: 'fx_rate_unavailable', currency: input.currency, invoiceDate: input.invoice_date } }
}
exchangeRate = rateData.rate
exchangeRateDate = rateData.date
subtotalSek = convertToSEK(subtotal, exchangeRate)
vatAmountSek = convertToSEK(vatAmount, exchangeRate)
totalSek = convertToSEK(total, exchangeRate)
}
return {
ok: true,
draft: {
customer: c,
items,
subtotal,
vatAmount,
total,
subtotalSek,
vatAmountSek,
totalSek,
exchangeRate,
exchangeRateDate,
currency: input.currency,
vatTreatment: vatRules.treatment,
momsRuta: vatRules.momsRuta,
reverseChargeText: vatRules.reverseChargeText || null,
vatRate,
},
}
}
/**
* Register a received self-billing invoice: insert the row, its items, and
* (under faktureringsmetoden) book the registration entry. Rolls back on any
* failure. Returns the created invoice or a structured failure. Re-throws an
* unexpected booking error (e.g. a period-lock trigger) after rolling back so
* the caller can map it to its structured envelope.
*/
export async function createSelfBilledSaleInvoice(
supabase: SupabaseClient,
companyId: string,
userId: string,
input: SelfBilledSaleInput,
): Promise<{ ok: true; invoice: Invoice } | { ok: false; failure: SelfBilledSaleFailure }> {
const resolved = await resolveSelfBilledSaleDraft(supabase, companyId, input)
if (!resolved.ok) return resolved
const { draft } = resolved
const { data: invoice, error: invoiceError } = await supabase
.from('invoices')
.insert({
user_id: userId,
company_id: companyId,
customer_id: input.customer_id,
// No own number: the counterparty's number lives in external_invoice_number.
invoice_number: null,
is_self_billed: true,
external_invoice_number: input.external_invoice_number,
self_billing_agreement_ref: input.self_billing_agreement_ref ?? null,
received_date: input.received_date,
invoice_date: input.invoice_date,
due_date: input.due_date,
// Booked + awaiting/with payment: never a draft.
status: 'sent',
currency: input.currency,
exchange_rate: draft.exchangeRate,
exchange_rate_date: draft.exchangeRateDate,
subtotal: draft.subtotal,
subtotal_sek: draft.subtotalSek,
vat_amount: draft.vatAmount,
vat_amount_sek: draft.vatAmountSek,
total: draft.total,
total_sek: draft.totalSek,
remaining_amount: draft.total,
vat_treatment: draft.vatTreatment,
vat_rate: draft.vatRate,
moms_ruta: draft.momsRuta,
reverse_charge_text: draft.reverseChargeText,
notes: input.notes,
document_type: 'invoice',
})
.select()
.single()
if (invoiceError || !invoice) {
log.error('self-billed invoice insert failed', invoiceError as Error)
return {
ok: false,
failure: { code: 'insert_failed', pgCode: invoiceError?.code, pgMessage: invoiceError?.message },
}
}
const itemRows = draft.items.map((item) => ({
invoice_id: invoice.id,
sort_order: item.sort_order,
description: item.description,
quantity: item.quantity,
unit: item.unit,
unit_price: item.unit_price,
line_total: item.line_total,
vat_rate: item.vat_rate,
vat_amount: item.vat_amount,
}))
const { error: itemsError } = await supabase.from('invoice_items').insert(itemRows)
if (itemsError) {
// Items insert failed: remove the orphaned invoice header.
await supabase.from('invoices').delete().eq('id', invoice.id)
log.error('self-billed invoice items insert failed; rolled back', itemsError, { invoiceId: invoice.id })
return { ok: false, failure: { code: 'items_failed', pgCode: itemsError.code, pgMessage: itemsError.message } }
}
const { data: settings } = await supabase
.from('company_settings')
.select('accounting_method, entity_type')
.eq('company_id', companyId)
.maybeSingle()
const accountingMethod = settings?.accounting_method || 'accrual'
const entityType = (settings?.entity_type as EntityType) || 'enskild_firma'
const { data: completeInvoice } = await supabase
.from('invoices')
.select('*, customer:customers(*), items:invoice_items(*)')
.eq('id', invoice.id)
.maybeSingle()
// Faktureringsmetoden: book the registration entry now (Debit 1510, Credit
// 30xx + 26xx). Kontantmetoden: leave unbooked until payment.
if (accountingMethod === 'accrual') {
if (!completeInvoice) {
await supabase.from('invoices').delete().eq('id', invoice.id)
log.error('self-billed invoice re-fetch returned no row before booking; rolled back', undefined, {
invoiceId: invoice.id,
})
return { ok: false, failure: { code: 'insert_failed', stage: 'refetch_before_booking' } }
}
try {
const journalEntry = await createInvoiceJournalEntry(
supabase,
companyId,
userId,
completeInvoice as Invoice,
entityType,
draft.customer.name,
{ descriptionPrefix: 'Självfaktura', numberOverride: input.external_invoice_number },
)
if (!journalEntry) {
// No open fiscal period: roll back so we never leave an unbooked sale.
await supabase.from('invoices').delete().eq('id', invoice.id)
return { ok: false, failure: { code: 'no_fiscal_period' } }
}
const { error: linkError } = await supabase
.from('invoices')
.update({ journal_entry_id: journalEntry.id })
.eq('id', invoice.id)
.eq('company_id', companyId)
if (linkError) {
// The verifikat is committed (immutable): don't roll it back over a
// failed convenience link. Log loudly.
log.error('self-billed invoice booked but journal_entry_id link failed', linkError, {
invoiceId: invoice.id,
journalEntryId: journalEntry.id,
})
}
} catch (err) {
await supabase.from('invoices').delete().eq('id', invoice.id)
log.error('failed to book self-billed invoice; rolled back', err as Error, { invoiceId: invoice.id })
throw err
}
}
const { data: finalInvoice } = await supabase
.from('invoices')
.select('*, customer:customers(*), items:invoice_items(*)')
.eq('id', invoice.id)
.maybeSingle()
const responseInvoice = (finalInvoice ?? completeInvoice ?? invoice) as Invoice
await eventBus.emit({
type: 'invoice.created',
payload: { invoice: responseInvoice, companyId, userId },
})
return { ok: true, invoice: responseInvoice }
}