Feat/voucher docs (#664)

* feat: implement inbox document picker and linking functionality

* feat: implement self-billing invoice functionality

- Added support for registering self-billed invoices received from customers.
- Updated the invoice schema to include fields for self-billing metadata such as `is_self_billed`, `external_invoice_number`, `self_billing_agreement_ref`, and `received_date`.
- Created API route for handling self-billed invoice submissions, including validation and error handling.
- Implemented database migrations to add necessary columns and constraints for self-billing invoices.
- Developed tests to ensure correct behavior of self-billing invoice creation and validation rules.
- Updated Swedish localization files to include new terms related to self-billing.

* feat: enforce SIE import requirement for non-Fortnox providers in migration process

* feat: streamline invoice processing and enhance error logging across APIs
This commit is contained in:
Mattsson
2026-06-04 13:14:57 +02:00
committed by GitHub
parent c1be9f15dd
commit 3e42fc6f32
45 changed files with 2675 additions and 529 deletions
@@ -1205,6 +1205,111 @@ describe('createSupplierInvoiceCashEntry', () => {
})
})
// ============================================================
// createSupplierInvoiceCashEntry — foreign-currency settlement
// (kontantmetoden books the expense at the PAYMENT-date rate; the
// payment-account credit must equal the SEK that left the bank)
// ============================================================
describe('createSupplierInvoiceCashEntry — foreign-currency settlement', () => {
beforeEach(() => {
vi.clearAllMocks()
mockedFindFiscalPeriod.mockResolvedValue('period-1')
})
it('books a no-VAT foreign invoice at the payment-date rate, not the invoice rate (the reported bug)', async () => {
// 19 USD invoice. The invoice was captured at rate 9.20 (→ 174.80 SEK),
// but the bank actually paid 175.28 SEK at the payment-date rate. Under
// kontantmetoden the expense belongs at the payment rate, so 1930 must
// equal the bank movement exactly — and there is NO kursdifferens.
const invoice = makeSupplierInvoice({
currency: 'USD', exchange_rate: 9.20, subtotal: 19, vat_amount: 0, total: 19,
})
const items = [makeItem({ line_total: 19, account_number: '4000', vat_rate: 0, vat_amount: 0 })]
await createSupplierInvoiceCashEntry(
null as never, 'company-1', 'user-1', invoice, items, '2026-01-19', 'non_eu_business',
undefined, undefined, 175.28,
)
const input = mockedCreateEntry.mock.calls[0][3]
// Payment-date rate (175.28 / 19), NOT the invoice's 9.20 (which would give 174.80).
expect(findByAccount(input.lines, '4000')[0].debit_amount).toBe(175.28)
expect(findByAccount(input.lines, '1930')[0].credit_amount).toBe(175.28)
// No kursvinst/kursförlust under the cash method.
expect(findByAccount(input.lines, '7960')).toHaveLength(0)
expect(findByAccount(input.lines, '3960')).toHaveLength(0)
expect(findByAccount(input.lines, '2641')).toHaveLength(0)
assertBalanced(input)
})
it('translates a foreign reverse-charge invoice (fiktiv moms base) at the payment rate', async () => {
// 100 USD EU-service invoice, reverse charge. Bank paid 922.50 SEK.
const invoice = makeSupplierInvoice({
currency: 'USD', exchange_rate: 9.20, subtotal: 100, vat_amount: 0, total: 100, reverse_charge: true,
})
const items = [makeItem({ line_total: 100, account_number: '6540', vat_rate: 0.25, vat_amount: 0 })]
await createSupplierInvoiceCashEntry(
null as never, 'company-1', 'user-1', invoice, items, '2026-01-19', 'eu_business',
undefined, undefined, 922.50,
)
const input = mockedCreateEntry.mock.calls[0][3]
expect(findByAccount(input.lines, '6540')[0].debit_amount).toBe(922.50)
// Fiktiv moms on the payment-rate base (922.50 × 25%), and it nets out so
// 1930 still equals the bank movement.
expect(findByAccount(input.lines, '2645')[0].debit_amount).toBeCloseTo(230.63, 2)
expect(findByAccount(input.lines, '2614')[0].credit_amount).toBeCloseTo(230.63, 2)
expect(findByAccount(input.lines, '1930')[0].credit_amount).toBe(922.50)
assertBalanced(input)
})
it('folds a sub-öre rounding residual into the largest expense line so 1930 = bank SEK', async () => {
// Two expense lines whose per-line payment-rate rounding sums to 175.29,
// one öre over the 175.28 that actually left the bank. The residual is
// folded into the larger line so the bank credit lands exactly on 175.28.
const invoice = makeSupplierInvoice({
currency: 'USD', exchange_rate: 1.75, subtotal: 100, vat_amount: 0, total: 100,
})
const items = [
makeItem({ id: 'a', line_total: 33.33, account_number: '4000', vat_rate: 0, vat_amount: 0 }),
makeItem({ id: 'b', line_total: 66.67, account_number: '5000', vat_rate: 0, vat_amount: 0 }),
]
await createSupplierInvoiceCashEntry(
null as never, 'company-1', 'user-1', invoice, items, '2026-01-19', 'swedish_business',
undefined, undefined, 175.28,
)
const input = mockedCreateEntry.mock.calls[0][3]
const debitSum = input.lines
.filter((l) => l.debit_amount > 0)
.reduce((s, l) => s + l.debit_amount, 0)
expect(Math.round(debitSum * 100) / 100).toBe(175.28)
expect(findByAccount(input.lines, '1930')[0].credit_amount).toBe(175.28)
assertBalanced(input)
})
it('ignores settledBankSek for a SEK invoice (behaviour unchanged)', async () => {
const invoice = makeSupplierInvoice({
currency: 'SEK', subtotal: 8000, vat_amount: 2000, total: 10000,
})
const items = [makeItem({ line_total: 8000, account_number: '6200', vat_rate: 0.25 })]
await createSupplierInvoiceCashEntry(
null as never, 'company-1', 'user-1', invoice, items, '2024-07-01', 'swedish_business',
undefined, undefined, 9999, // bogus settlement SEK must be ignored for a SEK invoice
)
const input = mockedCreateEntry.mock.calls[0][3]
expect(findByAccount(input.lines, '6200')[0].debit_amount).toBe(8000)
expect(findByAccount(input.lines, '2641')[0].debit_amount).toBe(2000)
expect(findByAccount(input.lines, '1930')[0].credit_amount).toBe(10000)
assertBalanced(input)
})
})
// ============================================================
// createSupplierCreditNoteEntry
// ============================================================
+16 -3
View File
@@ -231,7 +231,15 @@ export async function createInvoiceJournalEntry(
userId: string,
invoice: Invoice,
entityType: EntityType = 'enskild_firma',
customerName?: string
customerName?: string,
/**
* Overrides for non-standard sales that still book identically to a customer
* invoice. Used by self-billing received (mottagen självfaktura): the
* verifikation should read "Självfaktura <external number>" rather than
* "Kundfaktura <our number>", and the number tag must be the counterparty's
* external number because the row has no own `invoice_number`.
*/
options?: { descriptionPrefix?: string; numberOverride?: string | null }
): Promise<JournalEntry | null> {
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId, invoice.invoice_date)
if (!fiscalPeriodId) {
@@ -241,7 +249,7 @@ export async function createInvoiceJournalEntry(
const lines: CreateJournalEntryLineInput[] = []
const isForeign = invoice.currency !== 'SEK'
const tag = invoiceTag(invoice)
const tag = options?.numberOverride ?? invoiceTag(invoice)
// Credit lines: revenue + VAT per rate group (compute first to guarantee balance)
const creditLines: CreateJournalEntryLineInput[] = []
@@ -314,7 +322,12 @@ export async function createInvoiceJournalEntry(
const input: CreateJournalEntryInput = {
fiscal_period_id: fiscalPeriodId,
entry_date: invoice.invoice_date,
description: buildInvoiceDescription('Kundfaktura', invoice.invoice_number, customerName, invoice.id),
description: buildInvoiceDescription(
options?.descriptionPrefix ?? 'Kundfaktura',
options?.numberOverride ?? invoice.invoice_number,
customerName,
invoice.id,
),
source_type: 'invoice_created',
source_id: invoice.id,
lines,
+56 -8
View File
@@ -7,6 +7,7 @@ import {
resolveReverseChargeRate,
} from './vat-entries'
import { createLogger } from '@/lib/logger'
import { roundOre } from '@/lib/money'
import type { SupabaseClient } from '@supabase/supabase-js'
import type {
CreateJournalEntryInput,
@@ -282,7 +283,12 @@ export async function createSupplierInvoiceCashEntry(
paymentDate: string,
supplierType: string,
supplierName?: string,
paymentAccount?: string
paymentAccount?: string,
// SEK that actually settled the invoice (the amount that left the bank). For
// a foreign-currency invoice this pins the whole entry to the PAYMENT-date
// rate — see the kontantmetoden note below. Omit for SEK invoices and the
// behaviour is byte-identical to before.
settledBankSek?: number
): Promise<JournalEntry | null> {
const creditAccount = paymentAccount || '1930'
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId, paymentDate)
@@ -291,25 +297,46 @@ export async function createSupplierInvoiceCashEntry(
return null
}
// Under kontantmetoden the booked affärshändelse IS the payment (BFL 5 kap —
// "bokföring vid betalningstillfället"), so the entire verifikat is translated
// at the PAYMENT-date rate (ÅRL 4 kap 6 §). There is no kursvinst/kursförlust
// because no leverantörsskuld was ever carried at a historical rate — that
// only happens under faktureringsmetoden (handled by the 2440-clearing path
// with 7960/3960). When the caller passes the SEK that actually settled the
// invoice, we derive the implied payment-date rate from it so the payment-
// account credit equals the bank movement to the öre. For SEK invoices, or
// when no settlement SEK is supplied, we keep the invoice's stored rate.
const isForeign = invoice.currency !== 'SEK'
const useSettlementRate =
settledBankSek != null && settledBankSek > 0 && isForeign && invoice.total > 0
const effectiveRate = useSettlementRate
? settledBankSek / invoice.total
: invoice.exchange_rate
const desc = buildSupplierDescription('Kontantbetalning leverantörsfaktura', invoice.supplier_invoice_number, supplierName)
const lines: CreateJournalEntryLineInput[] = []
// Expense debit lines tracked separately so a sub-öre translation residual
// can be folded into the largest one (öresavrundning step below).
const expenseLines: CreateJournalEntryLineInput[] = []
// Aggregate expense amounts by account number and convert to SEK
const expenseByAccount = new Map<string, number>()
for (const item of items) {
const current = expenseByAccount.get(item.account_number) || 0
const itemSek = resolveSekAmount(item.line_total, null, invoice.currency, invoice.exchange_rate)
const itemSek = resolveSekAmount(item.line_total, null, invoice.currency, effectiveRate)
expenseByAccount.set(item.account_number, current + itemSek)
}
// Debit: Expense accounts (in SEK)
for (const [accountNumber, amount] of expenseByAccount) {
lines.push({
const line: CreateJournalEntryLineInput = {
account_number: accountNumber,
debit_amount: Math.round(amount * 100) / 100,
credit_amount: 0,
line_description: desc,
})
}
lines.push(line)
expenseLines.push(line)
}
const isReverseCharge = (supplierType === 'eu_business' || supplierType === 'non_eu_business' || supplierType === 'swedish_business') && invoice.reverse_charge
@@ -327,8 +354,10 @@ export async function createSupplierInvoiceCashEntry(
// Per-rate bucketing: see registration entry above for the FK004 rationale.
// Drive iteration off the basis (line_total per rate) — fiktiv moms is
// always statutory base × rate; manual vat_amount overrides don't apply.
const baseByRate = groupBaseByRate(items, invoice.currency, invoice.exchange_rate)
const nonBasisBaseByRate = groupNonBasisBaseByRate(items, invoice.currency, invoice.exchange_rate)
// effectiveRate (payment-date rate under kontantmetoden) keeps the fiktiv
// moms base consistent with the expense lines above.
const baseByRate = groupBaseByRate(items, invoice.currency, effectiveRate)
const nonBasisBaseByRate = groupNonBasisBaseByRate(items, invoice.currency, effectiveRate)
const rcSupplierType = supplierType as 'eu_business' | 'non_eu_business' | 'swedish_business'
for (const [rate, baseAmount] of baseByRate) {
if (rate > 0 && baseAmount > 0) {
@@ -342,8 +371,9 @@ export async function createSupplierInvoiceCashEntry(
}
}
} else if (invoice.vat_amount > 0) {
// Domestic standard: Debit ingående moms per rate group
const vatByRate = groupVatByRate(items, invoice.currency, invoice.exchange_rate)
// Domestic standard: Debit ingående moms per rate group (at the payment-
// date rate when settling a foreign invoice — see effectiveRate above).
const vatByRate = groupVatByRate(items, invoice.currency, effectiveRate)
for (const [rate, amount] of vatByRate) {
if (amount > 0) {
lines.push({
@@ -356,6 +386,24 @@ export async function createSupplierInvoiceCashEntry(
}
}
// Öresavrundning: when translating a foreign invoice at the payment-date
// rate, per-line rounding can drift the implied bank total by an öre or two.
// Fold that residual into the largest expense line so the payment-account
// credit lands exactly on the SEK that left the bank (1930 reconciles to the
// bank transaction). Immaterial to the momsdeklaration — rutor are whole
// kronor. The |residual| ≤ 1 guard ensures we only absorb rounding noise,
// never a real shortfall (a partial settlement is blocked upstream).
if (useSettlementRate && expenseLines.length > 0) {
const debitSum = lines.reduce((sum, l) => sum + l.debit_amount, 0)
const creditSum = lines.reduce((sum, l) => sum + l.credit_amount, 0)
const provisionalCredit = roundOre(debitSum - creditSum)
const residual = roundOre(settledBankSek! - provisionalCredit)
if (residual !== 0 && Math.abs(residual) <= 1) {
const target = expenseLines.reduce((a, b) => (b.debit_amount >= a.debit_amount ? b : a))
target.debit_amount = roundOre(target.debit_amount + residual)
}
}
// Credit: payment account — balance guarantee: ensures sum(debits) === sum(credits)
// For reverse charge, intermediate credits (2614/2624/2634) already exist, so we subtract them
const totalDebits = lines.reduce((sum, l) => sum + l.debit_amount, 0)