* feat(transactions): show all library templates in picker; fix PSD2 seed-row collision - Booking template picker: surface every active library template, not only the convertible 2-account shapes. Multi-leg/complex templates route to the manual journal editor pre-filled via applyTemplate instead of being hidden. Drop direction filtering for user templates (inferred direction is unreliable); the curated static catalog still respects it. Add an "Aktivera och bokfor" recovery toast for TX_CATEGORIZE_INVALID_ACCOUNT mirroring the existing ACCOUNTS_NOT_IN_CHART flow. CreateTemplateForm reflows to one card per line so trash buttons stop colliding on narrow screens. - cash_accounts.upsertFromPsd2: the seed_default_cash_account migration plants a manual (bank_connection_id IS NULL) row on the same ledger_account, so the first PSD2 sync's upsert on (company_id, bank_connection_id, external_uid) cannot match it (NULL != NULL) and falls through to INSERT, tripping the (company_id, ledger_account) UNIQUE constraint. Look up and promote the seed row in place first. - Tests: pin the TX_CATEGORIZE_INVALID_ACCOUNT error shape the recovery toast parses; cover applyTemplate on shapes the converter rejects (split-expense and all-'business'-typed). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(transactions): PR review — currency metadata, MRU ordering, observable promote, BAS validation - TransactionBookingDialog.buildInitialLinesFromTemplate: attach buildCurrencyMetadata to settlement lines for foreign-currency transactions so the journal entry retains the original currency, amount, and exchange_rate. Without this, non-SEK transactions routed through a non-convertible template were recorded in SEK only with no foreign-currency annotation. (Greptile #2) - TemplatePicker.handleSelectLibraryRaw: only bump the MRU after confirming the click will actually do something (i.e. converted OR a callback is wired). Future consumers that omit onPickLibraryTemplate would otherwise corrupt MRU ordering for templates the user never successfully applied. (Greptile #1) - cash_accounts.upsertFromPsd2 promote-seed: add .select('id') so a zero-row UPDATE is observable. If the seed row vanishes between the SELECT and UPDATE (concurrent ops), fall through to the normal upsert instead of silently returning success without persisting anything. (Greptile #3, compliance A.8.9) - transactions/page.tsx TX_CATEGORIZE_INVALID_ACCOUNT toast: validate accountNumber against /^\d{4}$/ before embedding in any fetch URL/body. Defense-in-depth against a malformed server error envelope. (compliance V8.2.1) - categorize route test: switch the not-in-chart fixture from '4535' (Inköp av varor från annat EU-land — reverse-charge) to '5420' (Programvaror) so the example doesn't imply a domestic override against an EU-reverse-charge account would be valid without its paired moms legs. (Swedish compliance review #3) Other review items deliberately not addressed in this PR: - Validate-on-save that every VAT-rated template has a 'vat' line — overrides existing CreateTemplateForm UX, separate PR. - vat_rate enum guard in applyTemplate — defensive; the editor dropdown only surfaces legal rates and is the only write path in production today. - Imbalance UI warning — already handled: JournalEntryForm computes isBalanced and gates submission; DB trigger check_journal_entry_balance enforces BFL 5 kap server-side. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
269 lines
8.6 KiB
TypeScript
269 lines
8.6 KiB
TypeScript
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import type { CashAccount, CashAccountSource } from '@/types'
|
|
import { createLogger } from '@/lib/logger'
|
|
|
|
const log = createLogger('cash-accounts')
|
|
|
|
/**
|
|
* Canonical read/write surface for cash_accounts.
|
|
*
|
|
* Replaces ad-hoc reads of bank_connections.accounts_data for routing decisions.
|
|
* UI panels that just display balances may still read accounts_data until the
|
|
* follow-up migration drops that column.
|
|
*
|
|
* All methods accept an authenticated SupabaseClient and rely on RLS for tenancy
|
|
* isolation. Defense-in-depth filter by company_id is applied regardless.
|
|
*/
|
|
|
|
export interface ListCashAccountsOptions {
|
|
enabledOnly?: boolean
|
|
}
|
|
|
|
export interface UpsertFromPsd2Input {
|
|
bank_connection_id: string
|
|
external_uid: string
|
|
currency: string
|
|
ledger_account: string
|
|
iban?: string | null
|
|
name?: string | null
|
|
balance?: number | null
|
|
balance_updated_at?: string | null
|
|
enabled?: boolean
|
|
}
|
|
|
|
export async function listForCompany(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
opts: ListCashAccountsOptions = {},
|
|
): Promise<CashAccount[]> {
|
|
let q = supabase
|
|
.from('cash_accounts')
|
|
.select('*')
|
|
.eq('company_id', companyId)
|
|
.order('is_primary', { ascending: false })
|
|
.order('ledger_account', { ascending: true })
|
|
|
|
if (opts.enabledOnly) q = q.eq('enabled', true)
|
|
|
|
const { data, error } = await q
|
|
if (error) {
|
|
log.error('listForCompany failed', { companyId, error: error.message })
|
|
return []
|
|
}
|
|
return (data ?? []) as CashAccount[]
|
|
}
|
|
|
|
/**
|
|
* Primary cash account for a company. Filters by currency when provided. Falls
|
|
* back to the global primary (`is_primary = true`) when no currency-specific
|
|
* match exists.
|
|
*
|
|
* Used by skattekonto-booking's __PRIMARY_SEK__ sentinel and by transfer-pairing
|
|
* to identify the company's default settlement account.
|
|
*/
|
|
export async function getPrimary(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
currency?: string,
|
|
): Promise<CashAccount | null> {
|
|
let q = supabase
|
|
.from('cash_accounts')
|
|
.select('*')
|
|
.eq('company_id', companyId)
|
|
.eq('is_primary', true)
|
|
.limit(1)
|
|
|
|
if (currency) q = q.eq('currency', currency.toUpperCase())
|
|
|
|
const { data, error } = await q.maybeSingle()
|
|
if (error) {
|
|
log.warn('getPrimary failed', { companyId, currency, error: error.message })
|
|
}
|
|
if (data) return data as CashAccount
|
|
|
|
if (currency) {
|
|
// Fall back to any-currency primary so a company without a SEK account still
|
|
// resolves the sentinel — rare but possible (manual cash-on-hand only).
|
|
const { data: anyPrimary } = await supabase
|
|
.from('cash_accounts')
|
|
.select('*')
|
|
.eq('company_id', companyId)
|
|
.eq('is_primary', true)
|
|
.maybeSingle()
|
|
if (anyPrimary) return anyPrimary as CashAccount
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
export async function findByIban(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
iban: string,
|
|
): Promise<CashAccount | null> {
|
|
if (!iban) return null
|
|
const { data, error } = await supabase
|
|
.from('cash_accounts')
|
|
.select('*')
|
|
.eq('company_id', companyId)
|
|
.eq('iban', iban)
|
|
.maybeSingle()
|
|
if (error) {
|
|
log.warn('findByIban failed', { companyId, iban, error: error.message })
|
|
return null
|
|
}
|
|
return (data as CashAccount | null) ?? null
|
|
}
|
|
|
|
/**
|
|
* Upsert a PSD2-sourced cash account during connection callback / sync. Keyed on
|
|
* (company_id, bank_connection_id, external_uid). When the row exists, balance
|
|
* and ledger_account are refreshed; the rest of the metadata stays put.
|
|
*
|
|
* Never sets is_primary — that's owned by the user via the AccountPicker or by
|
|
* the initial-backfill migration.
|
|
*/
|
|
export async function upsertFromPsd2(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
input: UpsertFromPsd2Input,
|
|
): Promise<void> {
|
|
const payload = {
|
|
company_id: companyId,
|
|
bank_connection_id: input.bank_connection_id,
|
|
external_uid: input.external_uid,
|
|
iban: input.iban ?? null,
|
|
name: input.name ?? null,
|
|
currency: input.currency.toUpperCase(),
|
|
ledger_account: input.ledger_account,
|
|
balance: input.balance ?? null,
|
|
balance_updated_at: input.balance_updated_at ?? null,
|
|
enabled: input.enabled ?? true,
|
|
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' })
|
|
|
|
if (error) {
|
|
log.error('upsertFromPsd2 failed', {
|
|
companyId,
|
|
bankConnectionId: input.bank_connection_id,
|
|
externalUid: input.external_uid,
|
|
error: error.message,
|
|
})
|
|
throw new Error(`cash_accounts upsert failed: ${error.message}`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Toggle a cash account's enabled flag. Used by the AccountPicker when a user
|
|
* opts in or out of syncing a particular PSD2 account.
|
|
*/
|
|
export async function setEnabled(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
cashAccountId: string,
|
|
enabled: boolean,
|
|
): Promise<void> {
|
|
const { error } = await supabase
|
|
.from('cash_accounts')
|
|
.update({ enabled })
|
|
.eq('company_id', companyId)
|
|
.eq('id', cashAccountId)
|
|
if (error) throw new Error(`cash_accounts setEnabled failed: ${error.message}`)
|
|
}
|
|
|
|
/**
|
|
* Remap a cash account to a different BAS ledger account. Triggers RLS + the
|
|
* (company_id, ledger_account) UNIQUE constraint — surface conflict errors so
|
|
* the UI can prompt the user to resolve.
|
|
*/
|
|
export async function setLedgerAccount(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
cashAccountId: string,
|
|
ledgerAccount: string,
|
|
): Promise<void> {
|
|
const { error } = await supabase
|
|
.from('cash_accounts')
|
|
.update({ ledger_account: ledgerAccount })
|
|
.eq('company_id', companyId)
|
|
.eq('id', cashAccountId)
|
|
if (error) throw new Error(`cash_accounts setLedgerAccount failed: ${error.message}`)
|
|
}
|
|
|
|
/**
|
|
* Mark a cash account as the primary for its company. Delegates to the
|
|
* `set_cash_account_primary` RPC so the clear-old-primary and set-new-primary
|
|
* updates happen inside a single transaction. The intermediate "no primary"
|
|
* state is never visible to concurrent readers — important because
|
|
* skattekonto-booking's __PRIMARY_SEK__ resolver runs through getPrimary() and
|
|
* would otherwise see null in the gap and mis-route the counter account.
|
|
*/
|
|
export async function setPrimary(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
cashAccountId: string,
|
|
): Promise<void> {
|
|
const { error } = await supabase.rpc('set_cash_account_primary', {
|
|
p_company_id: companyId,
|
|
p_cash_account_id: cashAccountId,
|
|
})
|
|
if (error) {
|
|
throw new Error(`cash_accounts setPrimary failed: ${error.message}`)
|
|
}
|
|
}
|