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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user