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
@@ -42,7 +42,7 @@ registerEndpoint({
doNotUseFor:
'Categorizing a direct supplier expense without an invoice — use `:categorize`. Matching to a customer invoice — use `:match-invoice`. Bulk auto-match — `POST /reconciliation/bank/run`.',
pitfalls: [
'Cash-method companies cannot match across currencies (MATCH_SI_CASH_FX_UNSUPPORTED) — switch to accrual or book FX manually.',
'Cash-method companies can settle a foreign invoice in full (booked at the payment-date rate); only a PARTIAL cash-method payment across currencies is rejected (MATCH_SI_CASH_FX_UNSUPPORTED) — pay in full, switch to accrual, or book manually.',
'Transaction must be negative (amount < 0). Positive returns MATCH_SI_NOT_EXPENSE.',
'Supplier invoice must NOT be paid/credited already. paid/credited returns MATCH_SI_ALREADY_PAID; registered/approved/partially_paid/overdue are matchable.',
'Idempotency-Key is mandatory.',
@@ -182,19 +182,28 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
const txAmountAbs = Math.abs(transaction.amount)
const paymentAmountInvoiceCurrency =
transaction.currency === invoice.currency ? txAmountAbs : invoice.remaining_amount
const actualBankSek =
// SEK that actually left the bank, when known. A foreign transaction with
// no stored amount_sek is `null` here — the raw foreign amount must never
// stand in (treating 19 USD as 19 SEK books "19 kr" on a ~175 kr payment).
const bankSekStored =
transaction.currency === 'SEK'
? txAmountAbs
: transaction.amount_sek != null
? Math.abs(transaction.amount_sek)
: txAmountAbs
: null
const invoiceFxRate = invoice.exchange_rate ?? null
const originalBookedSek =
// SEK the invoice was booked at for this payment portion (null if the
// invoice is foreign and carries no exchange_rate).
const bookedSek =
invoice.currency === 'SEK'
? paymentAmountInvoiceCurrency
: invoiceFxRate && invoiceFxRate > 0
? Math.round(paymentAmountInvoiceCurrency * invoiceFxRate * 100) / 100
: actualBankSek
: null
// Prefer the stored bank SEK; fall back to the invoice's booked SEK (right
// magnitude, FX diff 0); last resort the raw amount.
const actualBankSek = bankSekStored ?? bookedSek ?? txAmountAbs
const originalBookedSek = bookedSek ?? actualBankSek
const exchangeRateDifference =
Math.round((originalBookedSek - actualBankSek) * 100) / 100
const paymentAmountSek =
@@ -215,7 +224,20 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
const siAlreadyBooked = !!(invoice as { registration_journal_entry_id?: string | null }).registration_journal_entry_id
const useCashEntry = !siAlreadyBooked && accountingMethod === 'cash'
if (useCashEntry && exchangeRateDifference !== 0) {
// Full settlement = the bank amount pays off the whole remaining balance.
// Cross-currency always settles the remaining (paymentAmountInvoiceCurrency
// is clamped to invoice.remaining_amount above).
const fullSettlement =
transaction.currency !== invoice.currency ||
txAmountAbs >= invoice.remaining_amount - 0.005
// Under kontantmetoden the expense is recognised AT PAYMENT (payment-date
// rate), so a full foreign-currency settlement has no kursdifferens — the
// builder translates the whole entry to the actual bank SEK (settledBankSek)
// below, leaving 1930 equal to the bank line. Only a PARTIAL cash-method
// payment across rates can't be modelled cleanly (the builder books the
// full invoice), so that narrow case stays blocked.
if (useCashEntry && exchangeRateDifference !== 0 && !fullSettlement) {
return v1ErrorResponseFromCode('MATCH_SI_CASH_FX_UNSUPPORTED', txLog, {
requestId: ctx.requestId,
details: {
@@ -268,6 +290,11 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
(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. No-op for SEK / same-rate settlements.
exchangeRateDifference !== 0 && fullSettlement ? actualBankSek : undefined,
)
if (je) journalEntryId = je.id
} else {