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