diff --git a/app/(dashboard)/transactions/page.tsx b/app/(dashboard)/transactions/page.tsx index fde5f858..e42952f7 100644 --- a/app/(dashboard)/transactions/page.tsx +++ b/app/(dashboard)/transactions/page.tsx @@ -50,7 +50,7 @@ import { findBankSkvCounterparts } from '@/lib/skatteverket/bank-counterpart' import { useCompany } from '@/contexts/CompanyContext' import { getErrorMessage } from '@/lib/errors/get-error-message' import { formatCurrency, formatDate } from '@/lib/utils' -import type { TransactionCategory, CreateTransactionInput, Invoice, Customer, SupplierInvoice, Supplier, VatTreatment, EntityType, LinePatternEntry } from '@/types' +import type { TransactionCategory, CreateTransactionInput, Invoice, Customer, SupplierInvoice, Supplier, VatTreatment, EntityType, LinePatternEntry, BookingTemplateLibrary } from '@/types' import type { SuggestedTemplate } from '@/lib/transactions/category-suggestions' type InvoiceWithCustomer = Invoice & { customer?: Customer } @@ -109,6 +109,7 @@ export default function TransactionsPage() { // Booking dialog (journal entry form) const [bookingDialogOpen, setBookingDialogOpen] = useState(false) const [bookingDialogTransaction, setBookingDialogTransaction] = useState(null) + const [bookingDialogTemplate, setBookingDialogTemplate] = useState(null) // Template picker dialog const [templatePickerOpen, setTemplatePickerOpen] = useState(false) @@ -541,6 +542,78 @@ export default function TransactionsPage() { setProcessingId(null) return null } + if (result?.error?.code === 'TX_CATEGORIZE_INVALID_ACCOUNT') { + // The user picked a library template (or typed an account + // override) whose account isn't in this company's kontoplan. + // Mirror the ACCOUNTS_NOT_IN_CHART flow with a one-click + // "Aktivera och bokför" — pull the BAS name if known so the + // toast carries real context. + // Validate the BAS account number is a plain 4-digit string before + // embedding it in any fetch URL/body — the value comes from the + // server error envelope but defense-in-depth. + const rawAccountNumber: unknown = result.error.details?.accountNumber + const accountNumber: string | undefined = + typeof rawAccountNumber === 'string' && /^\d{4}$/.test(rawAccountNumber) + ? rawAccountNumber + : undefined + let displayName = accountNumber ?? '' + if (accountNumber) { + try { + const lookupRes = await fetch(`/api/bookkeeping/accounts/bas-lookup?numbers=${encodeURIComponent(accountNumber)}`) + if (lookupRes.ok) { + const lookup = await lookupRes.json() as { data?: Array<{ account_number: string; account_name: string | null; known?: boolean }> } + const hit = lookup.data?.find((r) => r.account_number === accountNumber) + if (hit?.account_name) displayName = `${accountNumber} — ${hit.account_name}` + } + } catch { /* fall through to the plain number */ } + } + let invalidAccountActivateInFlight = false + toast({ + title: 'Kontot finns inte i din kontoplan', + description: accountNumber + ? `Kontot ${displayName} är inte aktiverat.` + : 'Kontot är inte aktiverat.', + variant: 'destructive', + action: accountNumber ? ( + { + if (invalidAccountActivateInFlight) return + invalidAccountActivateInFlight = true + try { + const activateRes = await fetch('/api/bookkeeping/accounts/activate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ account_numbers: [accountNumber] }), + }) + if (!activateRes.ok) { + const errBody = await activateRes.json().catch(() => null) + toast({ + title: 'Kunde inte aktivera kontot', + description: getErrorMessage(errBody, { statusCode: activateRes.status }), + variant: 'destructive', + }) + return + } + const activateBody = await activateRes.json() + if (Array.isArray(activateBody.unknown) && activateBody.unknown.length > 0) { + toast({ + title: 'Kontot finns inte i BAS-planen', + description: `Lägg till ${accountNumber} manuellt under Inställningar → Kontoplan.`, + variant: 'destructive', + }) + return + } + await runCategorize(args) + } finally { + invalidAccountActivateInFlight = false + } + }}> + Aktivera och bokför + + ) : undefined, + }) + setProcessingId(null) + return null + } if (result?.error?.code === 'ACCOUNTS_NOT_IN_CHART') { // The mapped template/category references one or more accounts // that aren't active in this company's kontoplan. Without an @@ -1240,6 +1313,7 @@ export default function TransactionsPage() { }, 350) setBookingDialogOpen(false) setBookingDialogTransaction(null) + setBookingDialogTemplate(null) toast({ title: 'Bokförd' }) } @@ -1392,10 +1466,22 @@ export default function TransactionsPage() { setTemplatePickerOpen(false) if (templatePickerTransaction) { setBookingDialogTransaction(templatePickerTransaction) + setBookingDialogTemplate(null) setBookingDialogOpen(true) } } + // Complex (multi-leg or otherwise non-convertible) library template picked + // from the transaction modal — route into the manual booking dialog with + // the template pre-applied against the transaction's amount. + function handlePickLibraryTemplate(raw: BookingTemplateLibrary) { + if (!templatePickerTransaction) return + setBookingDialogTransaction(templatePickerTransaction) + setBookingDialogTemplate(raw) + setTemplatePickerOpen(false) + setBookingDialogOpen(true) + } + async function handleQuickReviewConfirm( id: string, category: TransactionCategory, @@ -1714,8 +1800,12 @@ export default function TransactionsPage() { { + setBookingDialogOpen(o) + if (!o) setBookingDialogTemplate(null) + }} transaction={bookingDialogTransaction} + preselectedTemplate={bookingDialogTemplate} onBooked={handleTransactionBooked} /> @@ -1767,6 +1857,7 @@ export default function TransactionsPage() { setTemplatePickerOpen(false) handleOpenTemplateReview(templatePickerTransaction, templateId) }} + onPickLibraryTemplate={handlePickLibraryTemplate} /> diff --git a/app/api/transactions/[id]/categorize/__tests__/route.test.ts b/app/api/transactions/[id]/categorize/__tests__/route.test.ts index 71743c94..0b20981e 100644 --- a/app/api/transactions/[id]/categorize/__tests__/route.test.ts +++ b/app/api/transactions/[id]/categorize/__tests__/route.test.ts @@ -714,4 +714,40 @@ describe('POST /api/transactions/[id]/categorize', () => { // had to enqueue a response for it. The absence of an enqueue here plus // the 400 status is the assertion that the route did not fall through. }) + + // The transactions page surfaces TX_CATEGORIZE_INVALID_ACCOUNT with an + // inline "Aktivera och bokför" toast and reads details.accountNumber to + // call POST /accounts/activate. This test pins the error shape that flow + // depends on — if the field name changes the recovery UI silently breaks. + it('returns 400 TX_CATEGORIZE_INVALID_ACCOUNT with details.accountNumber when account_override is not in the chart', async () => { + const tx = makeTransaction({ + id: 'tx-1', + amount: -869.25, + merchant_name: 'Paddle', + journal_entry_id: null, + }) + + enqueue({ data: tx, error: null }) + enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null }) + // chart_of_accounts lookup for '5420' — not in the company's chart. + // Using a plain expense account (Programvaror) avoids the implication + // that 4535 (Inköp av varor från annat EU-land, reverse-charge) would + // be a valid override on a domestic transaction without its paired + // moms legs (2614/2645) — see the Swedish compliance review note. + enqueue({ data: null, error: null }) + + const request = createMockRequest('/api/transactions/tx-1/categorize', { + method: 'POST', + body: { is_business: true, category: 'expense_software', account_override: '5420' }, + }) + const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse<{ + error: { code: string; details: { accountNumber?: string } } + }>(response) + + expect(status).toBe(400) + expect(body.error.code).toBe('TX_CATEGORIZE_INVALID_ACCOUNT') + expect(body.error.details.accountNumber).toBe('5420') + expect(mockCreateTransactionJournalEntry).not.toHaveBeenCalled() + }) }) diff --git a/components/settings/BookingTemplatesPanel.tsx b/components/settings/BookingTemplatesPanel.tsx index 1eb3f40f..a63619b4 100644 --- a/components/settings/BookingTemplatesPanel.tsx +++ b/components/settings/BookingTemplatesPanel.tsx @@ -490,59 +490,63 @@ function CreateTemplateForm({ onCreated, entityLabels }: { onCreated: () => void
{lines.map((line, i) => ( -
- updateLine(i, 'account', e.target.value.replace(/\D/g, '').slice(0, 4))} - placeholder={t('account_placeholder')} - className="w-20 font-mono" - maxLength={4} - /> - updateLine(i, 'label', e.target.value)} - placeholder={t('description_short_placeholder')} - className="flex-1" - /> - - - {line.type === 'vat' && ( - updateLine(i, 'account', e.target.value.replace(/\D/g, '').slice(0, 4))} + placeholder={t('account_placeholder')} + className="w-20 font-mono" + maxLength={4} + /> + updateLine(i, 'label', e.target.value)} + placeholder={t('description_short_placeholder')} + className="flex-1 min-w-0" + /> + +
+
+ - )} - + + {line.type === 'vat' && ( + + )} +
))} + ) +} + function TemplateCard({ template, selected, onClick, compact }: TemplateCardProps) { const t = useTranslations('tx_template_picker') const vatLabelKey = getVatLabelKey(template) @@ -140,6 +207,7 @@ interface TemplatePickerProps { recentTemplateIds?: string[] onSelect: (template: BookingTemplate) => void onSelectCounterparty?: (templateId: string) => void + onPickLibraryTemplate?: (raw: BookingTemplateLibrary) => void selectedTemplateId?: string } @@ -149,19 +217,25 @@ export default function TemplatePicker({ suggestedTemplates, onSelect, onSelectCounterparty, + onPickLibraryTemplate, selectedTemplateId, }: TemplatePickerProps) { const t = useTranslations('tx_template_picker') const [searchQuery, setSearchQuery] = useState('') const [showAdvanced, setShowAdvanced] = useState(false) - const [libraryTemplates, setLibraryTemplates] = useState([]) + const [libraryRaw, setLibraryRaw] = useState([]) - // Map direction to template direction filter (transfers show in both) + // Map direction to template direction filter (transfers show in both). + // Direction filtering applies only to the static "Vanliga mallar" list — + // user-created library templates ignore it (inferred direction is unreliable + // and users know what they made). const templateDirection = direction === 'income' ? 'income' : 'expense' - // Fetch user-created booking templates (company + team scope) and map - // convertible ones into BookingTemplate shape. System templates are - // already covered by the static list below, so we exclude them here. + // Fetch the user's library templates (company + team scope). We keep them + // in their raw shape so we can render every template, even ones that don't + // fit convertLibraryToBookingTemplate's simple 2-account contract — those + // get routed through the manual booking dialog instead of the QuickReview + // single-account path. useEffect(() => { const controller = new AbortController() ;(async () => { @@ -170,11 +244,7 @@ export default function TemplatePicker({ if (!res.ok) return const { data } = await res.json() as { data?: BookingTemplateLibrary[] } if (!data) return - const mapped = data - .filter((t) => !t.is_system && t.is_active) - .map(convertLibraryToBookingTemplate) - .filter((t): t is BookingTemplate => t !== null) - setLibraryTemplates(mapped) + setLibraryRaw(data.filter((tt) => !tt.is_system && tt.is_active)) } catch (err) { if (err instanceof DOMException && err.name === 'AbortError') return } @@ -182,6 +252,16 @@ export default function TemplatePicker({ return () => { controller.abort() } }, []) + // Lazy convertibility map. A template is "convertible" if it fits the + // simple debit/credit pair shape the QuickReview booking path expects. + // Non-convertible templates are still shown — they just route to the + // full journal-entry editor on click. + const convertedById = useMemo(() => { + const m = new Map() + for (const raw of libraryRaw) m.set(raw.id, convertLibraryToBookingTemplate(raw)) + return m + }, [libraryRaw]) + const commonTemplates = useMemo( () => getCommonTemplates(entityType, templateDirection), [entityType, templateDirection] @@ -211,43 +291,78 @@ export default function TemplatePicker({ [advancedTemplates, advancedTransfers] ) - // User-created library templates filtered by direction + entity - const relevantLibraryTemplates = useMemo(() => { - return libraryTemplates.filter((t) => { - if (entityType && t.entity_applicability !== 'all' && t.entity_applicability !== entityType) { + // Library templates filtered by entity_type only. Direction is NOT applied + // here — see the comment on convertedById above. + const relevantLibraryRaw = useMemo(() => { + return libraryRaw.filter((tt) => { + if (entityType && tt.entity_type && tt.entity_type !== 'all' && tt.entity_type !== entityType) { return false } - return t.direction === templateDirection || t.direction === 'transfer' + return true }) - }, [libraryTemplates, entityType, templateDirection]) + }, [libraryRaw, entityType]) - // Search results (static + library) - const searchResults = useMemo(() => { + // Convertible templates surface first; within each group, sort by name. + const sortedLibraryRaw = useMemo(() => { + return [...relevantLibraryRaw].sort((a, b) => { + const ac = convertedById.get(a.id) ? 0 : 1 + const bc = convertedById.get(b.id) ? 0 : 1 + if (ac !== bc) return ac - bc + return a.name.localeCompare(b.name, 'sv') + }) + }, [relevantLibraryRaw, convertedById]) + + // Search results (static + library). Library search ignores direction; the + // static catalog still respects it because it's curated content. + const searchResults = useMemo< + | { library: BookingTemplateLibrary[]; staticTemplates: BookingTemplate[] } + | null + >(() => { if (!searchQuery.trim()) return null const q = searchQuery.toLowerCase() - const libraryMatches = relevantLibraryTemplates.filter((t) => - t.name_sv.toLowerCase().includes(q) || t.description_sv.toLowerCase().includes(q) + const libraryMatches = sortedLibraryRaw.filter((tt) => + tt.name.toLowerCase().includes(q) || + (tt.description ?? '').toLowerCase().includes(q) ) - const staticMatches = searchTemplates(searchQuery, entityType).filter((t) => { - if (t.direction === templateDirection || t.direction === 'transfer') return true - return false + const staticMatches = searchTemplates(searchQuery, entityType).filter((tt) => { + return tt.direction === templateDirection || tt.direction === 'transfer' }) - return [...libraryMatches, ...staticMatches] - }, [searchQuery, entityType, templateDirection, relevantLibraryTemplates]) + return { library: libraryMatches, staticTemplates: staticMatches } + }, [searchQuery, entityType, templateDirection, sortedLibraryRaw]) // Group templates by group for display const commonGrouped = useMemo(() => groupTemplates(allCommon), [allCommon]) const advancedGrouped = useMemo(() => groupTemplates(allAdvanced), [allAdvanced]) + const bumpLibraryMru = (libraryId: string) => { + fetch(`/api/settings/booking-templates/${libraryId}/touch`, { method: 'POST' }).catch(() => {}) + } + const handleSelect = (template: BookingTemplate) => { - // For library-backed templates, bump MRU so they surface at the top next time. if (isLibraryTemplateId(template.id)) { - const libraryId = template.id.slice(LIBRARY_TEMPLATE_PREFIX.length) - fetch(`/api/settings/booking-templates/${libraryId}/touch`, { method: 'POST' }).catch(() => {}) + bumpLibraryMru(template.id.slice(LIBRARY_TEMPLATE_PREFIX.length)) } onSelect(template) } + // Click a raw library card. Convertible → fast QuickReview path via onSelect. + // Non-convertible → route to manual booking dialog pre-filled via the new + // onPickLibraryTemplate callback. MRU is only bumped after we confirm the + // click will actually do something — otherwise consumers that omit the + // callback would corrupt MRU ordering for templates the user never applied. + const handleSelectLibraryRaw = (raw: BookingTemplateLibrary) => { + const converted = convertedById.get(raw.id) ?? null + if (converted) { + bumpLibraryMru(raw.id) + onSelect(converted) + return + } + if (onPickLibraryTemplate) { + bumpLibraryMru(raw.id) + onPickLibraryTemplate(raw) + } + } + // Split suggestions: counterparty templates vs regular booking templates const counterpartySuggestions = useMemo(() => { if (!suggestedTemplates) return [] @@ -277,38 +392,54 @@ export default function TemplatePicker({
{/* Search results */} {searchResults !== null ? ( -
-

- {searchResults.length === 0 ? t('no_results') : t('n_results', { count: searchResults.length })} -

-
- {searchResults.map((t) => ( - handleSelect(t)} - /> - ))} -
-
+ (() => { + const totalResults = searchResults.library.length + searchResults.staticTemplates.length + return ( +
+

+ {totalResults === 0 ? t('no_results') : t('n_results', { count: totalResults })} +

+
+ {searchResults.library.map((raw) => ( + handleSelectLibraryRaw(raw)} + /> + ))} + {searchResults.staticTemplates.map((tt) => ( + handleSelect(tt)} + /> + ))} +
+
+ ) + })() ) : ( <> - {/* User-created library templates (company + team scope) */} - {relevantLibraryTemplates.length > 0 && ( + {/* User-created library templates (company + team scope). + Direction is intentionally NOT applied here — all the user's + own templates are shown regardless of expense/income context. */} + {sortedLibraryRaw.length > 0 && (

{t('my_templates')}

- {relevantLibraryTemplates.map((t) => ( - handleSelect(t)} - compact + {sortedLibraryRaw.map((raw) => ( + handleSelectLibraryRaw(raw)} /> ))}
diff --git a/components/transactions/TransactionBookingDialog.tsx b/components/transactions/TransactionBookingDialog.tsx index 3582c1d0..5a529607 100644 --- a/components/transactions/TransactionBookingDialog.tsx +++ b/components/transactions/TransactionBookingDialog.tsx @@ -13,6 +13,8 @@ import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone' import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone' import type { FormLine } from '@/components/bookkeeping/JournalEntryForm' import { resolveSekAmount, buildCurrencyMetadata } from '@/lib/bookkeeping/currency-utils' +import { applyTemplate } from '@/lib/bookkeeping/template-library' +import type { BookingTemplateLibrary } from '@/types' import type { TransactionWithInvoice } from './transaction-types' interface TransactionBookingDialogProps { @@ -20,6 +22,7 @@ interface TransactionBookingDialogProps { onOpenChange: (open: boolean) => void transaction: TransactionWithInvoice | null onBooked: (transactionId: string, journalEntryId: string) => void + preselectedTemplate?: BookingTemplateLibrary | null } function buildInitialLines(transaction: TransactionWithInvoice, bankLineDescription: string): FormLine[] { @@ -59,11 +62,41 @@ function buildInitialLines(transaction: TransactionWithInvoice, bankLineDescript return isExpense ? [bankLine, counterLine] : [bankLine, counterLine] } +function buildInitialLinesFromTemplate( + transaction: TransactionWithInvoice, + template: BookingTemplateLibrary, +): FormLine[] { + const sekAmount = Math.round(Math.abs(resolveSekAmount( + transaction.amount, + transaction.amount_sek, + transaction.currency, + transaction.exchange_rate + )) * 100) / 100 + const lines = applyTemplate(template.lines, sekAmount) + + // Match buildInitialLines's foreign-currency handling: attach original + // currency/amount/exchange_rate metadata to the settlement (bank/cash) legs + // so the journal entry retains the foreign-currency annotation. Without + // this the entry is silently recorded in SEK only. + const isForeign = !!transaction.currency && transaction.currency !== 'SEK' + if (!isForeign) return lines + const currencyMeta = buildCurrencyMetadata( + transaction.currency, + Math.abs(transaction.amount), + transaction.exchange_rate + ) + return lines.map((line, i) => { + const raw = template.lines[i] + return raw?.type === 'settlement' ? { ...line, ...currencyMeta } : line + }) +} + export default function TransactionBookingDialog({ open, onOpenChange, transaction, onBooked, + preselectedTemplate, }: TransactionBookingDialogProps) { const t = useTranslations('tx_booking_dialog') const { toast } = useToast() @@ -179,9 +212,13 @@ export default function TransactionBookingDialog({
{ const tpl = makeLibraryTemplate([], { lines: null as unknown as BookingTemplateLibraryLine[] }) expect(convertLibraryToBookingTemplate(tpl)).toBeNull() }) + + // Real-world shape from before the editor defaulted new lines to 'vat': users + // would tap "add line" twice and end up with three lines all typed 'business' + // (the dropdown default at the time). The converter rightly rejects this; + // the transaction picker now still surfaces these templates and routes the + // click to the manual booking editor instead of hiding them. + it('returns null when every line is typed "business" (pre-#589 default)', () => { + const tpl = makeLibraryTemplate([ + { account: '5420', label: 'Programvara', side: 'debit', type: 'business', ratio: 1 }, + { account: '2640', label: 'Ingående moms', side: 'debit', type: 'business', ratio: 0.25 }, + { account: '1930', label: 'Företagskonto', side: 'credit', type: 'business', ratio: 1 }, + ]) + expect(convertLibraryToBookingTemplate(tpl)).toBeNull() + }) +}) + +describe('applyTemplate on shapes the converter rejects', () => { + // The transaction picker's fallback for unconvertible templates is to open + // the manual booking dialog with initialLines = applyTemplate(raw.lines, |amount|). + // These tests pin that path: even when the shape is too rich for the simple + // debit/credit summary, applyTemplate still produces a usable FormLine[]. + it('still produces lines for a split-expense template (two business legs)', () => { + const lines: BookingTemplateLibraryLine[] = [ + { account: '5420', label: 'Programvara', side: 'debit', type: 'business', ratio: 0.7 }, + { account: '6991', label: 'Övrigt', side: 'debit', type: 'business', ratio: 0.3 }, + { account: '1930', label: 'Företagskonto', side: 'credit', type: 'settlement', ratio: 1 }, + ] + const result = applyTemplate(lines, 1000) + expect(result).toHaveLength(3) + expect(result[0].debit_amount).toBe('700.00') + expect(result[1].debit_amount).toBe('300.00') + expect(result[2].credit_amount).toBe('1000.00') + }) + + it('still produces lines when every leg is typed "business"', () => { + const lines: BookingTemplateLibraryLine[] = [ + { account: '5420', label: 'Programvara', side: 'debit', type: 'business', ratio: 1 }, + { account: '1930', label: 'Företagskonto', side: 'credit', type: 'business', ratio: 1 }, + ] + const result = applyTemplate(lines, 250) + expect(result).toHaveLength(2) + expect(result[0].debit_amount).toBe('250.00') + expect(result[1].credit_amount).toBe('250.00') + }) }) diff --git a/lib/cash-accounts/service.ts b/lib/cash-accounts/service.ts index 503d5c6d..d2e8c9fa 100644 --- a/lib/cash-accounts/service.ts +++ b/lib/cash-accounts/service.ts @@ -142,6 +142,57 @@ export async function upsertFromPsd2( source: 'enable_banking' as CashAccountSource, } + // create_company_with_owner and the seed_default_cash_account migration plant + // a manual (bank_connection_id IS NULL) row on the same ledger_account so + // reconciliation routes work before any PSD2 connection exists. The first + // PSD2 sync for that BAS slot has to promote that row in place — a plain + // upsert on (company_id, bank_connection_id, external_uid) wouldn't match it + // (NULL ≠ NULL) and the INSERT path then trips the (company_id, + // ledger_account) UNIQUE constraint. + const { data: seedRow, error: seedLookupError } = await supabase + .from('cash_accounts') + .select('id') + .eq('company_id', companyId) + .eq('ledger_account', input.ledger_account) + .is('bank_connection_id', null) + .maybeSingle() + + if (seedLookupError) { + log.error('upsertFromPsd2 seed lookup failed', { + companyId, + bankConnectionId: input.bank_connection_id, + externalUid: input.external_uid, + error: seedLookupError.message, + }) + throw new Error(`cash_accounts upsert failed: ${seedLookupError.message}`) + } + + if (seedRow) { + // .select() so we can detect a 0-row UPDATE — Supabase's update().eq() returns + // { error: null, data: [] } if the row was deleted between the SELECT above + // and this UPDATE (rare but theoretically possible under concurrent ops). + // If that happens, fall through to the normal upsert path instead of + // silently returning success without persisting anything. + const { data: promoted, error: promoteError } = await supabase + .from('cash_accounts') + .update(payload) + .eq('id', seedRow.id) + .select('id') + if (promoteError) { + log.error('upsertFromPsd2 promote-seed failed', { + companyId, + bankConnectionId: input.bank_connection_id, + externalUid: input.external_uid, + error: promoteError.message, + }) + throw new Error(`cash_accounts upsert failed: ${promoteError.message}`) + } + if (promoted && promoted.length > 0) { + return + } + // Seed row vanished between SELECT and UPDATE — fall through to upsert. + } + const { error } = await supabase .from('cash_accounts') .upsert(payload, { onConflict: 'company_id,bank_connection_id,external_uid' }) diff --git a/messages/en.json b/messages/en.json index 9085e9c3..95237adb 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1769,7 +1769,8 @@ "previous_counterparties": "Previous counterparties", "suggested": "Suggested", "common_templates": "Common templates", - "more_templates": "More templates ({count})" + "more_templates": "More templates ({count})", + "opens_editor_badge": "Opens journal editor" }, "tx_invoice_match": { "title_supplier": "Confirm supplier invoice match", diff --git a/messages/sv.json b/messages/sv.json index 251d29be..90d16b1b 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -1769,7 +1769,8 @@ "previous_counterparties": "Tidigare motparter", "suggested": "Föreslagna", "common_templates": "Vanliga mallar", - "more_templates": "Fler mallar ({count})" + "more_templates": "Fler mallar ({count})", + "opens_editor_badge": "Öppnar bokföringsformulär" }, "tx_invoice_match": { "title_supplier": "Bekräfta leverantörsfakturamatchning",