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:
@@ -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
|
||||
// ============================================================
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user