Bug/customer invoice bug (#628)

* fix(supplier-invoices): self-assess reverse-charge VAT + link payments to vouchers

Reverse-charge supplier invoices now carry a per-item reverse_charge_rate (0.06/0.12/0.25). Under omvänd skattskyldighet the supplier charges 0% VAT, so the line vat_rate stays 0 and the buyer self-assesses fiktiv moms at the statutory rate. Centralizes rate resolution (resolveReverseChargeRate) and the ruta 20-24 basis-account guard (isReverseChargeBasisAccount) in vat-entries so the booking engine and review-dialog preview can no longer drift.

Adds the link_supplier_invoice_voucher pending operation: mark a leverantorsfaktura paid by linking an existing posted verifikat that debits 2440, with no new journal entry. Exposes find-candidates/link MCP tools and the bulk-reconcile helper, scoped under suppliers:read/write.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(vat): report yearly VAT over the rakenskapsar, not the calendar year

Annual VAT (helarsmoms) is filed per beskattningsar/rakenskapsar (SFL 26 kap), which can be extended or shortened up to 18 months. The previous Jan-Dec calendar span silently dropped part of an extended first year. calculateVatDeclaration now accepts a fiscalPeriodId and resolves the period's actual bounds for yearly; monthly/quarterly stay calendar. The reports UI passes the selected fiscal period, defaults the periodicity from the company's moms_period setting, and carries the period into the ruta drill-down. full-archive export threads the period id through too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(migration): resolve supplier invoice status from payment amounts

The provider's lifecycle status and its payment status are computed independently upstream and can contradict each other (e.g. a Fortnox invoice marked booked but fully paid). Both the arcim entity-mapper and the Fortnox mapper now let payment state win: fully paid -> paid, partial -> partially_paid, otherwise the mapped lifecycle status, with credit notes forced terminal. Balance is compared numerically (never strict === 0) so float drift or a residual ore resolves cleanly, and an absent Balance is treated as unpaid.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(enable-banking): only ingest booked transactions to stop re-import drift

Pending entries are skipped during sync: a pending row is unstable across syncs (a later 'synka nu' returns it still pending or finally booked, often with a different effective date). Because both the dedup external_id and the content-dedup key are date-derived, that drift minted a new id and re-imported a transaction that already existed - observed in production as the same amount+description landing twice with different dates. Gating the import set on a stable booking_date removes the drift at the source and leaves booked rows' ids byte-identical.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(gitignore): ignore local SIE test fixtures

tests/fixtures/sie/ may contain real or scrubbed company data and must never be committed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(invoice): handle errors during registration journal entry creation and ensure invoice rollback
feat(tests): add test for reverse charge rate handling on supplier invoice line items
feat(fortnox): ensure paid status reflects zero balance for fully paid invoices
chore(migrations): add reverse_charge_rate to supplier_invoice_items and backfill link_supplier_invoice_voucher

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-06-02 13:25:48 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 358c25094d
commit f6ee0c2a82
43 changed files with 2226 additions and 182 deletions
@@ -15,6 +15,7 @@ import {
} from './lib/provider-client'
import { mapCompanyInfo } from './lib/entity-mapper'
import { executeMigration } from './lib/migration-orchestrator'
import { reconcileSupplierInvoiceVouchers } from '@/lib/invoices/bulk-reconcile-supplier-vouchers'
import type { ArcimProvider } from './types'
import { ARCIM_PROVIDERS } from './types'
import { parseSIEFile, validateSIEFile } from '@/lib/import/sie-parser'
@@ -1001,6 +1002,7 @@ export const arcimMigrationExtension: Extension = {
importSuppliers = true,
importSalesInvoices = true,
importSupplierInvoices = true,
reconcileVouchers = true,
} = await request.json() as {
consentId: string
importCompanyInfo?: boolean
@@ -1008,6 +1010,7 @@ export const arcimMigrationExtension: Extension = {
importSuppliers?: boolean
importSalesInvoices?: boolean
importSupplierInvoices?: boolean
reconcileVouchers?: boolean
}
if (!consentId) {
@@ -1034,6 +1037,7 @@ export const arcimMigrationExtension: Extension = {
importSuppliers,
importSalesInvoices,
importSupplierInvoices,
reconcileVouchers,
})
log.info('Migration completed:', results)
@@ -1055,6 +1059,60 @@ export const arcimMigrationExtension: Extension = {
},
},
// ── Reconcile supplier invoices to GL payment vouchers ────────
// Re-runnable maintenance endpoint. The migration runs this automatically as
// its final step, but SIE (the GL) and entity import are two separate HTTP
// requests whose order is UI-driven — so if the GL lands after the entity
// import, or a company was migrated before this feature existed, call this to
// auto-link settled supplier invoices to their existing vouchers. Pass
// { dryRun: true } to preview the plan (incl. items needing manual review)
// without writing.
{
method: 'POST',
path: '/reconcile',
handler: async (request: Request, ctx?: ExtensionContext) => {
const log = ctx?.log ?? console
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = ctx?.companyId ?? user.id
let dryRun = false
try {
const body = (await request.json()) as { dryRun?: boolean }
dryRun = body?.dryRun === true
} catch {
// empty body is fine — default to a real run
}
try {
const result = await reconcileSupplierInvoiceVouchers({
supabase,
companyId,
userId: user.id,
dryRun,
})
log.info('arcim reconcile completed', {
companyId,
dryRun,
autoLinked: result.autoLinked,
ambiguous: result.ambiguous,
unmatched: result.unmatched,
})
return NextResponse.json({ success: true, dryRun, result })
} catch (error) {
log.error('arcim reconcile failed', error as Error)
return errorResponseFromCode('PROVIDER_MIGRATE_FAILED', moduleLog, {
details: { reason: error instanceof Error ? error.message : 'unknown' },
})
}
},
},
// ── Accept consent (mark as fully connected after import) ─────
{
method: 'POST',
@@ -0,0 +1,127 @@
import { describe, it, expect } from 'vitest'
import { mapSupplierInvoice } from '../entity-mapper'
import type { SupplierInvoiceDto, InvoiceStatusCode, PartyDto } from '@/lib/providers/dto'
/**
* Guards the status/paid consistency hardening in mapSupplierInvoice: the
* provider's lifecycle status (dto.status) and its payment status are computed
* independently upstream and can contradict each other. The mapper must emit a
* `status` that always agrees with paid_amount / remaining_amount, and treat
* Balance numerically (drift-safe), without ever flipping a credit note.
*/
const party: PartyDto = { name: 'Leverantör AB', identifications: [] }
function makeDto(over: {
status?: InvoiceStatusCode
paid?: boolean
balance?: number
total?: number
invoiceTypeCode?: string
lastPaymentDate?: string
}): SupplierInvoiceDto {
const total = over.total ?? 1000
return {
id: 'inv-1',
invoiceNumber: 'F-100',
issueDate: '2026-01-10',
dueDate: '2026-02-10',
invoiceTypeCode: over.invoiceTypeCode,
currencyCode: 'SEK',
status: over.status ?? 'booked',
supplier: party,
buyer: party,
lines: [
{
id: '1',
description: 'Tjänst',
lineExtensionAmount: { value: total, currencyCode: 'SEK' },
taxPercent: 25,
},
],
legalMonetaryTotal: {
lineExtensionAmount: { value: total, currencyCode: 'SEK' },
payableAmount: { value: total, currencyCode: 'SEK' },
},
paymentStatus: {
paid: over.paid ?? false,
balance: { value: over.balance ?? total, currencyCode: 'SEK' },
lastPaymentDate: over.lastPaymentDate,
},
}
}
function map(over: Parameters<typeof makeDto>[0]) {
return mapSupplierInvoice(makeDto(over), 'user-1', 'company-1', 'supplier-1').invoice
}
describe('mapSupplierInvoice — status/paid consistency', () => {
it('unpaid booked invoice → registered with full remaining', () => {
const inv = map({ status: 'booked', paid: false, balance: 1000, total: 1000 })
expect(inv.status).toBe('registered')
expect(inv.paid_amount).toBe(0)
expect(inv.remaining_amount).toBe(1000)
expect(inv.paid_at).toBeNull()
})
it('booked-but-paid invoice → flips to paid (status follows payment)', () => {
// The bug: dto.status='booked' (→registered) while paymentStatus.paid=true.
const inv = map({ status: 'booked', paid: true, balance: 0, total: 1000, lastPaymentDate: '2026-02-05' })
expect(inv.status).toBe('paid')
expect(inv.paid_amount).toBe(1000)
expect(inv.remaining_amount).toBe(0)
expect(inv.paid_at).toBe('2026-02-05')
})
it('near-zero residual balance (0.004) resolves to paid, not unpaid', () => {
const inv = map({ status: 'booked', paid: false, balance: 0.004, total: 1000 })
expect(inv.status).toBe('paid')
expect(inv.remaining_amount).toBe(0)
expect(inv.paid_amount).toBe(1000)
})
it('partially-paid invoice (0 < paid < total) → partially_paid', () => {
const inv = map({ status: 'booked', paid: false, balance: 300, total: 1000 })
expect(inv.status).toBe('partially_paid')
expect(inv.paid_amount).toBe(700)
expect(inv.remaining_amount).toBe(300)
expect(inv.paid_at).not.toBeNull()
})
it('credit note with zero balance stays credited — never flipped to paid', () => {
const inv = map({ status: 'credited', paid: true, balance: 0, total: 1000, invoiceTypeCode: '381' })
expect(inv.status).toBe('credited')
expect(inv.is_credit_note).toBe(true)
})
it('credit note is forced to credited even if the provider sends a non-terminal status', () => {
// invoiceTypeCode='381' but a contradictory lifecycle status (the arcim
// gateway does not guarantee status='credited' alongside the type code).
for (const status of ['booked', 'paid', 'sent', 'draft'] as InvoiceStatusCode[]) {
const inv = map({ status, paid: true, balance: 0, total: 1000, invoiceTypeCode: '381' })
expect(inv.status, `status=${status}`).toBe('credited')
expect(inv.is_credit_note).toBe(true)
expect(inv.paid_at).toBeNull()
}
})
it('overdue lifecycle status is preserved when nothing is paid', () => {
const inv = map({ status: 'overdue', paid: false, balance: 1000, total: 1000 })
expect(inv.status).toBe('overdue')
expect(inv.remaining_amount).toBe(1000)
})
it('never emits a status outside the supplier_invoices CHECK allow-list', () => {
const allowed = new Set([
'registered', 'approved', 'paid', 'partially_paid', 'overdue', 'disputed', 'credited', 'reversed',
])
for (const status of ['draft', 'sent', 'booked', 'paid', 'overdue', 'cancelled', 'credited'] as InvoiceStatusCode[]) {
for (const paid of [true, false]) {
for (const balance of [0, 250, 1000]) {
const inv = map({ status, paid, balance, total: 1000 })
expect(allowed.has(inv.status as string)).toBe(true)
}
}
}
})
})
@@ -357,6 +357,38 @@ export function mapSupplierInvoice(
const isCreditNote = dto.invoiceTypeCode === '381'
// Payment-derived amounts. Treat Balance numerically (never strict === 0) so
// floating drift or a residual öre resolves cleanly to paid/unpaid.
const balance = round2(dto.paymentStatus.balance.value)
const paidAmount = dto.paymentStatus.paid ? total : round2(total - balance)
// Status MUST stay consistent with the payment amounts. The provider's
// lifecycle status (dto.status) and its payment status are computed
// independently upstream and can contradict each other (e.g. a Fortnox
// invoice that is "booked" but fully paid). Payment state wins:
// fully paid -> 'paid'
// 0 < paid < total -> 'partially_paid'
// otherwise -> the mapped lifecycle status
const mappedStatus = statusMap[dto.status] || 'registered'
let resolvedStatus: string
if (isCreditNote) {
// A kreditfaktura is never an open or "paid" payable. Force a credit-note
// terminal status regardless of the provider's lifecycle status — the
// arcim gateway is the only source of invoiceTypeCode and is NOT guaranteed
// to also send status='credited', so trusting dto.status here could persist
// a credit note as 'registered'/'paid' (contradicting its amounts).
resolvedStatus = mappedStatus === 'reversed' ? 'reversed' : 'credited'
} else if (mappedStatus === 'credited' || mappedStatus === 'reversed') {
// Terminal states from the provider: never flipped by payment.
resolvedStatus = mappedStatus
} else if (dto.paymentStatus.paid || balance <= 0) {
resolvedStatus = 'paid'
} else if (paidAmount > 0 && paidAmount < total) {
resolvedStatus = 'partially_paid'
} else {
resolvedStatus = mappedStatus
}
const invoice: Record<string, unknown> = {
user_id: userId,
company_id: companyId,
@@ -366,7 +398,7 @@ export function mapSupplierInvoice(
due_date: dto.dueDate || dto.issueDate,
received_date: dto.issueDate,
delivery_date: dto.deliveryDate || null,
status: statusMap[dto.status] || 'registered',
status: resolvedStatus,
currency: dto.currencyCode || 'SEK',
exchange_rate: dto.currencyCode === 'SEK' ? null : null,
subtotal,
@@ -378,9 +410,11 @@ export function mapSupplierInvoice(
vat_treatment: vatTreatment,
reverse_charge: vatTreatment === 'reverse_charge',
payment_reference: dto.ocrNumber || null,
paid_at: dto.paymentStatus.paid ? dto.paymentStatus.lastPaymentDate || dto.issueDate : null,
paid_amount: dto.paymentStatus.paid ? total : round2(total - dto.paymentStatus.balance.value),
remaining_amount: round2(dto.paymentStatus.balance.value),
paid_at: resolvedStatus === 'paid' || resolvedStatus === 'partially_paid'
? dto.paymentStatus.lastPaymentDate || dto.issueDate
: null,
paid_amount: resolvedStatus === 'paid' ? total : Math.max(0, paidAmount),
remaining_amount: resolvedStatus === 'paid' ? 0 : Math.max(0, balance),
is_credit_note: isCreditNote,
notes: dto.note || null,
}
@@ -30,6 +30,7 @@ import {
fetchSupplierInvoicesDirect,
} from '@/lib/providers/provider-data-fetcher'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { reconcileSupplierInvoiceVouchers } from '@/lib/invoices/bulk-reconcile-supplier-vouchers'
import {
mapCustomer,
mapSupplier,
@@ -49,6 +50,8 @@ export interface MigrationOptions {
importSuppliers?: boolean
importSalesInvoices?: boolean
importSupplierInvoices?: boolean
/** Auto-link imported supplier invoices to GL payment vouchers. Default true. */
reconcileVouchers?: boolean
onProgress?: (progress: MigrationProgress) => void
}
@@ -666,6 +669,30 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
}
}
// ── Step 6: Reconcile supplier invoices to GL payment vouchers ────
// The GL (incl. the Dr 2440 / Cr 1930 bank-payment vouchers) is imported
// separately via SIE. Supplier invoices arrive (via ?filter=unpaid) as open
// payables with no link to those vouchers, so settled invoices would surface
// as overdue. Auto-link the unambiguous matches. Best-effort: a failure here
// must never fail the migration — the imported data is already persisted.
if (options.reconcileVouchers !== false) {
emitProgress(options, { status: 'importing', currentStep: 'Stämmer av betalningar mot verifikationer...', progress: 95 })
try {
const recon = await reconcileSupplierInvoiceVouchers({ supabase, companyId, userId })
results.reconciliation = {
scanned: recon.scanned,
autoLinked: recon.autoLinked,
ambiguous: recon.ambiguous,
unmatched: recon.unmatched,
}
console.log(
`[migration] Reconcile: ${recon.autoLinked} auto-linked, ${recon.ambiguous} need review, ${recon.unmatched} unmatched (${recon.scanned} scanned)`,
)
} catch (err) {
console.error('Failed to reconcile supplier invoice payments:', err)
}
}
emitProgress(options, { status: 'completed', progress: 100, results })
return results
} catch (error) {
@@ -65,6 +65,13 @@ export interface MigrationResults {
suppliers?: { total: number; imported: number; skipped: number; skipReasons?: SkipReasons }
salesInvoices?: { total: number; imported: number; skipped: number; skipReasons?: SkipReasons }
supplierInvoices?: { total: number; imported: number; skipped: number; skipReasons?: SkipReasons }
/**
* Auto-reconciliation of imported supplier invoices to the GL payment
* vouchers that the separate SIE import already posted. `autoLinked` invoices
* are now marked paid; `ambiguous` need manual review; `unmatched` had no
* candidate voucher.
*/
reconciliation?: { scanned: number; autoLinked: number; ambiguous: number; unmatched: number }
}
// ── Consent flow ────────────────────────────────────────────────────
@@ -184,7 +184,7 @@ describe('syncAccountTransactions', () => {
it('passes raw transactions to ingest function', async () => {
mockGetAllTransactionsWithRaw.mockResolvedValue({
transactions: [{ transaction_amount: { amount: '500', currency: 'SEK' } }],
transactions: [{ transaction_amount: { amount: '500', currency: 'SEK' }, booking_date: '2024-06-15' }],
rawPages: ['{}'],
})
@@ -222,6 +222,83 @@ describe('syncAccountTransactions', () => {
expect(rawTxns[0].import_source).toBe('enable_banking')
})
it('skips pending entries (no booking_date) and ingests only booked ones', async () => {
// A pending PSD2 entry has no booking_date — only a value_date. Importing it
// is what produced the production duplicates: its date (hence its dedup id)
// differs from the same transaction's booked representation. Pending entries
// must be skipped; booked ones import as usual, keyed on booking_date.
mockConvertTransaction.mockImplementation((tx: { transaction_amount: { amount: string }, booking_date?: string, value_date?: string }) => ({
id: 'x',
date: tx.booking_date || tx.value_date,
booking_date: tx.booking_date || tx.value_date,
amount: -parseFloat(tx.transaction_amount.amount),
currency: 'SEK',
description: 'T',
}))
mockUploadDocument.mockResolvedValue({ id: 'doc-1' })
mockGetAllTransactionsWithRaw.mockResolvedValue({
transactions: [
{ transaction_amount: { amount: '50', currency: 'SEK' }, value_date: '2026-02-15' }, // pending → skipped
{ transaction_amount: { amount: '75', currency: 'SEK' }, booking_date: '2026-03-01' }, // booked → kept
],
rawPages: ['{}'],
})
await syncAccountTransactions(
{} as never, COMPANY_ID, USER_ID, CONNECTION_ID, makeAccount(),
'2026-01-01', '2026-06-01', mockIngest
)
const batch = mockIngest.mock.calls[0][3]
expect(batch).toHaveLength(1)
expect(batch[0].date).toBe('2026-03-01')
expect(batch[0].external_id).toBe('eb_acc-uid-1_2026-03-01_-7500_0')
})
it('does not re-import a booked transaction when a later sync returns it pending (no booking_date)', async () => {
// Regression for the 45→90 duplication. The same transaction is returned
// booked in sync 1 (booking_date 2026-04-21) and pending in sync 2 (only
// value_date 2026-02-15). Previously sync 2 ingested the pending copy with a
// value_date-derived id (different date → new id → duplicate). It must now
// be skipped, so sync 2 ingests nothing for it.
mockConvertTransaction.mockImplementation((tx: { transaction_amount: { amount: string }, booking_date?: string, value_date?: string }) => ({
id: 'x',
date: tx.booking_date || tx.value_date,
booking_date: tx.booking_date || tx.value_date,
amount: -parseFloat(tx.transaction_amount.amount),
currency: 'SEK',
description: 'AVI ÖVERDRAG',
}))
mockUploadDocument.mockResolvedValue({ id: 'doc-1' })
// Sync 1 — booked
mockGetAllTransactionsWithRaw.mockResolvedValueOnce({
transactions: [{ transaction_amount: { amount: '100', currency: 'SEK' }, booking_date: '2026-04-21', value_date: '2026-02-15' }],
rawPages: ['{}'],
})
await syncAccountTransactions(
{} as never, COMPANY_ID, USER_ID, CONNECTION_ID, makeAccount(),
'2026-01-01', '2026-06-01', mockIngest
)
const firstBatch = mockIngest.mock.calls[0][3]
expect(firstBatch).toHaveLength(1)
expect(firstBatch[0].date).toBe('2026-04-21')
expect(firstBatch[0].external_id).toBe('eb_acc-uid-1_2026-04-21_-10000_0')
// Sync 2 — same transaction now returned pending (booking_date dropped)
mockGetAllTransactionsWithRaw.mockResolvedValueOnce({
transactions: [{ transaction_amount: { amount: '100', currency: 'SEK' }, value_date: '2026-02-15' }],
rawPages: ['{}'],
})
await syncAccountTransactions(
{} as never, COMPANY_ID, USER_ID, CONNECTION_ID, makeAccount(),
'2026-01-01', '2026-06-01', mockIngest
)
const secondBatch = mockIngest.mock.calls[1][3]
expect(secondBatch).toHaveLength(0)
})
it('gives identical same-day same-amount transactions distinct, stable external_ids', async () => {
// Two genuinely distinct transactions that share date + amount must both be
// kept (distinct ids), and re-running the sync must reproduce the SAME set
+49 -13
View File
@@ -100,32 +100,68 @@ export async function syncAccountTransactions(
const bankTransactions = transactions.map(tx => convertTransaction(tx, account.currency))
// Derive a stable, content-based external_id per transaction. We deliberately
// do NOT key off the bank's transaction id (entry_reference/transaction_id):
// many Swedish ASPSPs regenerate those across requests, so a repeat "synka nu"
// produced a fresh id and re-imported transactions the user had already
// booked. buildStableExternalIds derives the id from (account, date, amount)
// plus an occurrence index, so re-syncs collide on (company_id, external_id)
// and dedupe while genuinely identical transactions are still kept apart.
// Normalize the IBAN (strip whitespace, uppercase) so formatting variants
// from the ASPSP ("SE45 5000 …" vs "SE455000…") don't change the scope and
// orphan every prior external_id. Falls back to the provider account uid.
// Only ingest BOOKED transactions — those the ASPSP returned with a real
// booking_date. Pending entries are intentionally skipped: a pending row is
// unstable across syncs (a later "synka nu" returns the same transaction
// either still pending or finally booked, often with a *different* effective
// date). Because BOTH the dedup external_id and the content-dedup key are
// date-derived, that drift mints a brand-new id and re-imports a transaction
// that already exists. Observed in production as the same amount+description
// landing twice with different dates — the bank's value_date in one sync, its
// booking_date in another. Gating the import set on a stable booking_date
// removes the drift at the source, and leaves booked rows' ids byte-identical
// (so the existing rows are NOT re-orphaned).
//
// booking_date is read from the RAW transaction (transactions[i]), index-
// aligned with bankTransactions: convertTransaction's booking_date already
// falls back to value_date/today, so it cannot tell booked from pending.
const bookedEntries = bankTransactions.flatMap((tx, i) => {
const bookingDate = transactions[i]?.booking_date
return typeof bookingDate === 'string' && bookingDate.trim() !== ''
? [{ tx, bookingDate: bookingDate.trim() }]
: []
})
const skippedPending = bankTransactions.length - bookedEntries.length
if (skippedPending > 0) {
console.log('[enable-banking] Skipped pending transactions (no booking_date)', {
connectionId,
accountUid: account.uid,
skippedPending,
total: bankTransactions.length,
})
}
// Derive a stable, content-based external_id per booked transaction. We
// deliberately do NOT key off the bank's transaction id (entry_reference/
// transaction_id): many Swedish ASPSPs regenerate those across requests, so a
// repeat "synka nu" produced a fresh id and re-imported transactions the user
// had already booked. buildStableExternalIds derives the id from (account,
// booking_date, amount) plus an occurrence index, so re-syncs collide on
// (company_id, external_id) and dedupe while genuinely identical transactions
// are still kept apart. Normalize the IBAN (strip whitespace, uppercase) so
// formatting variants from the ASPSP ("SE45 5000 …" vs "SE455000…") don't
// change the scope and orphan every prior external_id. Falls back to the
// provider account uid.
const accountScope = account.iban?.replace(/\s+/g, '').toUpperCase() || account.uid
const externalIds = buildStableExternalIds(
'eb',
accountScope,
bankTransactions.map((tx) => ({ date: tx.booking_date || tx.date, amount: tx.amount }))
bookedEntries.map(({ tx, bookingDate }) => ({ date: bookingDate, amount: tx.amount }))
)
// Convert Enable Banking format to generic RawTransaction. counterparty
// identification: prefer IBAN (international, normalized) over BBAN/BG
// numbers — the own-account detector matches on IBAN first, falling back
// to counterparty_account for Swedish domestic transfers.
const rawTransactions: RawTransaction[] = bankTransactions.map((tx, i) => {
const rawTransactions: RawTransaction[] = bookedEntries.map(({ tx, bookingDate }, i) => {
const cpAccount = tx.counterparty_account ?? null
const looksLikeIban = cpAccount && /^[A-Z]{2}\d/.test(cpAccount.replace(/\s+/g, ''))
return {
date: tx.booking_date || tx.date,
// The booked date is both the stable dedup anchor (see bookedEntries) and
// the accounting-correct ledger date; keep it identical to the value the
// external_id was derived from.
date: bookingDate,
// tx.description is already non-empty (convertTransaction guarantees a
// label); the trailing fallbacks are defensive. Ingest re-normalizes.
description: tx.description || tx.counterparty_name || FALLBACK_DESCRIPTION,
+21 -1
View File
@@ -1672,6 +1672,9 @@ export const invoiceInboxExtension: Extension = {
vat_code: bodyItem.vat_code || null,
vat_rate: vatRate,
vat_amount: vatAmount,
// Self-assessed RC rate (0.06/0.12/0.25) or null — engine defaults
// to 25% huvudregeln when null for a reverse-charge invoice.
reverse_charge_rate: body.reverse_charge ? (bodyItem.reverse_charge_rate ?? null) : null,
}
})
@@ -1782,7 +1785,24 @@ export const invoiceInboxExtension: Extension = {
})
}
} catch (err) {
console.error('[invoice-inbox/convert] Failed to create registration journal entry:', err)
// Engine threw (period lock, unbalanced entry, etc.) instead of
// cleanly returning null. Roll back the supplier invoice so the inbox
// item is never marked converted against an unbooked invoice (an orphan
// understating 2440/2641), then surface the error — mirroring the main
// /api/supplier-invoices route's registration catch.
await ctx.supabase
.from('supplier_invoices')
.delete()
.eq('id', invoice.id)
.eq('company_id', ctx.companyId)
const typed = bookkeepingErrorResponse(err)
if (typed) return typed
return errorResponseFromCode('SI_CREATE_FAILED', ctx.log, {
details: {
reason: err instanceof Error ? err.message : 'unknown',
step: 'registration_journal_entry',
},
})
}
}
@@ -31,9 +31,16 @@ describe('tools/list payload size guard', () => {
// closes the MCP parity gap with the existing REST endpoint so agents
// can attach a bank tx to an already-posted verifikat without creating
// duplicate bookkeeping. Description trimmed to ~180 chars.
// * 31.5K → 32K when gnubok_find_voucher_candidates_for_supplier_invoice +
// gnubok_link_supplier_invoice_to_voucher landed — the supplier-side
// mirror of the customer find/link voucher tools. The link tool inlines
// the shared STAGED_OPERATION_SCHEMA. Lets agents mark a leverantörs-
// faktura paid against an already-posted verifikat (no new bokföring),
// which is exactly the fix for invoices imported from Fortnox as open
// payables while their payment already exists in the SIE-imported GL.
// Long-term answer to growth is leaning harder on gnubok_search_tools — if this
// fires again, prefer trimming descriptions or making a tool opt-in via search
// before bumping further.
expect(approxTokens).toBeLessThan(31_500)
expect(approxTokens).toBeLessThan(32_000)
})
})
+155
View File
@@ -52,6 +52,10 @@ import {
findMatchingVouchersForInvoice,
validateVoucherForInvoiceLink,
} from '@/lib/invoices/voucher-matching'
import {
findMatchingVouchersForSupplierInvoice,
validateVoucherForSupplierInvoiceLink,
} from '@/lib/invoices/supplier-voucher-matching'
import { findFiscalPeriod, reverseEntry, validateBalance } from '@/lib/bookkeeping/engine'
import { closePeriod, lockPeriod, resolvePeriodStatusForDate, type PeriodStatusForDate } from '@/lib/core/bookkeeping/period-service'
import { validateYearEndReadiness, previewYearEndClosing } from '@/lib/core/bookkeeping/year-end-service'
@@ -5087,6 +5091,157 @@ export const tools: McpTool[] = [
},
},
{
name: 'gnubok_find_voucher_candidates_for_supplier_invoice',
description: 'List posted verifikat that debit leverantörsskuld (2440) and could be the payment for this supplier invoice. Use before gnubok_link_supplier_invoice_to_voucher when marking a leverantörsfaktura paid against an existing verifikation (no new bokföring).',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
supplier_invoice_id: { type: 'string', description: 'UUID of the supplier invoice to find candidates for' },
limit: { type: 'number', description: 'Max candidates to return (default 10, max 50)' },
},
required: ['supplier_invoice_id'],
},
outputSchema: {
type: 'object',
additionalProperties: false,
properties: {
supplier_invoice_id: { type: 'string' },
invoice_status: { type: 'string' },
candidates: { type: 'array', items: { type: 'object' } },
},
required: ['supplier_invoice_id', 'candidates'],
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
async execute(args, companyId, _userId, supabase) {
const supplierInvoiceId = args.supplier_invoice_id as string
if (!supplierInvoiceId) throw new Error('supplier_invoice_id is required')
const limit = Math.min(Math.max(1, Number(args.limit) || 10), 50)
const { data: invoice, error } = await supabase
.from('supplier_invoices')
.select(
'id, supplier_invoice_number, arrival_number, status, currency, total, paid_amount, remaining_amount, due_date, paid_at, exchange_rate, supplier_id, supplier:suppliers(id, name)'
)
.eq('id', supplierInvoiceId)
.eq('company_id', companyId)
.single()
if (error || !invoice) throw new Error('Supplier invoice not found')
if (!['registered', 'approved', 'overdue', 'partially_paid'].includes(invoice.status)) {
return {
supplier_invoice_id: supplierInvoiceId,
invoice_status: invoice.status,
candidates: [],
}
}
const candidates = await findMatchingVouchersForSupplierInvoice(
supabase,
companyId,
invoice as never,
{ limit },
)
return {
supplier_invoice_id: supplierInvoiceId,
invoice_status: invoice.status,
candidates,
}
},
},
{
name: 'gnubok_link_supplier_invoice_to_voucher',
description: 'Markera en leverantörsfaktura som betald genom att länka till en befintlig verifikation som redan debiterar leverantörsskuld (2440). Ingen ny verifikation skapas. Hitta kandidater med gnubok_find_voucher_candidates_for_supplier_invoice först.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
supplier_invoice_id: { type: 'string', description: 'UUID of the supplier invoice to mark paid' },
journal_entry_id: { type: 'string', description: 'UUID of the existing posted verifikat to link' },
notes: { type: 'string', description: 'Optional note stored on the supplier_invoice_payments row' },
},
required: ['supplier_invoice_id', 'journal_entry_id'],
},
outputSchema: STAGED_OPERATION_SCHEMA,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: false,
},
async execute(args, companyId, userId, supabase, actor) {
const supplierInvoiceId = args.supplier_invoice_id as string
const journalEntryId = args.journal_entry_id as string
const notes = (args.notes as string | undefined) ?? undefined
if (!supplierInvoiceId || !journalEntryId) {
throw new Error('supplier_invoice_id and journal_entry_id are required')
}
const { data: invoice, error: invErr } = await supabase
.from('supplier_invoices')
.select(
'id, supplier_invoice_number, arrival_number, status, currency, total, paid_amount, remaining_amount, due_date, paid_at, exchange_rate, supplier_id, supplier:suppliers(id, name)'
)
.eq('id', supplierInvoiceId)
.eq('company_id', companyId)
.single()
if (invErr || !invoice) throw new Error('Supplier invoice not found')
if (!['registered', 'approved', 'overdue', 'partially_paid'].includes(invoice.status)) {
throw new Error('Supplier invoice is not in a matchable state (must be registered, approved, overdue, or partially_paid)')
}
const validation = await validateVoucherForSupplierInvoiceLink(
supabase,
companyId,
invoice as never,
journalEntryId,
)
if (!validation.ok) {
throw new Error(
`${validation.code}${validation.details ? `: ${JSON.stringify(validation.details)}` : ''}`,
)
}
const voucherLabel = validation.voucher.voucher_series && validation.voucher.voucher_number != null
? `${validation.voucher.voucher_series}-${validation.voucher.voucher_number}`
: journalEntryId.slice(0, 8)
return stagePendingOperation(
supabase,
companyId,
userId,
'link_supplier_invoice_voucher',
`Länka verifikat ${voucherLabel} → leverantörsfaktura ${invoice.supplier_invoice_number ?? supplierInvoiceId.slice(0, 8)}`,
{ supplier_invoice_id: supplierInvoiceId, journal_entry_id: journalEntryId, notes },
{
supplier_invoice_number: invoice.supplier_invoice_number,
invoice_currency: invoice.currency,
invoice_remaining: invoice.remaining_amount,
voucher_label: voucherLabel,
voucher_date: validation.voucher.entry_date,
voucher_description: validation.voucher.description,
ap_debit_amount: validation.apDebitAmount,
payment_amount: validation.paymentAmount,
will_be_fully_paid: validation.isFullyPaid,
remaining_after: validation.remainingAfter,
supplier_name: (invoice.supplier as unknown as { name?: string } | null)?.name ?? null,
},
actor,
{
description: 'After approval the supplier invoice transitions to paid (or partially_paid). No new verifikat is created — the existing voucher is the payment posting.',
tool: 'gnubok_get_supplier_ledger',
},
)
},
},
{
name: 'gnubok_auto_match_period',
description: "Bulk reconciliation: scan unmatched income transactions in a date range and propose invoice matches with confidence + reasoning. dry_run=true (default) previews without staging; dry_run=false stages every match above confidence_threshold as a pending operation.",