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
@@ -69,6 +69,7 @@ function enqueueHappyPath(opts: {
remaining_amount?: number
paid_amount?: number
}
accountingMethod?: string
}) {
// 1. transactions fetch
enqueue({
@@ -98,7 +99,7 @@ function enqueueHappyPath(opts: {
error: null,
})
// 3. company_settings fetch
enqueue({ data: { accounting_method: 'accrual' }, error: null })
enqueue({ data: { accounting_method: opts.accountingMethod ?? 'accrual' }, error: null })
// 4. supplier_invoices update (CAS)
enqueue({ data: [{ id: SI_UUID }], error: null })
// 5. supplier_invoice_payments insert
@@ -248,3 +249,69 @@ describe('POST /api/transactions/[id]/match-supplier-invoice — non-FX paths',
expect(res.status).toBe(200)
})
})
describe('POST /api/transactions/[id]/match-supplier-invoice — cash method + FX', () => {
it('full cross-currency settlement books at the payment rate (no FX-unsupported error)', async () => {
// Cash method, SEK account paying a 25 USD invoice. The invoice's stored
// rate (9.20 → 230 SEK) differs from the 239 SEK that actually left the
// bank — previously this was blocked. It must now succeed and hand the
// cash builder the real bank SEK so 1930 matches the bank line.
enqueueHappyPath({
transaction: { amount: -239, currency: 'SEK' },
invoice: { currency: 'USD', exchange_rate: 9.20, remaining_amount: 25 },
accountingMethod: 'cash',
})
const res = await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
expect(res.status).toBe(200)
expect(mockCreateCashEntry).toHaveBeenCalledTimes(1)
expect(mockCreatePaymentEntry).not.toHaveBeenCalled()
// settledBankSek is the 10th positional arg (index 9).
expect(mockCreateCashEntry.mock.calls[0][9]).toBe(239)
})
it('full same-currency foreign settlement passes the actual bank SEK to the cash builder', async () => {
// 19 USD invoice paid from a USD card showing amount_sek = 175.28, while
// the invoice was captured at 9.20 (174.80). Full settlement → booked at
// the payment rate (175.28), no kursdifferens.
enqueueHappyPath({
transaction: { amount: -19, currency: 'USD', amount_sek: -175.28 },
invoice: { currency: 'USD', exchange_rate: 9.20, remaining_amount: 19 },
accountingMethod: 'cash',
})
const res = await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
expect(res.status).toBe(200)
expect(mockCreateCashEntry.mock.calls[0][9]).toBe(175.28)
})
it('foreign tx with no amount_sek books at the invoice rate, not the raw foreign amount', async () => {
// The bank line carries no stored SEK (amount_sek null). The old fallback
// treated 19 USD as 19 SEK → "19 kr". We must instead use the invoice's
// rate (≈175 kr): no settledBankSek override is passed (FX diff is 0,
// there's no independent bank figure) and the entry is NOT blocked.
enqueueHappyPath({
transaction: { amount: -19, currency: 'USD', amount_sek: null },
invoice: { currency: 'USD', exchange_rate: 9.225, remaining_amount: 19 },
accountingMethod: 'cash',
})
const res = await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
expect(res.status).toBe(200)
expect(mockCreateCashEntry).toHaveBeenCalledTimes(1)
// No bogus settledBankSek=19 override — the builder uses the invoice rate.
expect(mockCreateCashEntry.mock.calls[0][9]).toBeUndefined()
})
it('PARTIAL foreign payment under the cash method is still rejected', async () => {
// Paying only 10 of 19 USD remaining. The cash builder books the whole
// invoice, so a partial bank amount cannot pin the entry — still blocked.
enqueueHappyPath({
transaction: { amount: -10, currency: 'USD', amount_sek: -92.25 },
invoice: { currency: 'USD', exchange_rate: 9.20, remaining_amount: 19 },
accountingMethod: 'cash',
})
const res = await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(res)
expect(status).toBe(400)
expect(body.error.code).toBe('MATCH_SI_CASH_FX_UNSUPPORTED')
expect(mockCreateCashEntry).not.toHaveBeenCalled()
})
})
@@ -45,7 +45,10 @@ export const GET = withRouteContext(
const { data: transaction, error: txErr } = await supabase
.from('transactions')
.select('id, date, amount, currency')
// amount_sek is needed for the cash-method preview: a foreign-currency
// settlement is translated at the payment-date rate (the SEK that left
// the bank), mirroring the committed verifikat from the POST handler.
.select('id, date, amount, currency, amount_sek')
.eq('id', transactionId)
.eq('company_id', companyId)
.single()
@@ -84,6 +87,23 @@ export const GET = withRouteContext(
const si = invoice as SupplierInvoice & { items?: SupplierInvoiceItem[] }
const items = si.items ?? []
// Kontantmetoden books the expense AT PAYMENT at the payment-date rate
// (the SEK that actually left the bank), so translate this preview the
// same way the committed verifikat does. The bank SEK is only known when
// the transaction is in SEK or carries a stored amount_sek; for a foreign
// transaction without it we fall back to the invoice's own rate (the raw
// foreign amount must never be used — that would render 19 USD as 19 kr).
const bankSek =
transaction.currency === 'SEK'
? Math.abs(transaction.amount)
: transaction.amount_sek != null
? Math.abs(transaction.amount_sek)
: null
const cashRate =
bankSek != null && si.currency !== 'SEK' && si.total > 0
? bankSek / si.total
: si.exchange_rate
// Mirror createSupplierInvoiceCashEntry: per-item expense debit + VAT
// debit + bank credit. We only need a faithful preview, not exact
// account-mapping fidelity — show one aggregate expense line per item
@@ -92,8 +112,8 @@ export const GET = withRouteContext(
let totalVatSek = 0
if (items.length > 0) {
for (const it of items) {
const lineTotal = resolveSekAmount(it.line_total, null, si.currency, si.exchange_rate)
const vat = resolveSekAmount(it.vat_amount, null, si.currency, si.exchange_rate)
const lineTotal = resolveSekAmount(it.line_total, null, si.currency, cashRate)
const vat = resolveSekAmount(it.vat_amount, null, si.currency, cashRate)
const expenseAcct = (it as { expense_account?: string | null }).expense_account ?? '4000'
lines.push({
account_number: expenseAcct,
@@ -105,8 +125,11 @@ export const GET = withRouteContext(
totalVatSek += vat
}
} else {
const subSek = resolveSekAmount(si.subtotal, si.subtotal_sek, si.currency, si.exchange_rate)
const vatSek = resolveSekAmount(si.vat_amount, si.vat_amount_sek, si.currency, si.exchange_rate)
// Pass null for the pre-computed SEK so cashRate (payment-date rate)
// drives the translation — resolveSekAmount would otherwise prefer the
// invoice-rate *_sek columns and ignore the rate.
const subSek = resolveSekAmount(si.subtotal, null, si.currency, cashRate)
const vatSek = resolveSekAmount(si.vat_amount, null, si.currency, cashRate)
lines.push({
account_number: '4000',
debit_amount: Math.round(subSek * 100) / 100,
@@ -116,32 +116,38 @@ export const POST = withRouteContext(
? txAmountAbs
: invoice.remaining_amount
// Actual SEK leaving the bank — what really moved out of 1930. For a
// SEK transaction this is just the absolute amount; for a foreign-
// currency transaction we use the SEK conversion stored at import.
const actualBankSek =
// SEK that actually left the bank, when we know it. SEK transaction → the
// absolute amount; foreign transaction with a stored amount_sek → that
// value; foreign transaction WITHOUT amount_sek → unknown (null). The raw
// foreign amount must never stand in here — treating 19 USD as 19 SEK is
// exactly the bug that books "19 kr" on a ~175 kr payment.
const bankSekStored =
transaction.currency === 'SEK'
? txAmountAbs
: (transaction.amount_sek != null
? Math.abs(transaction.amount_sek)
: txAmountAbs)
: transaction.amount_sek != null
? Math.abs(transaction.amount_sek)
: null
// SEK value that's actually sitting on 2440 for this payment portion:
// SEK the invoice was booked at for this payment portion:
// - SEK invoice: face value = paymentAmountInvoiceCurrency
// - Non-SEK invoice w/ exchange_rate: portion × rate
// - Non-SEK invoice w/o exchange_rate: can't compute precisely; fall
// back to actualBankSek (no FX diff, plain SEK booking)
// FX diff hits 7960/3960 so 2440 clears cleanly instead of leaving a
// residual. Triggered whenever bank-paid SEK differs from booked SEK —
// happens for any currency mismatch (SEK→EUR, EUR→SEK, EUR→USD), not
// just non-SEK invoices.
// - Non-SEK invoice w/o exchange_rate: can't compute (null)
const invoiceFxRate = invoice.exchange_rate ?? null
const originalBookedSek =
const bookedSek =
invoice.currency === 'SEK'
? paymentAmountInvoiceCurrency
: invoiceFxRate && invoiceFxRate > 0
? Math.round(paymentAmountInvoiceCurrency * invoiceFxRate * 100) / 100
: actualBankSek
: null
// Actual SEK leaving the bank. Prefer the stored bank figure; if a foreign
// transaction has no amount_sek, fall back to the invoice's booked SEK so
// the magnitude is right (→ exchangeRateDifference 0, i.e. "no independent
// bank figure to reconcile against"). Last resort, with no invoice rate
// either, is the raw amount. The FX diff hits 7960/3960 so 2440 clears
// cleanly whenever bank-paid SEK genuinely differs from booked SEK.
const actualBankSek = bankSekStored ?? bookedSek ?? txAmountAbs
const originalBookedSek = bookedSek ?? actualBankSek
// Positive = gain (AP credited at more SEK than the bank actually paid).
// Negative = loss (bank paid more SEK than the AP we owed).
@@ -171,15 +177,24 @@ export const POST = withRouteContext(
const siAlreadyBooked = !!(invoice as { registration_journal_entry_id?: string | null }).registration_journal_entry_id
const useCashEntry = !siAlreadyBooked && accountingMethod === 'cash'
// A full settlement pays off the whole remaining balance. Cross-currency
// matches always do (paymentAmountInvoiceCurrency is clamped to
// invoice.remaining_amount above); same-currency does when the bank amount
// covers the remaining balance.
const fullSettlement =
transaction.currency !== invoice.currency ||
txAmountAbs >= invoice.remaining_amount - 0.005
// Cash method (kontantmetoden) collapses registration + payment into a
// single entry that credits 1930 at sum(expenses_SEK). It has no
// exchange_rate_difference path — if the actual bank SEK differs from
// the invoice's booked SEK, the 1930 credit won't match the bank
// transaction and we'd silently leave a reconciliation gap. Block the
// combination and ask the user to switch to accrual or do a manual JE.
// Only applies to true cash-method invoices — accrual-booked invoices
// never hit the cash branch.
if (useCashEntry && exchangeRateDifference !== 0) {
// single entry. Under the cash method the expense is recognised AT PAYMENT
// at the payment-date rate, so there is no kursvinst/kursförlust — we hand
// the builder the actual bank SEK (settledBankSek) and it translates the
// whole verifikat to that, leaving 1930 equal to the bank transaction.
// The only combination we still can't model is a PARTIAL cash-method
// payment across rates: the cash builder books the full invoice, so a
// partial bank amount can't pin the entry cleanly. That narrow case stays
// blocked (switch to accrual or book manually).
if (useCashEntry && exchangeRateDifference !== 0 && !fullSettlement) {
return errorResponseFromCode('MATCH_SI_CASH_FX_UNSUPPORTED', txLog, {
requestId,
details: {
@@ -229,6 +244,12 @@ export const POST = withRouteContext(
(invoice.items || []) as SupplierInvoiceItem[],
transaction.date,
invoice.supplier?.supplier_type || 'swedish_business',
undefined, // supplierName (unchanged default)
undefined, // paymentAccount (unchanged default 1930)
// Pin a foreign-currency settlement to the payment-date rate so 1930
// equals the bank movement (kontantmetoden books the expense at
// payment). No-op for SEK invoices and same-rate settlements.
exchangeRateDifference !== 0 && fullSettlement ? actualBankSek : undefined,
)
if (journalEntry) journalEntryId = journalEntry.id
} else {