Mcp/template data feedback (#617)

* fix(booking-templates): scope template list to the active company

GET /api/settings/booking-templates relied solely on the btl_select RLS
policy, which is membership-wide (user_company_ids) and returns templates
from every company the user belongs to. A user who owns multiple companies
saw all their templates merged regardless of which company was active.

Narrow the list in the API layer (mirroring counterparty-templates) to
system + the active company + the active company's team. RLS stays the
security backstop; this fixes the cross-company merge within a single
user's own view (it was never a cross-tenant data leak).

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

* fix(import): show proper message for duplicate bank file upload

The bank file import page mis-parsed the structured error envelope
({ error: { code, message, details } }), so a BANK_FILE_DUPLICATE
(409) fell through to the generic "Kunde inte läsa filen" fallback.
The upload step also hardcoded that same string as the error heading,
so duplicates were doubly misreported as parse failures.

- Parse the structured envelope by error.code; surface error.message
  for all codes instead of rendering the error object.
- Add a dedicated BANK_FILE_DUPLICATE message using the importedAt /
  importedCount details the route already returns.
- Add an optional errorTitle prop to BankFileUploadStep (defaults to
  the previous text) and pass "Filen är redan importerad" for dupes.

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

* feat(tests): add comprehensive tests for recordateEntry, inbox-linking, and external-id handling

- Implemented unit tests for recordateEntry in the bookkeeping module to validate various scenarios including date changes, non-posted entries, and fiscal period restrictions.
- Created tests for inbox-linking status in pending operations to ensure correct handling of invoice inbox items and supplier invoices, addressing historical bugs related to status updates.
- Added tests for external-id utilities to ensure consistent handling of monetary amounts and deduplication keys across different transaction sources.
- Introduced new functions in external-id.ts for stable external ID generation and normalization of imported descriptions, enhancing transaction deduplication reliability.

feat(migrations): add new database migrations for transaction handling

- Created migration to exclude storno and correction vouchers from unmatched GL lines, ensuring accurate reconciliation.
- Added a migration to preserve original bank transaction descriptions in a new immutable column, allowing for user edits while maintaining audit trails and deduplication integrity.

* feat(migrations): add function to exclude storno/correction vouchers from unmatched GL lines

* feat(transactions): enhance transaction handling with improved description normalization and preloaded original entries

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-06-01 14:45:49 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 2c59c3633f
commit c6c86cded4
54 changed files with 3673 additions and 158 deletions
@@ -17,6 +17,8 @@ import {
getAccountTransactions,
getAllTransactions,
getAllTransactionsWithRaw,
convertTransaction,
type Transaction,
} from '../api-client'
describe('api-client', () => {
@@ -248,3 +250,47 @@ describe('JWT cache', () => {
expect(typeof jwt._resetTokenCache).toBe('function')
})
})
describe('convertTransaction', () => {
function makeTx(overrides: Partial<Transaction> = {}): Transaction {
return {
transaction_amount: { amount: '250.00', currency: 'SEK' },
credit_debit_indicator: 'DBIT',
booking_date: '2024-06-15',
...overrides,
}
}
it('uses remittance_information when present', () => {
const tx = makeTx({ remittance_information: ['Faktura 123', ' '] })
expect(convertTransaction(tx, 'SEK').description).toBe('Faktura 123')
})
it('falls back to the counterparty name when remittance is empty', () => {
const out = makeTx({ remittance_information: [' '], creditor_name: 'Telia AB' })
expect(convertTransaction(out, 'SEK').description).toBe('Telia AB')
})
it('derives a Swedish label from bank_transaction_code when remittance and counterparty are both absent', () => {
const tx = makeTx({ bank_transaction_code: 'PMNT-CCRD-POSD', merchant_category_code: '5411' })
// MCC 5411 wins (most specific).
expect(convertTransaction(tx, 'SEK').description).toBe('Inköp dagligvaror')
})
it('uses the ISO family label when only bank_transaction_code is present', () => {
const tx = makeTx({ bank_transaction_code: 'PMNT/CCRD' })
expect(convertTransaction(tx, 'SEK').description).toBe('Kortköp')
})
it('falls back to the Swedish neutral (never English "Unknown") when nothing is recognized', () => {
const tx = makeTx({})
expect(convertTransaction(tx, 'SEK').description).toBe('Okänd transaktion')
})
it('carries the ISO codes through onto the converted transaction', () => {
const tx = makeTx({ bank_transaction_code: 'PMNT/RCDT', proprietary_bank_transaction_code: 'XB' })
const out = convertTransaction(tx, 'SEK')
expect(out.bank_transaction_code).toBe('PMNT/RCDT')
expect(out.proprietary_bank_transaction_code).toBe('XB')
})
})
@@ -216,10 +216,127 @@ describe('syncAccountTransactions', () => {
expect(mockIngest).toHaveBeenCalledTimes(1)
const rawTxns = mockIngest.mock.calls[0][3]
expect(rawTxns).toHaveLength(1)
expect(rawTxns[0].external_id).toBe('eb_acc-uid-1_tx-500')
// Content-derived external_id: eb_{accountScope}_{date}_{öre}_{occurrence}.
// Deliberately NOT keyed off the bank's (unstable) tx id.
expect(rawTxns[0].external_id).toBe('eb_acc-uid-1_2024-06-15_-50000_0')
expect(rawTxns[0].import_source).toBe('enable_banking')
})
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
// of ids so the second sync dedupes instead of duplicating.
const apiTxns = [
{ transaction_amount: { amount: '250', currency: 'SEK' }, booking_date: '2024-06-15' },
{ transaction_amount: { amount: '250', currency: 'SEK' }, booking_date: '2024-06-15' },
]
mockGetAllTransactionsWithRaw.mockResolvedValue({ transactions: apiTxns, rawPages: ['{}'] })
mockConvertTransaction.mockImplementation((tx: { transaction_amount: { amount: string }, booking_date: string }) => ({
id: `bank-id-${Math.random()}`, // unstable bank id — must NOT influence external_id
date: tx.booking_date,
booking_date: tx.booking_date,
amount: -parseFloat(tx.transaction_amount.amount),
currency: 'SEK',
description: 'Kaffe',
}))
mockUploadDocument.mockResolvedValue({ id: 'doc-1' })
await syncAccountTransactions(
{} as never, COMPANY_ID, USER_ID, CONNECTION_ID, makeAccount(),
'2024-06-01', '2024-06-30', mockIngest
)
const ids = mockIngest.mock.calls[0][3].map((t: { external_id: string }) => t.external_id)
expect(ids).toEqual([
'eb_acc-uid-1_2024-06-15_-25000_0',
'eb_acc-uid-1_2024-06-15_-25000_1',
])
})
it('prefers IBAN over uid for the external_id account scope', async () => {
mockGetAllTransactionsWithRaw.mockResolvedValue({
transactions: [{ transaction_amount: { amount: '100', currency: 'SEK' }, booking_date: '2024-06-15' }],
rawPages: ['{}'],
})
mockConvertTransaction.mockReturnValue({
id: 'tx-1', date: '2024-06-15', booking_date: '2024-06-15', amount: 100, currency: 'SEK', description: 'Test',
})
mockUploadDocument.mockResolvedValue({ id: 'doc-1' })
await syncAccountTransactions(
{} as never, COMPANY_ID, USER_ID, CONNECTION_ID,
makeAccount({ iban: 'SE4550000000058398257466' }),
'2024-06-01', '2024-06-30', mockIngest
)
const ids = mockIngest.mock.calls[0][3].map((t: { external_id: string }) => t.external_id)
expect(ids).toEqual(['eb_SE4550000000058398257466_2024-06-15_10000_0'])
})
it('normalizes IBAN whitespace/case so the account scope is stable across syncs', async () => {
mockGetAllTransactionsWithRaw.mockResolvedValue({
transactions: [{ transaction_amount: { amount: '100', currency: 'SEK' }, booking_date: '2024-06-15' }],
rawPages: ['{}'],
})
mockConvertTransaction.mockReturnValue({
id: 'tx-1', date: '2024-06-15', booking_date: '2024-06-15', amount: 100, currency: 'SEK', description: 'Test',
})
mockUploadDocument.mockResolvedValue({ id: 'doc-1' })
// ASPSP returns the IBAN in grouped, lowercased display form.
await syncAccountTransactions(
{} as never, COMPANY_ID, USER_ID, CONNECTION_ID,
makeAccount({ iban: 'se45 5000 0000 0583 9825 7466' }),
'2024-06-01', '2024-06-30', mockIngest
)
const ids = mockIngest.mock.calls[0][3].map((t: { external_id: string }) => t.external_id)
// Same scope as the spaced/cased variant above.
expect(ids).toEqual(['eb_SE4550000000058398257466_2024-06-15_10000_0'])
})
it('reproduces the same SET of external_ids when a re-sync returns transactions in a different order', async () => {
// Two genuinely distinct same-day/same-amount transactions. A later sync may
// return them in any order; the dedupe guarantee is that the id SET is
// identical, so the re-sync collides on (company_id, external_id).
const mk = (booking_date: string, amount: string) => ({ transaction_amount: { amount, currency: 'SEK' }, booking_date })
const convert = (tx: { transaction_amount: { amount: string }, booking_date: string }) => ({
id: `bank-${Math.random()}`, // unstable bank id — irrelevant to external_id
date: tx.booking_date,
booking_date: tx.booking_date,
amount: -parseFloat(tx.transaction_amount.amount),
currency: 'SEK',
description: 'Lunch',
})
mockConvertTransaction.mockImplementation(convert)
mockUploadDocument.mockResolvedValue({ id: 'doc-1' })
// First sync order.
mockGetAllTransactionsWithRaw.mockResolvedValueOnce({
transactions: [mk('2024-06-15', '250'), mk('2024-06-15', '250')],
rawPages: ['{}'],
})
await syncAccountTransactions(
{} as never, COMPANY_ID, USER_ID, CONNECTION_ID, makeAccount(),
'2024-06-01', '2024-06-30', mockIngest
)
const firstIds = mockIngest.mock.calls[0][3].map((t: { external_id: string }) => t.external_id)
// Re-sync, reversed order (and different lookback window does not matter).
mockGetAllTransactionsWithRaw.mockResolvedValueOnce({
transactions: [mk('2024-06-15', '250'), mk('2024-06-15', '250')],
rawPages: ['{}'],
})
await syncAccountTransactions(
{} as never, COMPANY_ID, USER_ID, CONNECTION_ID, makeAccount(),
'2024-03-01', '2024-06-30', mockIngest
)
const secondIds = mockIngest.mock.calls[1][3].map((t: { external_id: string }) => t.external_id)
expect(new Set(firstIds)).toEqual(new Set(secondIds))
expect(firstIds).toHaveLength(2)
})
it('returns the min/max booking date the ASPSP returned for the activation UI', async () => {
// The min/max loop reads booking_date from the *raw* transactions (sync.ts:75-82),
// before convertTransaction runs — so the dates need to be set here.
@@ -0,0 +1,50 @@
import { describe, it, expect } from 'vitest'
import { deriveTransactionLabel } from '../transaction-label'
describe('deriveTransactionLabel', () => {
it('prefers MCC over the ISO code (most specific signal)', () => {
expect(deriveTransactionLabel({ mcc: '6011' })).toBe('Uttag')
expect(deriveTransactionLabel({ mcc: 5411 })).toBe('Inköp dagligvaror')
// MCC wins even when a (different-meaning) bank code is also present.
expect(
deriveTransactionLabel({ mcc: '6011', bankTransactionCode: 'PMNT/CCRD' }),
).toBe('Uttag')
})
it('maps ISO 20022 Domain/Family codes', () => {
expect(deriveTransactionLabel({ bankTransactionCode: 'PMNT/RCDT' })).toBe('Inbetalning')
expect(deriveTransactionLabel({ bankTransactionCode: 'PMNT/ICDT' })).toBe('Betalning')
expect(deriveTransactionLabel({ bankTransactionCode: 'PMNT/CCRD' })).toBe('Kortköp')
expect(deriveTransactionLabel({ bankTransactionCode: 'PMNT/RDDT' })).toBe('Autogiro')
})
it('parses dash- and dot-separated three-part codes (Domain-Family-SubFamily)', () => {
expect(deriveTransactionLabel({ bankTransactionCode: 'PMNT-CCRD-POSD' })).toBe('Kortköp')
expect(deriveTransactionLabel({ bankTransactionCode: 'pmnt.rcdt.esct' })).toBe('Inbetalning')
})
it('falls back to a keyword scan for proprietary code strings', () => {
expect(
deriveTransactionLabel({ proprietaryBankTransactionCode: 'INTEREST PAYMENT' }),
).toBe('Ränta')
expect(
deriveTransactionLabel({ proprietaryBankTransactionCode: 'ACCOUNT FEE' }),
).toBe('Avgift')
expect(
deriveTransactionLabel({ proprietaryBankTransactionCode: 'ATM WITHDRAWAL' }),
).toBe('Uttag')
})
it('uses the bare PMNT domain + direction as a last generic resort', () => {
expect(deriveTransactionLabel({ bankTransactionCode: 'PMNT', isCredit: true })).toBe('Inbetalning')
expect(deriveTransactionLabel({ bankTransactionCode: 'PMNT', isCredit: false })).toBe('Betalning')
})
it('returns null when nothing is recognized (caller falls through)', () => {
expect(deriveTransactionLabel({})).toBeNull()
expect(deriveTransactionLabel({ bankTransactionCode: 'ZZZZ/QQQQ' })).toBeNull()
expect(deriveTransactionLabel({ mcc: '0000' })).toBeNull()
// Bare unknown domain without isCredit cannot be classified.
expect(deriveTransactionLabel({ bankTransactionCode: 'PMNT' })).toBeNull()
})
})
@@ -13,6 +13,8 @@
*/
import { getAuthorizationHeader } from './jwt'
import { deriveTransactionLabel } from './transaction-label'
import { FALLBACK_DESCRIPTION } from '@/lib/transactions/external-id'
// Prefer _PRODUCTION variant; sandbox uses api.tilisy.com, production uses api.enablebanking.com
const ENABLE_BANKING_API_URL =
@@ -149,6 +151,11 @@ export interface BankTransaction {
counterparty_account?: string
reference?: string
merchant_category_code?: string
// ISO 20022 / proprietary transaction codes — carried through so the
// description fallback can derive a meaningful Swedish label when remittance
// text and a counterparty name are both absent. See deriveTransactionLabel.
bank_transaction_code?: string
proprietary_bank_transaction_code?: string
}
// Constants
@@ -639,14 +646,27 @@ export function convertTransaction(tx: Transaction, accountCurrency: string): Ba
booking_date: tx.booking_date || tx.value_date || new Date().toISOString().split('T')[0],
amount,
currency: tx.transaction_amount.currency || accountCurrency,
// Fallback chain: bank's payment message → counterparty name → a Swedish
// label derived from the ISO 20022 / MCC codes the bank DID send (card
// purchases, ATM, fees, interest) → 'Okänd transaktion'. The final fallback
// is also normalized at the ingest boundary, so any leftover lands as the
// same Swedish neutral.
description: tx.remittance_information?.filter(r => r.trim()).join(' ') ||
(isCredit ? debtorName : creditorName) ||
'Unknown',
deriveTransactionLabel({
bankTransactionCode: tx.bank_transaction_code,
proprietaryBankTransactionCode: tx.proprietary_bank_transaction_code,
mcc: tx.merchant_category_code,
isCredit,
}) ||
FALLBACK_DESCRIPTION,
counterparty_name: isCredit ? debtorName : creditorName,
counterparty_account: isCredit
? tx.debtor_account?.iban || tx.debtor_account?.bban
: tx.creditor_account?.iban || tx.creditor_account?.bban,
merchant_category_code: tx.merchant_category_code
merchant_category_code: tx.merchant_category_code,
bank_transaction_code: tx.bank_transaction_code,
proprietary_bank_transaction_code: tx.proprietary_bank_transaction_code,
}
}
+23 -3
View File
@@ -2,6 +2,7 @@ import type { SupabaseClient } from '@supabase/supabase-js'
import { getAllTransactionsWithRaw, convertTransaction, getAccountBalance } from './api-client'
import { uploadDocument } from '@/lib/core/documents/document-service'
import { ingestTransactions as defaultIngest } from '@/lib/transactions/ingest'
import { buildStableExternalIds, FALLBACK_DESCRIPTION } from '@/lib/transactions/external-id'
import type { RawTransaction, IngestResult, IngestOptions } from '@/types'
import type { StoredAccount, TransactionsFetchStrategy } from '../types'
@@ -99,19 +100,38 @@ 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.
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 }))
)
// 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) => {
const rawTransactions: RawTransaction[] = bankTransactions.map((tx, 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,
description: tx.description || tx.counterparty_name || 'Unknown',
// 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,
amount: tx.amount,
currency: tx.currency || account.currency,
external_id: `eb_${account.iban || account.uid}_${tx.id}`,
external_id: externalIds[i],
mcc_code: tx.merchant_category_code ? parseInt(tx.merchant_category_code, 10) : null,
merchant_name: tx.counterparty_name || null,
reference: tx.reference || null,
@@ -0,0 +1,100 @@
/**
* Derive a Swedish, human-readable working label for a bank transaction from
* the structured codes an ASPSP DOES send when free-text remittance and a
* counterparty name are both absent — the classic card-purchase / ATM / fee /
* interest case that otherwise falls through to a generic placeholder.
*
* Pure and side-effect free, so it is trivially unit-testable and safe to call
* inside the transaction conversion fallback chain.
*
* Precedence (most specific first):
* 1. MCC (merchant_category_code) — identifies the merchant kind for a card
* purchase. Already trusted for auto-categorization
* (lib/bookkeeping/mapping-engine.ts).
* 2. ISO 20022 bank_transaction_code Domain/Family (e.g. "PMNT/CCRD").
* 3. Keyword scan over the (often proprietary, non-normalized) code strings.
* 4. Bare "PMNT" domain with no recognized family → direction-based generic.
*
* Returns null when nothing is recognized — the caller then falls through to
* its own final fallback (the ingest boundary normalizes any leftover empty /
* 'Unknown' value to 'Okänd transaktion').
*
* The mapping tables are intentionally small starters. ASPSP coverage of these
* codes varies and proprietary formats differ per bank — extend the tables
* against real archived `psd2-response_*.json` samples as they surface.
*/
export interface TransactionLabelInput {
/** ISO 20022 bank transaction code, e.g. "PMNT-CCRD-POSD" or "PMNT/RCDT". */
bankTransactionCode?: string | null
/** ASPSP-proprietary code (free-form, varies per bank). */
proprietaryBankTransactionCode?: string | null
/** Merchant category code (card transactions). */
mcc?: string | number | null
/** CRDT (money in) vs DBIT (money out) — used only for the bare-domain case. */
isCredit?: boolean
}
// ISO 20022 External Bank Transaction Codes, keyed by `DOMAIN/FAMILY`.
const ISO20022_LABELS: Record<string, string> = {
'PMNT/RCDT': 'Inbetalning', // ReceivedCreditTransfers
'PMNT/ICDT': 'Betalning', // IssuedCreditTransfers
'PMNT/CCRD': 'Kortköp', // CustomerCardTransactions
'PMNT/MCRD': 'Kortköp', // MerchantCardTransactions
'PMNT/RDDT': 'Autogiro', // ReceivedDirectDebits
'PMNT/IDDT': 'Autogiro', // IssuedDirectDebits
'PMNT/CWDL': 'Uttag', // CashWithdrawal
'PMNT/CAJT': 'Justering', // CashAdjustments
}
// MCC → coarse Swedish label. Tiny starter set.
const MCC_LABELS: Record<string, string> = {
'6011': 'Uttag', // ATM / automated cash disbursements
'5411': 'Inköp dagligvaror', // Grocery stores, supermarkets
}
// Keyword → label, scanned over the raw (incl. proprietary) code strings as a
// last resort before null. Covers banks that send free-form codes, not ISO.
const KEYWORD_LABELS: Array<[RegExp, string]> = [
[/INTRST|INTEREST|RÄNTA|RANTA/i, 'Ränta'],
[/\bFEE\b|CHRG|CHARGE|AVGIFT/i, 'Avgift'],
[/ATM|CASH.?WDL|WITHDRAW|UTTAG/i, 'Uttag'],
[/\bCARD\b|KORT|\bPOS\b/i, 'Kortköp'],
[/SALA|SALARY|\bLÖN\b|\bLON\b/i, 'Lön'],
]
export function deriveTransactionLabel(input: TransactionLabelInput): string | null {
// 1. MCC — most specific signal for card purchases.
const mcc = input.mcc != null ? String(input.mcc).trim() : ''
if (mcc && MCC_LABELS[mcc]) return MCC_LABELS[mcc]
const codes = [input.bankTransactionCode, input.proprietaryBankTransactionCode].filter(
(c): c is string => typeof c === 'string' && c.trim().length > 0,
)
// 2. ISO 20022 Domain/Family from the structured code.
for (const raw of codes) {
const parts = raw.toUpperCase().split(/[/\-_.\s]+/).filter(Boolean)
if (parts.length >= 2) {
const key = `${parts[0]}/${parts[1]}`
if (ISO20022_LABELS[key]) return ISO20022_LABELS[key]
}
}
// 3. Keyword scan over the raw code strings (covers proprietary formats).
for (const raw of codes) {
for (const [re, label] of KEYWORD_LABELS) {
if (re.test(raw)) return label
}
}
// 4. Bare "PMNT" domain with no recognized family → direction-based generic.
if (input.isCredit != null) {
for (const raw of codes) {
const domain = raw.toUpperCase().split(/[/\-_.\s]+/)[0]
if (domain === 'PMNT') return input.isCredit ? 'Inbetalning' : 'Betalning'
}
}
return null
}
+14
View File
@@ -20,6 +20,7 @@ import {
import { createSupplierInvoiceRegistrationEntry } from '@/lib/bookkeeping/supplier-invoice-entries'
import { createJournalEntry } from '@/lib/bookkeeping/engine'
import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { linkToJournalEntry } from '@/lib/core/documents/document-service'
import { CreateSupplierInvoiceSchema, BookInboxItemDirectlySchema } from '@/lib/api/schemas'
import { appendProcessingHistory } from '@/lib/processing-history/append'
@@ -1766,6 +1767,19 @@ export const invoiceInboxExtension: Extension = {
.eq('id', item.document_id)
.eq('company_id', ctx.companyId)
}
} else {
// createSupplierInvoiceRegistrationEntry returns null ONLY when no
// fiscal period covers invoice_date (every other failure throws).
// Roll back so we never mark the inbox item converted against an
// unbooked supplier invoice (orphan understating 2440/2641).
await ctx.supabase
.from('supplier_invoices')
.delete()
.eq('id', invoice.id)
.eq('company_id', ctx.companyId)
return errorResponseFromCode('SI_CREATE_NO_FISCAL_PERIOD', ctx.log, {
details: { invoiceDate: (invoice as SupplierInvoice).invoice_date },
})
}
} catch (err) {
console.error('[invoice-inbox/convert] Failed to create registration journal entry:', err)