Per-account bank reconciliation + overdue/inbox/privacy fixes (#619)
* feat(reconciliation): scope bank reconciliation per cash account via transactions.cash_account_id A company with two same-currency cash accounts (e.g. checking 1930 + a savings account) saw every SEK transaction on every account, and the status card summed across both — reconciliation filtered transactions by CURRENCY while filtering GL lines by ACCOUNT (issue #604). Bind each bank transaction to the cash_accounts row it settled on: - New nullable transactions.cash_account_id FK (ON DELETE SET NULL — a bank transaction is räkenskapsinformation, BFL 7 kap, and must survive cash-account deletion) + a best-effort 4-pass backfill. - All reconciliation/transaction queries scope to the selected account with a NULL->currency fallback, so legacy/un-backfilled rows never disappear mid-backfill. - ingestTransactions stamps cash_account_id from the batch's settlementAccount; categorize + manualLink resolve and use it. - Bank leg now books to the transaction's actual settlement account via applySettlementAccount (no-op for 1930), so interest/fees on a savings/EUR account reconcile instead of mis-booking to 1930. - manualLink cross-checks the transaction's account and requires a voucher line on the selected account (no silent cross-account links). - BankReconciliationView: quick-book menu for any settlement account, in-flight request abort on account/date switch, 500-row truncation notice, per-account state reset. - pg-real coverage for the FK, all backfill passes, account-scoped query isolation, and cross-company isolation. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(supplier-invoices): stop marking paid invoices and credit notes as overdue update_overdue_supplier_invoices() (the daily pg_cron job) flipped every past-due 'registered'/'approved' row to 'overdue' without looking at the outstanding balance. Credit notes — created 'registered', remaining 0, due today — got flipped the next day, surfacing as "Förfallen" with "kvar att betala 0 kr"; so did any fully-paid invoice left in 'registered'/'approved'. Guard the cron on remaining_amount > 0.005 (the "fully paid" threshold used by the payment/match paths) and is_credit_note = false, and backfill the rows already mis-flagged (credit notes -> 'registered', paid -> 'paid' with paid_at stamped only when missing). pg-real coverage for the guarded function and the one-off backfill. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(invoice-inbox): refresh dokumentinkorg on realtime row changes The InvoiceInboxWorkspace only refetched on mount and on explicit in-component actions. When an inbox item was resolved out of band — the in-app agent sheet committing a staged create_supplier_invoice_from_inbox / book-direct op, the /pending page approving one, or another tab booking it — none of those paths called fetchItems(), so the booked underlag stayed in "Att göra" until a manual reload (issue #600). Add invoice_inbox_items to the supabase_realtime publication (mirrors the /pending fix in 20260520120100) and subscribe in the workspace, refetching the whole list on any change so derived status/counts/ordering stay authoritative. RLS scopes the channel to the user's company. fetchItems now preserves optimistic upload placeholders so a refetch firing mid-upload can't drop an in-flight row. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(privacy): disclose EU AI inference via Amazon Bedrock (eu-north-1) Update the privacy policy and DPA to state that AI inference, when AI features are enabled, runs inside the EU via Amazon Bedrock (eu-north-1, Stockholm) using Anthropic's Claude models — no transfer to a third country, prompts not retained after the call or used for model training. Add AWS as a subprocessor row and refresh the "last updated" dates. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(migrations): rename invoice_inbox_realtime to avoid version collision main's #617 shipped 20260605120000_transactions_original_description.sql — the same version this branch used for the inbox-realtime publication. The Supabase migration tracker keys on the numeric version, not the filename, so the preview branch failed with a duplicate-key error on supabase_migrations.schema_migrations (version 20260605120000 already exists). Rename to the unique version 20260605120500; the body (ALTER PUBLICATION) is order-independent. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(reconciliation): align run guard with status; harden filter interpolation Addresses PR review (greptile + compliance swarm): - The v1 and core bank/run routes rejected an unknown account uniformly, including the default '1930', while the status routes were lenient for '1930'. A company reconciling its primary SEK account without a cash_accounts row got 200 from status but 400 from run. Make run match status: '1930' falls back to currency-only scoping (cashAccountId undefined); non-default unknown accounts are still rejected. Adds a test. - /api/transactions accepts a user-supplied `currency` query param that was interpolated raw into a PostgREST .or() filter. Reject anything that isn't a 3-letter ISO code — RLS already scopes to the company, but an unsanitized value could otherwise malform/widen the filter. Assert currency/cashAccountId shape in scopeTransactionsToAccount as well. - categorize: log (instead of silently swallowing) a cash_accounts settlement-account lookup error, so a fall-back-to-1930 mis-booking is observable in the audit log. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(migrations): correct backfill UPDATE..FROM join; idempotent realtime publication Two SQL errors that only surface on real Postgres (CI pg-real + Supabase preview) — the unit suite mocks Supabase, so neither was caught locally. - Backfill pass (a): `UPDATE transactions t ... FROM journal_entry_lines jel JOIN cash_accounts ca ON ca.company_id = t.company_id` referenced the UPDATE target `t` inside the FROM join's ON clause, which Postgres rejects ("invalid reference to FROM-clause entry for table t"). Move the company match to WHERE; the JOIN now relates jel<->ca only. Semantics unchanged. - invoice_inbox_realtime: `ALTER PUBLICATION ... ADD TABLE` is not idempotent (SQLSTATE 42710 if the table is already a member). The earlier version-collision push partially applied it on the Supabase preview branch, so the re-apply errored. Guard with a pg_publication_tables existence check. Both statements validated against a real Postgres: the single-line tx binds, the two-bank-line transfer stays NULL, and the publication add runs twice cleanly. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(migrations): backfill pass (c) uses array_agg, not min(uuid) Postgres has no min() aggregate for uuid, so pass (c)'s min(id) raised "function min(uuid) does not exist" on apply (CI pg-real + Supabase). The HAVING count(*) = 1 already guarantees one row per group, so (array_agg(id))[1] returns that single id. Validated the full backfill (all four passes) and the overdue migration against a real Postgres: every pass binds / falls through as intended, and the overdue guard + backfill produce the right statuses. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(compliance): add RoPA entry for Amazon Bedrock AI inference (GDPR Art.30) The privacy policy now discloses AI inference (transaction categorization + document/receipt OCR) via Amazon Bedrock as a processing activity, but .compliance/ropa.yaml had no matching Art.30 record. Add it: opt-in consent basis, EU-region (eu-north-1) inference with no third-country transfer, prompts not retained or used for model training. Mirrors the privacy-page disclosure shipped in this PR. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
c6c86cded4
commit
953980c875
@@ -234,3 +234,46 @@ processing_activities:
|
||||
- no_financial_figures_in_body
|
||||
- tls_to_resend
|
||||
- rls_company_scoped
|
||||
|
||||
- id: ai.inference
|
||||
name: AI-inferens (kategorisering + dokumenttolkning) via Amazon Bedrock
|
||||
purpose: >-
|
||||
Föreslå bokföringskategori för banktransaktioner och tolka/extrahera
|
||||
uppladdade underlag (kvitton, leverantörsfakturor) med Anthropic
|
||||
Claude-modeller körda i Amazon Bedrock. Endast aktiv när tenanten
|
||||
uttryckligen aktiverat AI-funktioner — kärntjänsten (bokföring, fakturor,
|
||||
moms, rapporter) fungerar fullt ut utan AI.
|
||||
lawful_basis: art_6_1_a # consent — opt-in, AI features off by default
|
||||
special_category_basis: null
|
||||
controller: gnubok-tenant
|
||||
processor: aws-bedrock
|
||||
data_subjects:
|
||||
- business_owner
|
||||
- counterparty # namn/belopp på uppladdade underlag
|
||||
data_categories:
|
||||
- user.financial # transaktionsdata skickad för kategorisering
|
||||
- user.document # uppladdade kvitton/fakturor för OCR/extraktion
|
||||
recipients:
|
||||
- name: Amazon Web Services (Amazon Bedrock)
|
||||
country: EU
|
||||
role: processor
|
||||
international_transfers:
|
||||
applicable: false
|
||||
mechanism: null
|
||||
note: >-
|
||||
Inferens körs i AWS-regionen eu-north-1 (Stockholm) — ingen överföring
|
||||
till tredje land. AWS DPA + SCC + DPF-certifiering finns som
|
||||
skyddsmekanism. Prompter lagras ej hos Bedrock efter anropet och används
|
||||
ej till modellträning.
|
||||
retention:
|
||||
duration: none_at_processor
|
||||
basis: no_retention_bedrock # prompt not persisted by Bedrock post-inference
|
||||
stored_in:
|
||||
- ai_usage_tracking # usage metadata only (tokens/cost), no payload body
|
||||
security_measures:
|
||||
- opt_in_consent_required
|
||||
- eu_region_inference_eu_north_1
|
||||
- prompts_not_retained_after_inference
|
||||
- not_used_for_model_training
|
||||
- rls_company_scoped
|
||||
- tls_to_bedrock
|
||||
|
||||
@@ -19,7 +19,7 @@ export default function DPAPage() {
|
||||
Personuppgiftsbitradesavtal (DPA)
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Enligt GDPR Art. 28 | Senast uppdaterad: 2026-03-05
|
||||
Enligt GDPR Art. 28 | Senast uppdaterad: 2026-06-01
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -81,7 +81,9 @@ export default function DPAPage() {
|
||||
<li><strong>Oföränderlig bokföring:</strong> Bokförda verifikationer kan inte ändras eller
|
||||
raderas (databasutlösare)</li>
|
||||
<li><strong>Säkerhetskopior:</strong> Kontinuerliga databaskopior med point-in-time-recovery</li>
|
||||
<li><strong>EU-lagring:</strong> All primär datalagring sker i EU (eu-central-1)</li>
|
||||
<li><strong>EU-lagring och EU-inferens:</strong> All primär datalagring sker i EU
|
||||
(Supabase, eu-central-1). AI-inferens sker, när AI-funktioner är aktiverade, inom
|
||||
EU via Amazon Bedrock (eu-north-1, Stockholm) — ingen överföring till tredje land</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -18,7 +18,7 @@ export default function PrivacyPolicyPage() {
|
||||
Integritetspolicy
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Senast uppdaterad: 2026-05-28
|
||||
Senast uppdaterad: 2026-06-01
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -118,6 +118,21 @@ export default function PrivacyPolicyPage() {
|
||||
<td className="py-2 pr-4">EU</td>
|
||||
<td className="py-2">EU-baserad</td>
|
||||
</tr>
|
||||
<tr className="border-b">
|
||||
<td className="py-2 pr-4 font-medium">Amazon Web Services (AWS)</td>
|
||||
<td className="py-2 pr-4">
|
||||
AI-inferens (kategorisering samt dokument- och
|
||||
kvittotolkning) via Amazon Bedrock. Bearbetar bokföringsdata
|
||||
och uppladdade underlag — endast när AI-funktioner är
|
||||
aktiverade.
|
||||
</td>
|
||||
<td className="py-2 pr-4">EU (eu-north-1, Stockholm)</td>
|
||||
<td className="py-2">
|
||||
EU-baserad inferens — ingen tredjelandsöverföring. DPA, SCC
|
||||
och DPF-certifiering. Prompter lagras ej efter anropet och
|
||||
används ej till modellträning.
|
||||
</td>
|
||||
</tr>
|
||||
<tr className="border-b">
|
||||
<td className="py-2 pr-4 font-medium">Resend</td>
|
||||
<td className="py-2 pr-4">Transaktionell e-postleverans</td>
|
||||
@@ -139,8 +154,12 @@ export default function PrivacyPolicyPage() {
|
||||
</div>
|
||||
|
||||
<p className="mt-4 text-sm text-muted-foreground">
|
||||
AI-funktioner (Anthropic, OpenAI) kräver separat samtycke före aktivering.
|
||||
Data skickas först när du aktivt godkänner användningen.
|
||||
AI-funktioner är frivilliga och kräver separat samtycke före
|
||||
aktivering — data skickas först när du aktivt godkänner
|
||||
användningen. AI:t använder Anthropics Claude-modeller men körs
|
||||
inom Amazon Bedrock i EU (eu-north-1, Stockholm); datan lämnar
|
||||
alltså inte EU och delas inte med Anthropic. Kärntjänsten
|
||||
(bokföring, fakturor, moms och rapporter) fungerar fullt ut utan AI.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -154,6 +173,8 @@ export default function PrivacyPolicyPage() {
|
||||
Vissa underbiträden är baserade i USA. För dessa överföringar används EU-kommissionens
|
||||
standardavtalsklausuler (SCCs) som skyddsmekanism i enlighet med GDPR kapitel V.
|
||||
All primär datalagring (databas, filer) sker inom EU via Supabase (eu-central-1).
|
||||
Även AI-inferens sker inom EU (Amazon Bedrock, eu-north-1) och innebär ingen
|
||||
överföring till tredje land.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -24,9 +24,16 @@ export async function POST(request: Request) {
|
||||
|
||||
const validation = await validateBody(request, BankLinkSchema)
|
||||
if (!validation.success) return validation.response
|
||||
const { transaction_id, journal_entry_id } = validation.data
|
||||
const { transaction_id, journal_entry_id, account_number } = validation.data
|
||||
|
||||
const result = await manualLink(supabase, companyId, transaction_id, journal_entry_id, user.id)
|
||||
const result = await manualLink(
|
||||
supabase,
|
||||
companyId,
|
||||
transaction_id,
|
||||
journal_entry_id,
|
||||
user.id,
|
||||
account_number ?? '1930',
|
||||
)
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json({ error: result.error }, { status: 400 })
|
||||
|
||||
@@ -28,30 +28,33 @@ export async function POST(request: Request) {
|
||||
|
||||
const accountNumber = account_number ?? '1930'
|
||||
|
||||
// Defense-in-depth: only allow account numbers the company has registered as
|
||||
// a cash account. Applies uniformly including '1930' — the cash_accounts
|
||||
// backfill seeds 1930 for every company that had a SEK PSD2 account, and the
|
||||
// AccountPickerDialog seeds it for new companies on first connection.
|
||||
// Defense-in-depth: reject a non-default account the company hasn't
|
||||
// registered as a cash account. The default '1930' is exempt — when no
|
||||
// cash_accounts row exists it falls back to currency-only scoping
|
||||
// (cashAccountId undefined), so a company reconciling its primary SEK account
|
||||
// without a row behaves exactly as before this feature. Matches the status
|
||||
// endpoint, which is likewise lenient for '1930'.
|
||||
const { data: cashAccount } = await supabase
|
||||
.from('cash_accounts')
|
||||
.select('currency')
|
||||
.select('id, currency')
|
||||
.eq('company_id', companyId)
|
||||
.eq('ledger_account', accountNumber)
|
||||
.maybeSingle()
|
||||
|
||||
if (!cashAccount) {
|
||||
if (!cashAccount && accountNumber !== '1930') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Okänt kassakonto för det här företaget' },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
const currency = (cashAccount.currency as string | undefined) ?? 'SEK'
|
||||
const currency = (cashAccount?.currency as string | undefined) ?? 'SEK'
|
||||
|
||||
const result = await runReconciliation(supabase, companyId, user.id, {
|
||||
dateFrom: date_from,
|
||||
dateTo: date_to,
|
||||
accountNumber,
|
||||
currency,
|
||||
cashAccountId: cashAccount?.id as string | undefined,
|
||||
dryRun: dry_run ?? false,
|
||||
})
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ export async function GET(request: Request) {
|
||||
// produces nonsense.
|
||||
const { data: cashAccount } = await supabase
|
||||
.from('cash_accounts')
|
||||
.select('currency')
|
||||
.select('id, currency')
|
||||
.eq('company_id', companyId)
|
||||
.eq('ledger_account', accountNumber)
|
||||
.maybeSingle()
|
||||
@@ -36,6 +36,7 @@ export async function GET(request: Request) {
|
||||
}
|
||||
|
||||
const currency = (cashAccount?.currency as string | undefined) ?? 'SEK'
|
||||
const cashAccountId = cashAccount?.id as string | undefined
|
||||
|
||||
const status = await getReconciliationStatus(
|
||||
supabase,
|
||||
@@ -44,6 +45,7 @@ export async function GET(request: Request) {
|
||||
dateTo,
|
||||
accountNumber,
|
||||
currency,
|
||||
cashAccountId,
|
||||
)
|
||||
|
||||
return NextResponse.json({ data: status })
|
||||
|
||||
@@ -41,6 +41,19 @@ vi.mock('@/lib/bookkeeping/transaction-entries', () => ({
|
||||
const mockSaveUserMappingRule = vi.fn()
|
||||
vi.mock('@/lib/bookkeeping/mapping-engine', () => ({
|
||||
saveUserMappingRule: (...args: unknown[]) => mockSaveUserMappingRule(...args),
|
||||
// Mirror the real implementation: rewrite a 1930 bank leg to the settlement
|
||||
// account, no-op when the settlement account is 1930.
|
||||
applySettlementAccount: (
|
||||
result: { debit_account?: string; credit_account?: string },
|
||||
bankAccount: string,
|
||||
) =>
|
||||
bankAccount === '1930'
|
||||
? result
|
||||
: {
|
||||
...result,
|
||||
debit_account: result.debit_account === '1930' ? bankAccount : result.debit_account,
|
||||
credit_account: result.credit_account === '1930' ? bankAccount : result.credit_account,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/bookkeeping/counterparty-templates', () => ({
|
||||
|
||||
@@ -5,7 +5,7 @@ import { ensureInitialized } from '@/lib/init'
|
||||
import { buildMappingResultFromCategory } from '@/lib/bookkeeping/category-mapping'
|
||||
import { getTemplateById, buildMappingResultFromTemplate, validateTemplateForEntity } from '@/lib/bookkeeping/booking-templates'
|
||||
import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries'
|
||||
import { saveUserMappingRule } from '@/lib/bookkeeping/mapping-engine'
|
||||
import { saveUserMappingRule, applySettlementAccount } from '@/lib/bookkeeping/mapping-engine'
|
||||
import { upsertCounterpartyTemplate, buildMappingResultFromCounterpartyTemplate } from '@/lib/bookkeeping/counterparty-templates'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
@@ -226,6 +226,34 @@ export const POST = withRouteContext(
|
||||
)
|
||||
}
|
||||
|
||||
// Book the bank leg against the transaction's ACTUAL settlement account
|
||||
// rather than the hardcoded 1930 in the templates. Without this, interest
|
||||
// or fees that landed on a savings/EUR account mis-book to 1930 and the
|
||||
// real bank line never reconciles. applySettlementAccount only rewrites a
|
||||
// 1930 leg and is a no-op when the settlement account is 1930 — so legacy
|
||||
// rows with no cash_account_id behave exactly as before.
|
||||
let settlementAccount = '1930'
|
||||
if (transaction.cash_account_id) {
|
||||
const { data: txCashAccount, error: cashAccountError } = await supabase
|
||||
.from('cash_accounts')
|
||||
.select('ledger_account')
|
||||
.eq('id', transaction.cash_account_id)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
if (cashAccountError) {
|
||||
// Don't fail the booking — fall back to 1930 — but surface the lookup
|
||||
// failure so a silent mis-booking to the wrong bank leg stays auditable.
|
||||
txLog.warn('settlement-account lookup failed; defaulting to 1930', {
|
||||
cashAccountId: transaction.cash_account_id,
|
||||
error: cashAccountError.message,
|
||||
})
|
||||
}
|
||||
if (txCashAccount?.ledger_account) {
|
||||
settlementAccount = txCashAccount.ledger_account as string
|
||||
}
|
||||
}
|
||||
mappingResult = applySettlementAccount(mappingResult, settlementAccount)
|
||||
|
||||
txLog.info('mapping resolved', {
|
||||
debit: mappingResult.debit_account,
|
||||
credit: mappingResult.credit_account,
|
||||
|
||||
@@ -18,28 +18,39 @@ export async function GET(request: Request) {
|
||||
const unmatched = searchParams.get('unmatched') === 'true'
|
||||
const reconciled = searchParams.get('reconciled') === 'true'
|
||||
const currency = searchParams.get('currency') || undefined
|
||||
// currency is interpolated into the PostgREST .or() filter below, so reject
|
||||
// anything that isn't a 3-letter ISO code. RLS still scopes results to the
|
||||
// company, but an unsanitized value could otherwise malform or widen the
|
||||
// filter (PostgREST filter injection).
|
||||
if (currency && !/^[A-Z]{3}$/.test(currency)) {
|
||||
return NextResponse.json({ error: 'Ogiltig valutakod' }, { status: 400 })
|
||||
}
|
||||
const dateFrom = searchParams.get('date_from') || undefined
|
||||
const dateTo = searchParams.get('date_to') || undefined
|
||||
// When set, return only ignored rows — used by the reconciliation view to
|
||||
// surface a "Visa ignorerade" undo list. The default (no param) behaviour
|
||||
// continues to exclude ignored rows from unmatched results.
|
||||
const onlyIgnored = searchParams.get('only_ignored') === 'true'
|
||||
// account_number is accepted for API symmetry with the reconciliation status
|
||||
// endpoint; transactions don't carry a cash_account FK today (PSD2 account
|
||||
// identity is embedded in external_id), so we use it to derive a default
|
||||
// currency when the caller didn't supply one. Anything more precise needs
|
||||
// the cash_account_id backfill tracked as Tier 4.
|
||||
// account_number selects which cash account to scope to. We resolve it to a
|
||||
// cash_accounts.id (ledger_account is unique per company) and scope
|
||||
// transactions by that id, falling back to currency for legacy rows whose
|
||||
// cash_account_id hasn't been backfilled yet. This is what stops two
|
||||
// same-currency accounts from showing each other's transactions.
|
||||
const accountNumberParam = searchParams.get('account_number') || undefined
|
||||
|
||||
let derivedCurrency = currency
|
||||
if (!derivedCurrency && accountNumberParam) {
|
||||
let cashAccountId: string | undefined
|
||||
if (accountNumberParam) {
|
||||
const { data: cashAccount } = await supabase
|
||||
.from('cash_accounts')
|
||||
.select('currency')
|
||||
.select('id, currency')
|
||||
.eq('company_id', companyId)
|
||||
.eq('ledger_account', accountNumberParam)
|
||||
.maybeSingle()
|
||||
if (cashAccount?.currency) derivedCurrency = cashAccount.currency as string
|
||||
if (cashAccount) {
|
||||
cashAccountId = cashAccount.id as string
|
||||
if (!derivedCurrency && cashAccount.currency) derivedCurrency = cashAccount.currency as string
|
||||
}
|
||||
}
|
||||
|
||||
let query = supabase
|
||||
@@ -60,7 +71,17 @@ export async function GET(request: Request) {
|
||||
|
||||
if (onlyIgnored) query = query.eq('is_ignored', true)
|
||||
|
||||
if (derivedCurrency) query = query.eq('currency', derivedCurrency)
|
||||
// Scope to the selected cash account. With a resolved id, match that account
|
||||
// OR legacy NULL rows of the same currency (so nothing disappears mid-
|
||||
// backfill). With only a currency (no account), filter by currency. With
|
||||
// neither (e.g. the company-wide only_ignored recovery list), no scope.
|
||||
if (cashAccountId) {
|
||||
query = query.or(
|
||||
`cash_account_id.eq.${cashAccountId},and(cash_account_id.is.null,currency.eq.${derivedCurrency ?? 'SEK'})`,
|
||||
)
|
||||
} else if (derivedCurrency) {
|
||||
query = query.eq('currency', derivedCurrency)
|
||||
}
|
||||
if (dateFrom) query = query.gte('date', dateFrom)
|
||||
if (dateTo) query = query.lte('date', dateTo)
|
||||
|
||||
|
||||
@@ -129,6 +129,7 @@ describe('POST /reconciliation/bank/run', () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
cash_accounts: { data: { id: 'ca-1930', currency: 'SEK' }, error: null },
|
||||
}),
|
||||
)
|
||||
const res = await runPOST(
|
||||
@@ -146,7 +147,50 @@ describe('POST /reconciliation/bank/run', () => {
|
||||
expect.anything(),
|
||||
COMPANY_ID,
|
||||
'user-1',
|
||||
expect.objectContaining({ dryRun: false }),
|
||||
expect.objectContaining({ dryRun: false, accountNumber: '1930', cashAccountId: 'ca-1930' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects an unknown settlement account', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
// No matching cash_accounts row for the requested account_number.
|
||||
cash_accounts: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
const res = await runPOST(
|
||||
postRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/reconciliation/bank/run`, {
|
||||
account_number: '9999',
|
||||
}),
|
||||
{ params: Promise.resolve({ companyId: COMPANY_ID }) },
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
expect(runRecMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('runs the default 1930 account even without a cash_accounts row (currency fallback)', async () => {
|
||||
// Mirrors the status endpoint's leniency: the primary SEK account always
|
||||
// reconciles via the currency fallback, so a company without a 1930
|
||||
// cash_accounts row is not blocked from running reconciliation.
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
cash_accounts: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
const res = await runPOST(
|
||||
postRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/reconciliation/bank/run`, {
|
||||
account_number: '1930',
|
||||
}),
|
||||
{ params: Promise.resolve({ companyId: COMPANY_ID }) },
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
expect(runRecMock).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
COMPANY_ID,
|
||||
'user-1',
|
||||
expect.objectContaining({ accountNumber: '1930', cashAccountId: undefined }),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -154,6 +198,7 @@ describe('POST /reconciliation/bank/run', () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
cash_accounts: { data: { id: 'ca-1930', currency: 'SEK' }, error: null },
|
||||
}),
|
||||
)
|
||||
const res = await runPOST(
|
||||
@@ -207,6 +252,7 @@ describe('GET /reconciliation/bank/status', () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
cash_accounts: { data: { id: 'ca-1930', currency: 'SEK' }, error: null },
|
||||
}),
|
||||
)
|
||||
const res = await statusGET(
|
||||
@@ -223,6 +269,7 @@ describe('GET /reconciliation/bank/status', () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
cash_accounts: { data: { id: 'ca-1930', currency: 'SEK' }, error: null },
|
||||
}),
|
||||
)
|
||||
const res = await statusGET(
|
||||
|
||||
@@ -21,6 +21,10 @@ const RunRequest = z
|
||||
.object({
|
||||
date_from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||
date_to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||
// Settlement account (BAS code) to reconcile against, e.g. '1930' (SEK) or
|
||||
// '1932' (EUR). Defaults to '1930'. Required for multi-account companies to
|
||||
// reconcile anything other than their primary SEK account.
|
||||
account_number: z.string().regex(/^\d{4}$/).optional(),
|
||||
})
|
||||
// Bound the window so a key with no explicit range can't trigger an
|
||||
// unbounded join across years. 366 days covers a full räkenskapsår + a
|
||||
@@ -67,6 +71,7 @@ registerEndpoint({
|
||||
'Creating new journal entries — this only links bank transactions to existing GL lines. Matching to invoices — use `:match-invoice` or `:match-supplier-invoice` for explicit invoice payments.',
|
||||
pitfalls: [
|
||||
'date_from / date_to default to the company\'s full bank history if omitted. Specify a window for predictable performance.',
|
||||
'account_number defaults to 1930. Multi-account companies must pass the BAS code of the account they are reconciling (e.g. 1932 for a EUR account), or it silently reconciles 1930.',
|
||||
'Idempotency-Key is mandatory.',
|
||||
'matches.confidence is between 0 and 1; the matcher only applies matches above the internal threshold (currently ~0.85).',
|
||||
],
|
||||
@@ -110,11 +115,35 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
|
||||
}
|
||||
const body = parsed.data
|
||||
|
||||
// Resolve the settlement account to its cash account (currency + id) so the
|
||||
// matcher scopes transactions to this exact account, not every same-currency
|
||||
// account. The default '1930' is exempt from the existence check — it falls
|
||||
// back to currency-only scoping, matching the status endpoint and the
|
||||
// pre-feature behaviour. A non-default unknown account is rejected.
|
||||
const accountNumber = body.account_number ?? '1930'
|
||||
const { data: cashAccount } = await ctx.supabase
|
||||
.from('cash_accounts')
|
||||
.select('id, currency')
|
||||
.eq('company_id', ctx.companyId!)
|
||||
.eq('ledger_account', accountNumber)
|
||||
.maybeSingle()
|
||||
if (!cashAccount && accountNumber !== '1930') {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: {
|
||||
issues: [{ field: 'account_number', message: 'Okänt kassakonto för det här företaget' }],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
let result
|
||||
try {
|
||||
result = await runReconciliation(ctx.supabase, ctx.companyId!, ctx.userId, {
|
||||
dateFrom: body.date_from,
|
||||
dateTo: body.date_to,
|
||||
accountNumber,
|
||||
currency: (cashAccount?.currency as string | undefined) ?? 'SEK',
|
||||
cashAccountId: cashAccount?.id as string | undefined,
|
||||
dryRun: ctx.dryRun,
|
||||
})
|
||||
} catch (err) {
|
||||
|
||||
@@ -66,10 +66,13 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
|
||||
const Filters = z.object({
|
||||
date_from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||
date_to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||
// Settlement account (BAS code), e.g. '1930' / '1932'. Defaults to 1930.
|
||||
account_number: z.string().regex(/^\d{4}$/).optional(),
|
||||
})
|
||||
const parsed = Filters.safeParse({
|
||||
date_from: url.searchParams.get('date_from') ?? undefined,
|
||||
date_to: url.searchParams.get('date_to') ?? undefined,
|
||||
account_number: url.searchParams.get('account_number') ?? undefined,
|
||||
})
|
||||
if (!parsed.success) {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
@@ -83,12 +86,31 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
|
||||
})
|
||||
}
|
||||
|
||||
const accountNumber = parsed.data.account_number ?? '1930'
|
||||
const { data: cashAccount } = await ctx.supabase
|
||||
.from('cash_accounts')
|
||||
.select('id, currency')
|
||||
.eq('company_id', ctx.companyId!)
|
||||
.eq('ledger_account', accountNumber)
|
||||
.maybeSingle()
|
||||
if (!cashAccount && accountNumber !== '1930') {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: {
|
||||
issues: [{ field: 'account_number', message: 'Okänt kassakonto för det här företaget' }],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const status = await getReconciliationStatus(
|
||||
ctx.supabase,
|
||||
ctx.companyId!,
|
||||
parsed.data.date_from,
|
||||
parsed.data.date_to,
|
||||
accountNumber,
|
||||
(cashAccount?.currency as string | undefined) ?? 'SEK',
|
||||
cashAccount?.id as string | undefined,
|
||||
)
|
||||
return ok(status, { requestId: ctx.requestId })
|
||||
} catch (err) {
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { cn, formatCurrency } from '@/lib/utils'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
|
||||
import type { InvoiceExtractionResult } from '@/types'
|
||||
import BookDirectlyDialog from '@/components/extensions/general/BookDirectlyDialog'
|
||||
@@ -217,7 +218,19 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
|
||||
try {
|
||||
const res = await fetch('/api/extensions/ext/invoice-inbox/items?limit=500')
|
||||
const json = await res.json()
|
||||
if (res.ok) setItems(json.data?.items ?? [])
|
||||
if (res.ok) {
|
||||
const serverItems: InboxItem[] = json.data?.items ?? []
|
||||
// Preserve optimistic upload placeholders that haven't resolved to a
|
||||
// server row yet. A refetch can now fire mid-upload (a realtime event
|
||||
// from an unrelated booking), and a wholesale replace would briefly
|
||||
// drop the in-flight placeholder. Placeholders carry a `temp-` id that
|
||||
// never collides with a real row, and uploadFile() removes its own
|
||||
// placeholder before its fetchItems(), so this never duplicates.
|
||||
setItems((prev) => {
|
||||
const pending = prev.filter((it) => it.isPlaceholder)
|
||||
return pending.length > 0 ? [...pending, ...serverItems] : serverItems
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[invoice-inbox] fetchItems failed:', err)
|
||||
} finally {
|
||||
@@ -252,6 +265,33 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
|
||||
.catch(() => { /* keep 'accrual' default */ })
|
||||
}, [fetchItems, fetchInboxAddress])
|
||||
|
||||
// Realtime: refetch when any invoice_inbox_items row changes for this
|
||||
// company. The inbox is routinely resolved "out of band" — the in-app agent
|
||||
// sheet commits a staged create_supplier_invoice_from_inbox / book-direct
|
||||
// operation, the /pending page approves one, or another tab books it — and
|
||||
// none of those paths call this component's fetchItems(). Without this, a
|
||||
// booked underlag stayed in "Att göra" until a manual reload (issue #600).
|
||||
// RLS scopes the channel to the user's company, so we never receive other
|
||||
// tenants' events; we refetch the whole list (rather than patch in place) so
|
||||
// the derived status, count pills, and ordering stay authoritative. Mirrors
|
||||
// the /pending page subscription (app/(dashboard)/pending/page.tsx).
|
||||
useEffect(() => {
|
||||
const supabase = createClient()
|
||||
const channel = supabase
|
||||
.channel('invoice_inbox_items:list')
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{ event: '*', schema: 'public', table: 'invoice_inbox_items' },
|
||||
() => {
|
||||
fetchItems()
|
||||
}
|
||||
)
|
||||
.subscribe()
|
||||
return () => {
|
||||
void supabase.removeChannel(channel)
|
||||
}
|
||||
}, [fetchItems])
|
||||
|
||||
// Read the onboarding-dismissed flag from localStorage after mount
|
||||
// (SSR-safe — no window access during initial render).
|
||||
useEffect(() => {
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
@@ -37,6 +39,27 @@ const METHOD_LABELS: Record<string, string> = {
|
||||
manual: 'Manuell',
|
||||
}
|
||||
|
||||
// One-click bookings for transactions with no upstream invoice/voucher to match
|
||||
// against — the common "stuck on the unmatched list" cause (small ränteintäkter,
|
||||
// bankavgifter, valutakursdifferenser). These reuse the existing bank_finance
|
||||
// booking templates; the categorize endpoint rewrites the bank leg to the
|
||||
// transaction's actual settlement account, so they book correctly on ANY cash
|
||||
// account (1930, a savings account, a EUR account…), not just 1930.
|
||||
// `account` is the non-bank leg (revenue/cost) — the bank leg is the selected
|
||||
// account. Income templates apply to positive amounts, expense to negative.
|
||||
const QUICK_BOOK_TEMPLATES: {
|
||||
id: string
|
||||
label: string
|
||||
account: string
|
||||
direction: 'income' | 'expense'
|
||||
}[] = [
|
||||
{ id: 'bank_interest_income', label: 'ränteintäkt', account: '8310', direction: 'income' },
|
||||
{ id: 'bank_currency_gain', label: 'valutakursvinst', account: '3960', direction: 'income' },
|
||||
{ id: 'bank_fees', label: 'bankavgift', account: '6570', direction: 'expense' },
|
||||
{ id: 'bank_interest_expense', label: 'räntekostnad', account: '8410', direction: 'expense' },
|
||||
{ id: 'bank_currency_loss', label: 'valutakursförlust', account: '7960', direction: 'expense' },
|
||||
]
|
||||
|
||||
// ============================================================
|
||||
// Types
|
||||
// ============================================================
|
||||
@@ -282,6 +305,13 @@ export function BankReconciliationView() {
|
||||
const [showIgnored, setShowIgnored] = useState(true)
|
||||
const [ignoredTx, setIgnoredTx] = useState<UnmatchedTransaction[]>([])
|
||||
const [selectedMatch, setSelectedMatch] = useState<Record<string, string>>({})
|
||||
// True when the unmatched list hit the API's 500-row cap — surfaced so a long
|
||||
// date range doesn't silently hide rows and let the user think they're done.
|
||||
const [unmatchedTruncated, setUnmatchedTruncated] = useState(false)
|
||||
// Aborts the previous in-flight load when the account/date filters change, so
|
||||
// a slow stale response can't overwrite the freshly-selected account's data
|
||||
// (the intermittent "flips between accounts" bug).
|
||||
const fetchAbortRef = useRef<AbortController | null>(null)
|
||||
|
||||
const { dialogProps: confirmDialogProps, confirm } = useDestructiveConfirm()
|
||||
const { toast } = useToast()
|
||||
@@ -308,6 +338,14 @@ export function BankReconciliationView() {
|
||||
}, [])
|
||||
|
||||
const fetchAll = useCallback(async () => {
|
||||
// Cancel any in-flight load — it may be for a different account. Without
|
||||
// this, switching accounts quickly lets an older response land last and
|
||||
// overwrite the current account's data.
|
||||
fetchAbortRef.current?.abort()
|
||||
const controller = new AbortController()
|
||||
fetchAbortRef.current = controller
|
||||
const { signal } = controller
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
@@ -326,10 +364,10 @@ export function BankReconciliationView() {
|
||||
const reconciledQs = `?reconciled=true&${txParams}`
|
||||
|
||||
const [statusRes, glRes, unmatchedRes, matchedRes] = await Promise.all([
|
||||
fetch(`/api/reconciliation/bank/status${qs}`),
|
||||
fetch(`/api/reconciliation/bank/unmatched-entries${qs}`),
|
||||
fetch(`/api/transactions${unmatchedQs}`),
|
||||
fetch(`/api/transactions${reconciledQs}`),
|
||||
fetch(`/api/reconciliation/bank/status${qs}`, { signal }),
|
||||
fetch(`/api/reconciliation/bank/unmatched-entries${qs}`, { signal }),
|
||||
fetch(`/api/transactions${unmatchedQs}`, { signal }),
|
||||
fetch(`/api/transactions${reconciledQs}`, { signal }),
|
||||
])
|
||||
|
||||
const [statusData, glData, unmatchedData, matchedData] = await Promise.all([
|
||||
@@ -339,10 +377,15 @@ export function BankReconciliationView() {
|
||||
matchedRes.json(),
|
||||
])
|
||||
|
||||
// A newer load superseded this one while we awaited — discard these
|
||||
// stale results rather than clobber the current account's data.
|
||||
if (signal.aborted) return
|
||||
|
||||
if (statusData.data) setStatus(statusData.data)
|
||||
setGlLines(glData.data || [])
|
||||
setUnmatchedTx(unmatchedData.data || [])
|
||||
setMatchedTx(matchedData.data || [])
|
||||
setUnmatchedTruncated(Boolean(unmatchedData.has_more))
|
||||
|
||||
// Refresh the ignored list whenever the main lists refresh.
|
||||
// Deliberately NOT filtered by account or currency — if a user ignored
|
||||
@@ -351,17 +394,21 @@ export function BankReconciliationView() {
|
||||
// keeps the Återställ path reachable from any account selection. The
|
||||
// date filter is also dropped so old ignores stay visible.
|
||||
try {
|
||||
const ignoredRes = await fetch(`/api/transactions?unmatched=true&only_ignored=true`)
|
||||
const ignoredRes = await fetch(`/api/transactions?unmatched=true&only_ignored=true`, { signal })
|
||||
const ignoredData = await ignoredRes.json()
|
||||
setIgnoredTx(ignoredData.data || [])
|
||||
if (!signal.aborted) setIgnoredTx(ignoredData.data || [])
|
||||
} catch {
|
||||
setIgnoredTx([])
|
||||
if (!signal.aborted) setIgnoredTx([])
|
||||
}
|
||||
} catch (e) {
|
||||
// Aborts are expected when the user switches account/date quickly.
|
||||
if (signal.aborted || (e instanceof DOMException && e.name === 'AbortError')) return
|
||||
console.error('[reconciliation] fetchAll failed', e)
|
||||
setError('Kunde inte hämta avstämningsdata')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
// Only the latest load owns the spinner; a superseded load must not flip
|
||||
// it off while the fresh one is still running.
|
||||
if (!signal.aborted) setLoading(false)
|
||||
}
|
||||
}, [dateFrom, dateTo, accountNumber, accountCurrency])
|
||||
|
||||
@@ -369,6 +416,14 @@ export function BankReconciliationView() {
|
||||
fetchAll()
|
||||
}, [fetchAll])
|
||||
|
||||
// Reset transient per-account UI state when the selected account changes. A
|
||||
// verifikation pick or a dry-run preview computed for the previous account is
|
||||
// meaningless against the new one — and applying it would cross-link.
|
||||
useEffect(() => {
|
||||
setSelectedMatch({})
|
||||
setDryRunResults(null)
|
||||
}, [accountNumber])
|
||||
|
||||
const handleDryRun = async () => {
|
||||
setRunLoading(true)
|
||||
setDryRunResults(null)
|
||||
@@ -428,6 +483,7 @@ export function BankReconciliationView() {
|
||||
body: JSON.stringify({
|
||||
transaction_id: transactionId,
|
||||
journal_entry_id: journalEntryId,
|
||||
account_number: accountNumber,
|
||||
}),
|
||||
})
|
||||
const result = await res.json()
|
||||
@@ -470,13 +526,15 @@ export function BankReconciliationView() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline shortcut for the most common "stuck on the unmatched list" cause:
|
||||
* a small ränteintäkt that has no upstream voucher to match against. Calls
|
||||
* the standard categorize endpoint with the existing bank_interest_income
|
||||
* Inline one-click booking for an unmatched transaction with no upstream
|
||||
* voucher to match against (ränteintäkter, bankavgifter, valutakurs-
|
||||
* differenser). Calls the standard categorize endpoint with a bank_finance
|
||||
* template so the resulting verifikation is identical to the /transactions
|
||||
* flow — no parallel booking path.
|
||||
* flow — no parallel booking path. The categorize endpoint rewrites the bank
|
||||
* leg to the transaction's actual settlement account, so this is correct on
|
||||
* any cash account.
|
||||
*/
|
||||
const handleBookInterestIncome = async (transactionId: string) => {
|
||||
const handleQuickBook = async (transactionId: string, templateId: string) => {
|
||||
setActionLoading(transactionId)
|
||||
try {
|
||||
const res = await fetch(`/api/transactions/${transactionId}/categorize`, {
|
||||
@@ -484,13 +542,13 @@ export function BankReconciliationView() {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
is_business: true,
|
||||
template_id: 'bank_interest_income',
|
||||
template_id: templateId,
|
||||
confirm_no_match: true,
|
||||
}),
|
||||
})
|
||||
const result = await res.json()
|
||||
if (!res.ok || result.error) {
|
||||
setError(result.error?.message || result.error || 'Kunde inte bokföra ränteintäkten')
|
||||
setError(result.error?.message || result.error || 'Kunde inte bokföra transaktionen')
|
||||
return
|
||||
}
|
||||
if (result.journal_entry_error) {
|
||||
@@ -499,7 +557,7 @@ export function BankReconciliationView() {
|
||||
}
|
||||
await fetchAll()
|
||||
} catch {
|
||||
setError('Kunde inte bokföra ränteintäkten')
|
||||
setError('Kunde inte bokföra transaktionen')
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
@@ -773,14 +831,21 @@ export function BankReconciliationView() {
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{unmatchedTruncated && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Visar de senaste 500 transaktionerna — begränsa datumintervallet för att se fler.
|
||||
</p>
|
||||
)}
|
||||
<div className="space-y-3">
|
||||
{unmatchedTx.map((tx) => {
|
||||
// Piggy-bank shortcut hardcodes the 1930↔8310 ränteintäkt template,
|
||||
// so only offer it on a SEK account using 1930. On EUR (1932) or
|
||||
// other settlement accounts the booking would post the EUR amount
|
||||
// to the SEK cash account — silently wrong, hide it.
|
||||
const canBookInterest = tx.amount > 0 && accountNumber === '1930'
|
||||
const isPositive = tx.amount > 0
|
||||
// Quick-book options matching the transaction's direction. The
|
||||
// bank leg books to the SELECTED account (the categorize endpoint
|
||||
// rewrites it from the cash_account_id), so these are correct on
|
||||
// any account, not just 1930.
|
||||
const quickBooks = QUICK_BOOK_TEMPLATES.filter((t) =>
|
||||
isPositive ? t.direction === 'income' : t.direction === 'expense',
|
||||
)
|
||||
return (
|
||||
<div
|
||||
key={tx.id}
|
||||
@@ -826,19 +891,36 @@ export function BankReconciliationView() {
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-72">
|
||||
{canBookInterest && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleBookInterestIncome(tx.id)}
|
||||
disabled={actionLoading === tx.id}
|
||||
>
|
||||
<PiggyBank className="h-4 w-4" />
|
||||
<div className="flex flex-col">
|
||||
<span>Bokför som ränteintäkt</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
1930 mot 8310, ingen moms
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
{quickBooks.length > 0 && (
|
||||
<>
|
||||
<DropdownMenuLabel className="text-[11px] font-normal uppercase tracking-wider text-muted-foreground">
|
||||
Bokför direkt
|
||||
</DropdownMenuLabel>
|
||||
{quickBooks.map((t) => {
|
||||
// Read as "debit mot credit": income debits the
|
||||
// bank (selected account), credits revenue;
|
||||
// expense debits the cost account, credits bank.
|
||||
const legs = isPositive
|
||||
? `${accountNumber} mot ${t.account}`
|
||||
: `${t.account} mot ${accountNumber}`
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={t.id}
|
||||
onClick={() => handleQuickBook(tx.id, t.id)}
|
||||
disabled={actionLoading === tx.id}
|
||||
>
|
||||
<PiggyBank className="h-4 w-4" />
|
||||
<div className="flex flex-col">
|
||||
<span>Bokför som {t.label}</span>
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
{legs}
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleIgnore(tx)}
|
||||
|
||||
@@ -952,6 +952,13 @@ export const UpdateAccountSchema = z.object({
|
||||
export const BankLinkSchema = z.object({
|
||||
transaction_id: uuid,
|
||||
journal_entry_id: uuid,
|
||||
// Settlement account being reconciled. The voucher must have a line on this
|
||||
// account and the transaction must belong to it. Defaults to '1930' in the
|
||||
// route for back-compat.
|
||||
account_number: z
|
||||
.string()
|
||||
.regex(/^[0-9]{4}$/, 'Kontonummer måste vara 4 siffror')
|
||||
.optional(),
|
||||
})
|
||||
|
||||
export const BankUnlinkSchema = z.object({
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
stripBankNoise,
|
||||
type BookingTemplate,
|
||||
} from '../booking-templates'
|
||||
import { applySettlementAccount } from '../mapping-engine'
|
||||
|
||||
// ============================================================
|
||||
// Template Data Integrity
|
||||
@@ -801,3 +802,47 @@ describe('new and split templates', () => {
|
||||
expect(t!.deductibility_note_sv).toContain('46 kr/person')
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// applySettlementAccount — bank-leg routing for non-1930 accounts
|
||||
// ============================================================
|
||||
|
||||
describe('applySettlementAccount (bank-leg routing)', () => {
|
||||
it('routes the bank_interest_income debit leg to the transaction settlement account', () => {
|
||||
const template = getTemplateById('bank_interest_income')
|
||||
expect(template).toBeDefined()
|
||||
const tx = makeTransaction({ amount: 50, currency: 'SEK' })
|
||||
const base = buildMappingResultFromTemplate(template!, tx, 'enskild_firma')
|
||||
// Template hardcodes 1930 as the bank leg.
|
||||
expect(base.debit_account).toBe('1930')
|
||||
expect(base.credit_account).toBe('8310')
|
||||
|
||||
// Interest that landed on a savings account mapped to 1931 must debit 1931,
|
||||
// not 1930 — otherwise the real bank transaction never reconciles.
|
||||
const routed = applySettlementAccount(base, '1931')
|
||||
expect(routed.debit_account).toBe('1931')
|
||||
expect(routed.credit_account).toBe('8310')
|
||||
})
|
||||
|
||||
it('routes the bank_fees credit leg to the transaction settlement account', () => {
|
||||
const template = getTemplateById('bank_fees')
|
||||
expect(template).toBeDefined()
|
||||
const tx = makeTransaction({ amount: -29, currency: 'SEK' })
|
||||
const base = buildMappingResultFromTemplate(template!, tx, 'enskild_firma')
|
||||
expect(base.debit_account).toBe('6570')
|
||||
expect(base.credit_account).toBe('1930')
|
||||
|
||||
const routed = applySettlementAccount(base, '1931')
|
||||
expect(routed.debit_account).toBe('6570')
|
||||
expect(routed.credit_account).toBe('1931')
|
||||
})
|
||||
|
||||
it('is a no-op when the settlement account is 1930 (legacy/unresolved rows)', () => {
|
||||
const template = getTemplateById('bank_interest_income')!
|
||||
const tx = makeTransaction({ amount: 50, currency: 'SEK' })
|
||||
const base = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
|
||||
const routed = applySettlementAccount(base, '1930')
|
||||
expect(routed.debit_account).toBe('1930')
|
||||
expect(routed.credit_account).toBe('8310')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -374,7 +374,7 @@ function buildOwnAccountTransferResult(
|
||||
* This allows mapping rules and templates that don't explicitly set a bank account
|
||||
* to work correctly with secondary bank accounts (e.g. 1931).
|
||||
*/
|
||||
function applySettlementAccount(result: MappingResult, bankAccount: string): MappingResult {
|
||||
export function applySettlementAccount(result: MappingResult, bankAccount: string): MappingResult {
|
||||
if (bankAccount === '1930') return result
|
||||
return {
|
||||
...result,
|
||||
|
||||
@@ -440,39 +440,84 @@ describe('manualLink', () => {
|
||||
expect(result.error).toBe('Transaction is already linked to a journal entry')
|
||||
})
|
||||
|
||||
it('rejects when journal entry has no 1930 line', async () => {
|
||||
it('rejects when journal entry has no line on the selected account', async () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
const tx = makeTransaction({ id: 'tx-1', journal_entry_id: null })
|
||||
|
||||
// Transaction found
|
||||
// Transaction found (cash_account_id null → cross-check skipped)
|
||||
enqueue({ data: tx })
|
||||
// Journal entry found
|
||||
enqueue({ data: { id: 'je-1', user_id: 'company-1', status: 'posted' } })
|
||||
// No 1930 lines
|
||||
// No line on the selected account
|
||||
enqueue({ data: [] })
|
||||
|
||||
const result = await manualLink(supabase as never, 'company-1', 'tx-1', 'je-1', 'user-1')
|
||||
const result = await manualLink(supabase as never, 'company-1', 'tx-1', 'je-1', 'user-1', '1930')
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe('Verifikationen saknar rad på bankkonto (19xx)')
|
||||
expect(result.error).toBe('Verifikationen saknar rad på 1930')
|
||||
})
|
||||
|
||||
it('succeeds when all validations pass', async () => {
|
||||
it('rejects when the transaction belongs to a different cash account', async () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
const tx = makeTransaction({
|
||||
id: 'tx-1',
|
||||
journal_entry_id: null,
|
||||
cash_account_id: 'ca-1931',
|
||||
})
|
||||
|
||||
// Transaction found (bound to a cash account)
|
||||
enqueue({ data: tx })
|
||||
// Journal entry found + posted
|
||||
enqueue({ data: { id: 'je-1', user_id: 'company-1', status: 'posted' } })
|
||||
// Cross-check: this cash account maps to 1931, but we're reconciling 1930
|
||||
enqueue({ data: { ledger_account: '1931' } })
|
||||
|
||||
const result = await manualLink(supabase as never, 'company-1', 'tx-1', 'je-1', 'user-1', '1930')
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe('Transaktionen hör till 1931, inte 1930')
|
||||
})
|
||||
|
||||
it('succeeds when all validations pass (line on selected account)', async () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
const tx = makeTransaction({ id: 'tx-1', journal_entry_id: null })
|
||||
|
||||
// Transaction found
|
||||
// Transaction found (cash_account_id null → cross-check skipped)
|
||||
enqueue({ data: tx })
|
||||
// Journal entry found
|
||||
enqueue({ data: { id: 'je-1', user_id: 'company-1', status: 'posted' } })
|
||||
// 1930 line exists
|
||||
enqueue({ data: [{ debit_amount: 1000, credit_amount: 0 }] })
|
||||
// Line exists on the selected account
|
||||
enqueue({ data: [{ debit_amount: 1000, credit_amount: 0, account_number: '1930' }] })
|
||||
// No existing link
|
||||
enqueue({ data: null, error: null })
|
||||
// Update succeeds
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const result = await manualLink(supabase as never, 'company-1', 'tx-1', 'je-1', 'user-1')
|
||||
const result = await manualLink(supabase as never, 'company-1', 'tx-1', 'je-1', 'user-1', '1930')
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('succeeds for a bound transaction when the account matches', async () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
const tx = makeTransaction({
|
||||
id: 'tx-1',
|
||||
journal_entry_id: null,
|
||||
cash_account_id: 'ca-1930',
|
||||
})
|
||||
|
||||
enqueue({ data: tx })
|
||||
enqueue({ data: { id: 'je-1', user_id: 'company-1', status: 'posted' } })
|
||||
// Cross-check: cash account maps to the account being reconciled
|
||||
enqueue({ data: { ledger_account: '1930' } })
|
||||
// Line exists on 1930
|
||||
enqueue({ data: [{ debit_amount: 1000, credit_amount: 0, account_number: '1930' }] })
|
||||
// No existing link
|
||||
enqueue({ data: null, error: null })
|
||||
// Update succeeds
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const result = await manualLink(supabase as never, 'company-1', 'tx-1', 'je-1', 'user-1', '1930')
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
@@ -81,6 +81,45 @@ export interface ReconciliationOptions {
|
||||
* cash account so EUR transactions reconcile against 1932 etc.
|
||||
*/
|
||||
currency?: string
|
||||
/**
|
||||
* cash_accounts.id of the selected account. When set, transactions are
|
||||
* scoped to this exact account (with a currency fallback for legacy rows
|
||||
* whose cash_account_id hasn't been backfilled yet) instead of being matched
|
||||
* by currency alone — this is what stops two same-currency accounts (e.g.
|
||||
* checking 1930 + savings 1931) from pooling together. Omit for the legacy
|
||||
* currency-only behaviour.
|
||||
*/
|
||||
cashAccountId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope a transactions query builder to a single cash account, tolerating
|
||||
* legacy rows that predate the cash_account_id backfill:
|
||||
* cash_account_id = X OR (cash_account_id IS NULL AND currency = cur)
|
||||
* A bound row shows only on its own account; an unbound row falls back to
|
||||
* currency so nothing disappears mid-backfill. When cashAccountId is omitted
|
||||
* we keep the pure currency filter (back-compat).
|
||||
*/
|
||||
function scopeTransactionsToAccount<Q extends {
|
||||
or(filters: string): Q
|
||||
eq(column: string, value: string): Q
|
||||
}>(query: Q, cashAccountId: string | undefined, currency: string): Q {
|
||||
// Both values are interpolated into a raw PostgREST filter string below. They
|
||||
// are DB-derived in every caller (cash_accounts.id / .currency, or the 'SEK'
|
||||
// default), never raw user input — but assert their shape anyway so a future
|
||||
// caller cannot thread an unsanitized value through into the filter.
|
||||
if (!/^[A-Z]{3}$/.test(currency)) {
|
||||
throw new Error(`scopeTransactionsToAccount: invalid currency ${JSON.stringify(currency)}`)
|
||||
}
|
||||
if (cashAccountId) {
|
||||
if (!/^[0-9a-fA-F-]{36}$/.test(cashAccountId)) {
|
||||
throw new Error('scopeTransactionsToAccount: invalid cashAccountId (expected UUID)')
|
||||
}
|
||||
return query.or(
|
||||
`cash_account_id.eq.${cashAccountId},and(cash_account_id.is.null,currency.eq.${currency})`,
|
||||
)
|
||||
}
|
||||
return query.eq('currency', currency)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -175,19 +214,20 @@ export async function runReconciliation(
|
||||
dryRun = false,
|
||||
accountNumber = '1930',
|
||||
currency = 'SEK',
|
||||
cashAccountId,
|
||||
} = options
|
||||
|
||||
// Fetch unlinked GL lines via RPC
|
||||
const glLines = await fetchUnlinkedGLLines(supabase, companyId, accountNumber, dateFrom, dateTo)
|
||||
|
||||
// Fetch unmatched transactions
|
||||
// Fetch unmatched transactions, scoped to the selected cash account.
|
||||
let query = supabase
|
||||
.from('transactions')
|
||||
.select('*')
|
||||
.eq('company_id', companyId)
|
||||
.is('journal_entry_id', null)
|
||||
.eq('is_ignored', false)
|
||||
.eq('currency', currency)
|
||||
query = scopeTransactionsToAccount(query, cashAccountId, currency)
|
||||
|
||||
if (dateFrom) query = query.gte('date', dateFrom)
|
||||
if (dateTo) query = query.lte('date', dateTo)
|
||||
@@ -268,16 +308,19 @@ export async function getReconciliationStatus(
|
||||
dateTo?: string,
|
||||
bankAccount = '1930',
|
||||
currency: string = 'SEK',
|
||||
cashAccountId?: string,
|
||||
): Promise<ReconciliationStatus> {
|
||||
// Get all transactions in range. Ignored rows are pulled too so the totals
|
||||
// card still reflects what the bank actually moved, but they're excluded
|
||||
// from the "unmatched" count below — the user has explicitly said they
|
||||
// don't want them surfacing as something to reconcile.
|
||||
// Get all transactions in range, scoped to the selected cash account. Ignored
|
||||
// rows are pulled too so the totals card still reflects what the bank
|
||||
// actually moved, but they're excluded from the "unmatched" count below — the
|
||||
// user has explicitly said they don't want them surfacing as something to
|
||||
// reconcile. Scoping by cash account (not just currency) is what stops a
|
||||
// second same-currency account from inflating bankTotal here.
|
||||
let txQuery = supabase
|
||||
.from('transactions')
|
||||
.select('amount, journal_entry_id, reconciliation_method, is_ignored')
|
||||
.eq('company_id', companyId)
|
||||
.eq('currency', currency)
|
||||
txQuery = scopeTransactionsToAccount(txQuery, cashAccountId, currency)
|
||||
|
||||
if (dateFrom) txQuery = txQuery.gte('date', dateFrom)
|
||||
if (dateTo) txQuery = txQuery.lte('date', dateTo)
|
||||
@@ -397,7 +440,8 @@ export async function manualLink(
|
||||
companyId: string,
|
||||
transactionId: string,
|
||||
journalEntryId: string,
|
||||
userId: string
|
||||
userId: string,
|
||||
accountNumber: string = '1930',
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
// Fetch transaction
|
||||
const { data: tx, error: txError } = await supabase
|
||||
@@ -431,16 +475,36 @@ export async function manualLink(
|
||||
return { success: false, error: 'Journal entry is not posted' }
|
||||
}
|
||||
|
||||
// Check for a bank account line (19xx class accounts)
|
||||
// Defense-in-depth: the transaction must belong to the account being
|
||||
// reconciled. A transaction bound to 1930 must not be linked against a 1931
|
||||
// voucher even if the caller passes accountNumber=1931. Legacy rows with no
|
||||
// cash_account_id fall through (the UI list already gates them by currency).
|
||||
if (tx.cash_account_id) {
|
||||
const { data: txCa } = await supabase
|
||||
.from('cash_accounts')
|
||||
.select('ledger_account')
|
||||
.eq('id', tx.cash_account_id)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
if (txCa?.ledger_account && txCa.ledger_account !== accountNumber) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Transaktionen hör till ${txCa.ledger_account}, inte ${accountNumber}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for a bank account line on the SELECTED settlement account. The old
|
||||
// "any 19xx line" check let a 1930 transaction link to a voucher that only
|
||||
// touched 1931 — a cross-account link that silently hides a real imbalance.
|
||||
const { data: lines } = await supabase
|
||||
.from('journal_entry_lines')
|
||||
.select('debit_amount, credit_amount, account_number')
|
||||
.eq('journal_entry_id', journalEntryId)
|
||||
.gte('account_number', '1900')
|
||||
.lte('account_number', '1999')
|
||||
.eq('account_number', accountNumber)
|
||||
|
||||
if (!lines || lines.length === 0) {
|
||||
return { success: false, error: 'Verifikationen saknar rad på bankkonto (19xx)' }
|
||||
return { success: false, error: `Verifikationen saknar rad på ${accountNumber}` }
|
||||
}
|
||||
|
||||
// Check that no other transaction is already linked to this entry
|
||||
|
||||
@@ -39,6 +39,9 @@ vi.mock('@/lib/currency/riksbanken', () => ({
|
||||
|
||||
function createQueueMockSupabase() {
|
||||
const resultQueue: { data: unknown; error: unknown }[] = []
|
||||
// Captures .insert() payloads keyed by table, so tests can assert what was
|
||||
// written (e.g. cash_account_id stamping).
|
||||
const inserts: Record<string, unknown[]> = {}
|
||||
|
||||
/**
|
||||
* Push one or more results onto the queue.
|
||||
@@ -50,25 +53,31 @@ function createQueueMockSupabase() {
|
||||
}
|
||||
}
|
||||
|
||||
const buildChain = (): unknown => {
|
||||
const buildChain = (table: string): unknown => {
|
||||
const handler: ProxyHandler<object> = {
|
||||
get(_target, prop) {
|
||||
if (prop === 'then') {
|
||||
const next = resultQueue.shift() ?? { data: null, error: null }
|
||||
return (resolve: (v: unknown) => void) => resolve(next)
|
||||
}
|
||||
return (..._args: unknown[]) => buildChain()
|
||||
if (prop === 'insert') {
|
||||
return (payload: unknown) => {
|
||||
;(inserts[table] ??= []).push(payload)
|
||||
return buildChain(table)
|
||||
}
|
||||
}
|
||||
return (..._args: unknown[]) => buildChain(table)
|
||||
},
|
||||
}
|
||||
return new Proxy({}, handler)
|
||||
}
|
||||
|
||||
const supabase = {
|
||||
from: vi.fn().mockImplementation(() => buildChain()),
|
||||
rpc: vi.fn().mockImplementation(() => buildChain()),
|
||||
from: vi.fn().mockImplementation((table: string) => buildChain(table)),
|
||||
rpc: vi.fn().mockImplementation(() => buildChain('rpc')),
|
||||
}
|
||||
|
||||
return { supabase, enqueue }
|
||||
return { supabase, enqueue, inserts }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -154,6 +163,54 @@ describe('ingestTransactions', () => {
|
||||
expect(result.transaction_ids).toEqual(['tx-1'])
|
||||
})
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 1c. Stamps cash_account_id from the settlement account
|
||||
// -----------------------------------------------------------------------
|
||||
it('stamps cash_account_id on the insert when settlementAccount resolves', async () => {
|
||||
const { supabase, enqueue, inserts } = createQueueMockSupabase()
|
||||
const raw = makeRaw({ amount: -100 })
|
||||
const inserted = makeTransaction({ id: 'tx-1', external_id: raw.external_id })
|
||||
|
||||
enqueue({ data: [], error: null }) // booked map
|
||||
enqueue({ data: [], error: null }) // unbooked map
|
||||
enqueue({ data: [], error: null }) // supplier invoices
|
||||
enqueue({ data: [], error: null }) // external_id dedup
|
||||
enqueue({ data: { id: 'ca-1931' }, error: null }) // cash_accounts lookup
|
||||
enqueue({ data: inserted, error: null }) // insert
|
||||
mockEvaluateMappingRules.mockResolvedValue(makeMappingResult({ confidence: 0.5 }))
|
||||
|
||||
const result = await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, [raw], {
|
||||
settlementAccount: '1931',
|
||||
})
|
||||
|
||||
expect(result.imported).toBe(1)
|
||||
expect(supabase.from).toHaveBeenCalledWith('cash_accounts')
|
||||
const txInserts = inserts['transactions'] ?? []
|
||||
expect(txInserts).toHaveLength(1)
|
||||
expect((txInserts[0] as { cash_account_id?: string | null }).cash_account_id).toBe('ca-1931')
|
||||
})
|
||||
|
||||
it('inserts cash_account_id null when no settlementAccount is given', async () => {
|
||||
const { supabase, enqueue, inserts } = createQueueMockSupabase()
|
||||
const raw = makeRaw({ amount: -100 })
|
||||
const inserted = makeTransaction({ id: 'tx-1', external_id: raw.external_id })
|
||||
|
||||
enqueue({ data: [], error: null }) // booked map
|
||||
enqueue({ data: [], error: null }) // unbooked map
|
||||
enqueue({ data: [], error: null }) // supplier invoices
|
||||
enqueue({ data: [], error: null }) // external_id dedup
|
||||
// No cash_accounts lookup — settlementAccount omitted.
|
||||
enqueue({ data: inserted, error: null }) // insert
|
||||
mockEvaluateMappingRules.mockResolvedValue(makeMappingResult({ confidence: 0.5 }))
|
||||
|
||||
const result = await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, [raw])
|
||||
|
||||
expect(result.imported).toBe(1)
|
||||
expect(supabase.from).not.toHaveBeenCalledWith('cash_accounts')
|
||||
const txInserts = inserts['transactions'] ?? []
|
||||
expect((txInserts[0] as { cash_account_id?: string | null }).cash_account_id).toBeNull()
|
||||
})
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 2. Detects duplicates
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -213,6 +213,25 @@ export async function ingestTransactions(
|
||||
data?.forEach(r => existingExternalIds.add(r.external_id))
|
||||
}
|
||||
|
||||
// Resolve the cash account this batch settled on, once. Every row in one
|
||||
// ingest call shares a settlement account: enable-banking calls this per
|
||||
// account (settlementAccount = account.ledger_account), CSV import passes the
|
||||
// single account the user picked. cash_accounts.ledger_account is unique per
|
||||
// company, so this is a single-row lookup. Tolerate a miss — the row stays
|
||||
// unbound (cash_account_id NULL) and reconciliation falls back to currency.
|
||||
// We never auto-create a cash account here; that would race upsertFromPsd2's
|
||||
// seed-promotion logic in lib/cash-accounts/service.ts.
|
||||
let cashAccountId: string | null = null
|
||||
if (options?.settlementAccount) {
|
||||
const { data: ca } = await supabase
|
||||
.from('cash_accounts')
|
||||
.select('id')
|
||||
.eq('company_id', companyId)
|
||||
.eq('ledger_account', options.settlementAccount)
|
||||
.maybeSingle()
|
||||
cashAccountId = (ca?.id as string | undefined) ?? null
|
||||
}
|
||||
|
||||
// Track already-matched invoice IDs within this ingestion batch
|
||||
// to prevent suggesting the same invoice for multiple transactions
|
||||
const matchedInvoiceIds = new Set<string>()
|
||||
@@ -270,6 +289,7 @@ export async function ingestTransactions(
|
||||
company_id: companyId,
|
||||
user_id: userId,
|
||||
bank_connection_id: raw.bank_connection_id || null,
|
||||
cash_account_id: cashAccountId,
|
||||
external_id: raw.external_id,
|
||||
date: raw.date,
|
||||
description: description,
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
-- Migration: stream invoice_inbox_items changes via Supabase realtime
|
||||
--
|
||||
-- The dokumentinkorg (InvoiceInboxWorkspace) only refetched on mount and on
|
||||
-- explicit in-component actions. When an inbox item was resolved "out of
|
||||
-- band" — the in-app agent sheet committing a staged
|
||||
-- create_supplier_invoice_from_inbox / book-direct operation, the /pending
|
||||
-- approval page committing one, or another browser tab booking it — none of
|
||||
-- those paths call the component's fetchItems(). The booked underlag stayed
|
||||
-- in "Att göra" until the user manually reloaded the page (issue #600).
|
||||
-- Adding the table to supabase_realtime lets the browser subscribe via
|
||||
-- supabase.channel('postgres_changes') and refresh as the
|
||||
-- created_supplier_invoice_id / created_journal_entry_id FKs land.
|
||||
--
|
||||
-- This mirrors 20260520120100_pending_ops_realtime_publication.sql, which
|
||||
-- fixed the identical staleness on the /pending page.
|
||||
--
|
||||
-- RLS already restricts invoice_inbox_items to company members
|
||||
-- (20260223150836_invoice_inbox.sql, refreshed by the multi-tenant refactor
|
||||
-- in 20260330130000), and realtime respects the same row-level access — a
|
||||
-- member of company A only receives change events for rows where their RLS
|
||||
-- predicate evaluates true. Default replica identity (primary key) is
|
||||
-- sufficient: the client only refetches, never inspecting the old/new record.
|
||||
|
||||
-- Idempotent: ALTER PUBLICATION ... ADD TABLE errors if the table is already a
|
||||
-- member (SQLSTATE 42710). Guard so a re-apply is a no-op — e.g. a Supabase
|
||||
-- preview branch that partially applied an earlier revision of this migration
|
||||
-- (the ALTER ran, but the schema_migrations bookkeeping insert failed), leaving
|
||||
-- the table already in the publication on the next attempt.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_publication_tables
|
||||
WHERE pubname = 'supabase_realtime'
|
||||
AND schemaname = 'public'
|
||||
AND tablename = 'invoice_inbox_items'
|
||||
) THEN
|
||||
ALTER PUBLICATION supabase_realtime ADD TABLE public.invoice_inbox_items;
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -0,0 +1,40 @@
|
||||
-- Migration: transactions.cash_account_id
|
||||
--
|
||||
-- Binds each bank transaction to the specific cash account (cash_accounts row)
|
||||
-- it settled on. Until now `transactions` only carried `currency` +
|
||||
-- `bank_connection_id`, so the bank reconciliation (Rapporter → Bankavstämning)
|
||||
-- filtered transactions by CURRENCY while it filtered GL lines by ACCOUNT
|
||||
-- NUMBER. A company with two same-currency accounts (e.g. checking 1930 + a
|
||||
-- savings account on another SEK code) therefore saw every SEK transaction on
|
||||
-- every account, and the status card summed across both — the reported
|
||||
-- "shows 1930 even when you switch / sums all transactions" bug (issue #604).
|
||||
--
|
||||
-- This is the cash_account_id FK that app/api/transactions/route.ts already
|
||||
-- referred to as "the cash_account_id backfill tracked as Tier 4".
|
||||
--
|
||||
-- Nullable on purpose: legacy rows are backfilled best-effort in the paired
|
||||
-- 20260606120100_transactions_cash_account_id_backfill.sql migration, and any
|
||||
-- row that can't be resolved stays NULL. All reconciliation queries treat a
|
||||
-- NULL cash_account_id as "matches the selected account's currency" so nothing
|
||||
-- ever disappears from a report mid-backfill.
|
||||
--
|
||||
-- ON DELETE SET NULL — never CASCADE: a bank transaction is räkenskaps-
|
||||
-- information (BFL 7 kap) and must survive the deletion of a cash account.
|
||||
-- Never RESTRICT: cash accounts are user-disable-able (and occasionally
|
||||
-- deletable); the FK must not block that. In practice cash accounts are
|
||||
-- disabled, not hard-deleted, so SET NULL is an edge path that degrades into
|
||||
-- the same currency fallback as an un-backfilled row.
|
||||
|
||||
ALTER TABLE public.transactions
|
||||
ADD COLUMN IF NOT EXISTS cash_account_id UUID
|
||||
REFERENCES public.cash_accounts(id) ON DELETE SET NULL;
|
||||
|
||||
-- Partial index — in steady state most rows are bound, and the hot
|
||||
-- reconciliation query is "unmatched rows for account X". The non-NULL slice
|
||||
-- is exactly what that query needs and keeps the index small during the
|
||||
-- transition while most rows are still NULL.
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_cash_account
|
||||
ON public.transactions (company_id, cash_account_id)
|
||||
WHERE cash_account_id IS NOT NULL;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,85 @@
|
||||
-- Migration: backfill transactions.cash_account_id
|
||||
--
|
||||
-- Best-effort population of the cash_account_id added in
|
||||
-- 20260606120000_transactions_cash_account_id.sql. Four passes, in descending
|
||||
-- order of authority. Every pass touches ONLY rows that are still NULL, so the
|
||||
-- migration is idempotent and safe to re-run / resume after an interruption.
|
||||
--
|
||||
-- Rows that no pass resolves stay NULL — reconciliation queries fall back to
|
||||
-- currency matching for those, exactly as before this feature, so nothing
|
||||
-- disappears from any report.
|
||||
|
||||
-- Pass (a) — Booked rows via the voucher's bank line. Most authoritative:
|
||||
-- reflects where the money was actually booked. Map the journal entry's single
|
||||
-- 19xx line to a cash account by (company_id, ledger_account).
|
||||
--
|
||||
-- Vouchers with MORE than one bank-class (19xx) line are own-account transfers
|
||||
-- (e.g. 1930 → 1931) and are ambiguous — which leg is "this transaction"? We
|
||||
-- deliberately skip them (leave NULL) rather than guess wrong.
|
||||
UPDATE public.transactions t
|
||||
SET cash_account_id = ca.id
|
||||
FROM public.journal_entry_lines jel
|
||||
JOIN public.cash_accounts ca
|
||||
ON ca.ledger_account = jel.account_number
|
||||
WHERE t.cash_account_id IS NULL
|
||||
AND t.journal_entry_id IS NOT NULL
|
||||
AND jel.journal_entry_id = t.journal_entry_id
|
||||
-- Relate the cash account to the target by company in WHERE, not in the JOIN
|
||||
-- ON above: Postgres forbids referencing the UPDATE target (t) from a
|
||||
-- FROM-clause join condition ("invalid reference to FROM-clause entry for t").
|
||||
AND ca.company_id = t.company_id
|
||||
AND jel.account_number BETWEEN '1900' AND '1999'
|
||||
AND (
|
||||
SELECT count(*)
|
||||
FROM public.journal_entry_lines x
|
||||
WHERE x.journal_entry_id = t.journal_entry_id
|
||||
AND x.account_number BETWEEN '1900' AND '1999'
|
||||
) = 1;
|
||||
|
||||
-- Pass (b) — PSD2 rows via the owned-account identity embedded in external_id.
|
||||
-- Enable Banking writes external_id = 'eb_<iban|uid>_<txid>'
|
||||
-- (extensions/general/enable-banking/lib/sync.ts). Match the prefix against the
|
||||
-- cash account's iban or external_uid. The trailing '_' in the prefix makes
|
||||
-- this an exact account match (prevents 'SE111' matching 'SE1112…'), and
|
||||
-- starts_with avoids LIKE wildcard/metacharacter ambiguity entirely.
|
||||
UPDATE public.transactions t
|
||||
SET cash_account_id = ca.id
|
||||
FROM public.cash_accounts ca
|
||||
WHERE t.cash_account_id IS NULL
|
||||
AND ca.company_id = t.company_id
|
||||
AND t.external_id IS NOT NULL
|
||||
AND (
|
||||
(ca.iban IS NOT NULL
|
||||
AND starts_with(t.external_id, 'eb_' || ca.iban || '_'))
|
||||
OR
|
||||
(ca.external_uid IS NOT NULL
|
||||
AND starts_with(t.external_id, 'eb_' || ca.external_uid || '_'))
|
||||
);
|
||||
|
||||
-- Pass (c) — Single-account-of-currency fallback. If a company has exactly one
|
||||
-- ENABLED cash account in the row's currency, the row unambiguously belongs to
|
||||
-- it. Resolves the single-account majority (incl. CSV imports, which carry no
|
||||
-- account identity). Deliberately does NOT fire for the 2-same-currency case
|
||||
-- (HAVING count(*) = 1) — those rows stay NULL and rely on passes (a)/(b) or
|
||||
-- on the currency fallback at query time. We must not guess between checking
|
||||
-- and savings.
|
||||
WITH single_ca AS (
|
||||
-- (array_agg(id))[1], not min(id): Postgres has no min() aggregate for uuid.
|
||||
-- HAVING count(*) = 1 guarantees exactly one row per group, so the array has
|
||||
-- a single element and which one we pick is moot.
|
||||
SELECT company_id, currency, (array_agg(id))[1] AS cash_account_id
|
||||
FROM public.cash_accounts
|
||||
WHERE enabled = true
|
||||
GROUP BY company_id, currency
|
||||
HAVING count(*) = 1
|
||||
)
|
||||
UPDATE public.transactions t
|
||||
SET cash_account_id = s.cash_account_id
|
||||
FROM single_ca s
|
||||
WHERE t.cash_account_id IS NULL
|
||||
AND s.company_id = t.company_id
|
||||
AND s.currency = t.currency;
|
||||
|
||||
-- Pass (d) — anything still NULL is left as-is (query-time currency fallback).
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
-- Migration: supplier_invoice_overdue_skip_paid_and_credit_notes
|
||||
--
|
||||
-- Fix: supplier invoices showing "Förfallen" (overdue) even though
|
||||
-- "kvar att betala" (remaining_amount) is 0 kr.
|
||||
--
|
||||
-- Root cause: update_overdue_supplier_invoices() (the daily pg_cron job from
|
||||
-- 20260303145744_supplier_invoice_overdue_cron.sql) flipped EVERY row past its
|
||||
-- due_date whose status was 'registered'/'approved' to 'overdue', without ever
|
||||
-- looking at the outstanding balance.
|
||||
--
|
||||
-- Credit notes (is_credit_note = true) are created with status='registered',
|
||||
-- remaining_amount=0 and due_date=today (see the supplier-invoice credit
|
||||
-- routes). A credit note is not a payable — there is nothing to pay and nothing
|
||||
-- to fall due — but because it sits in 'registered' with a due_date of today,
|
||||
-- the cron turned it 'overdue' the very next day. The same happens to any
|
||||
-- regular invoice that was fully paid but left in 'registered'/'approved'.
|
||||
--
|
||||
-- Two parts:
|
||||
-- 1. Guard the cron so a row with no outstanding balance, or a credit note,
|
||||
-- is never marked overdue.
|
||||
-- 2. Backfill the rows already mis-flagged.
|
||||
|
||||
-- 1. Guarded cron function ---------------------------------------------------
|
||||
-- CREATE OR REPLACE rewrites the whole definition, so re-declare the
|
||||
-- search_path that 20260304191528_set_search_path_on_functions.sql pinned.
|
||||
CREATE OR REPLACE FUNCTION public.update_overdue_supplier_invoices()
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
UPDATE supplier_invoices
|
||||
SET status = 'overdue',
|
||||
updated_at = NOW()
|
||||
WHERE due_date < CURRENT_DATE
|
||||
AND status IN ('registered', 'approved')
|
||||
-- Nothing left to pay -> cannot be overdue. 0.005 mirrors the
|
||||
-- "fully paid" threshold used by the payment/match paths.
|
||||
AND remaining_amount > 0.005
|
||||
-- Credit notes (kreditfakturor) are not payables.
|
||||
AND COALESCE(is_credit_note, false) = false;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- 2. Backfill rows already mis-flagged by the old function -------------------
|
||||
-- Credit notes wrongly flipped to 'overdue' return to 'registered' (their
|
||||
-- resting state — there is no payment flow that advances a credit note).
|
||||
UPDATE public.supplier_invoices
|
||||
SET status = 'registered',
|
||||
updated_at = NOW()
|
||||
WHERE status = 'overdue'
|
||||
AND COALESCE(is_credit_note, false) = true;
|
||||
|
||||
-- Regular invoices that are fully paid but stuck on 'overdue' are 'paid'.
|
||||
-- paid_at is only stamped when it was missing, so a real payment timestamp is
|
||||
-- never overwritten.
|
||||
UPDATE public.supplier_invoices
|
||||
SET status = 'paid',
|
||||
paid_at = COALESCE(paid_at, NOW()),
|
||||
updated_at = NOW()
|
||||
WHERE status = 'overdue'
|
||||
AND COALESCE(is_credit_note, false) = false
|
||||
AND remaining_amount <= 0.005;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -172,6 +172,7 @@ export function makeTransaction(overrides: Partial<Transaction> = {}): Transacti
|
||||
user_id: 'user-1',
|
||||
company_id: 'company-1',
|
||||
bank_connection_id: null,
|
||||
cash_account_id: null,
|
||||
external_id: null,
|
||||
date: '2024-06-15',
|
||||
description: 'ICA MAXI STOCKHOLM',
|
||||
|
||||
@@ -89,6 +89,78 @@ export async function seedCompany(overrides: { isClosed?: boolean } = {}): Promi
|
||||
return { userId, companyId, fiscalPeriodId }
|
||||
}
|
||||
|
||||
// Insert a cash account (cash_accounts row). ledger_account is unique per
|
||||
// company; is_primary defaults false to avoid the one-primary partial index.
|
||||
export async function insertCashAccount(params: {
|
||||
companyId: string
|
||||
ledgerAccount: string
|
||||
currency?: string
|
||||
iban?: string | null
|
||||
externalUid?: string | null
|
||||
isPrimary?: boolean
|
||||
enabled?: boolean
|
||||
source?: 'enable_banking' | 'manual' | 'sie_import'
|
||||
bankConnectionId?: string | null
|
||||
}): Promise<string> {
|
||||
const id = randomUUID()
|
||||
await getPool().query(
|
||||
`INSERT INTO public.cash_accounts
|
||||
(id, company_id, ledger_account, currency, iban, external_uid,
|
||||
is_primary, enabled, source, bank_connection_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
|
||||
[
|
||||
id,
|
||||
params.companyId,
|
||||
params.ledgerAccount,
|
||||
params.currency ?? 'SEK',
|
||||
params.iban ?? null,
|
||||
params.externalUid ?? null,
|
||||
params.isPrimary ?? false,
|
||||
params.enabled ?? true,
|
||||
params.source ?? 'manual',
|
||||
params.bankConnectionId ?? null,
|
||||
],
|
||||
)
|
||||
return id
|
||||
}
|
||||
|
||||
// Insert a bank transaction row. cashAccountId/journalEntryId default null so
|
||||
// tests can exercise the backfill and the NULL-fallback scoping.
|
||||
export async function insertTransaction(params: {
|
||||
companyId: string
|
||||
userId: string
|
||||
currency?: string
|
||||
amount?: number
|
||||
date?: string
|
||||
description?: string
|
||||
externalId?: string | null
|
||||
journalEntryId?: string | null
|
||||
cashAccountId?: string | null
|
||||
isIgnored?: boolean
|
||||
}): Promise<string> {
|
||||
const id = randomUUID()
|
||||
await getPool().query(
|
||||
`INSERT INTO public.transactions
|
||||
(id, company_id, user_id, currency, amount, date, description,
|
||||
external_id, journal_entry_id, cash_account_id, is_ignored, category)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, 'uncategorized')`,
|
||||
[
|
||||
id,
|
||||
params.companyId,
|
||||
params.userId,
|
||||
params.currency ?? 'SEK',
|
||||
params.amount ?? -100,
|
||||
params.date ?? '2026-06-01',
|
||||
params.description ?? 'Test tx',
|
||||
params.externalId ?? null,
|
||||
params.journalEntryId ?? null,
|
||||
params.cashAccountId ?? null,
|
||||
params.isIgnored ?? false,
|
||||
],
|
||||
)
|
||||
return id
|
||||
}
|
||||
|
||||
// Insert a draft journal entry and return its id. Uses a placeholder
|
||||
// voucher_number=0 which commit_journal_entry() will overwrite on commit.
|
||||
export async function insertDraftJournalEntry(params: {
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { seedCompany } from '@/tests/pg/fixtures'
|
||||
import { getPool } from '@/tests/pg/setup'
|
||||
|
||||
/**
|
||||
* pg-real coverage for update_overdue_supplier_invoices() and its fix in
|
||||
* 20260607120000_supplier_invoice_overdue_skip_paid_and_credit_notes.sql.
|
||||
*
|
||||
* Regression: supplier invoices (and credit notes) with remaining_amount = 0
|
||||
* were being flipped to 'overdue' by the daily cron — surfacing in the UI as
|
||||
* "Förfallen" with "kvar att betala 0 kr". Credit notes are the systematic
|
||||
* case: they are created status='registered', remaining_amount=0,
|
||||
* due_date=today, so the cron caught them the next day.
|
||||
*
|
||||
* Locks in:
|
||||
* - The function still marks a genuinely-unpaid, past-due invoice overdue.
|
||||
* - It NEVER marks a credit note overdue.
|
||||
* - It NEVER marks a fully-paid (remaining ~= 0) invoice overdue.
|
||||
* - Not-yet-due invoices are untouched.
|
||||
* - The one-off backfill corrects rows already mis-flagged.
|
||||
*
|
||||
* Tests write through the superuser pool (RLS bypassed); the function is
|
||||
* SECURITY DEFINER. Dates are pinned far in the past/future so the result is
|
||||
* independent of the wall-clock date the suite runs on.
|
||||
*/
|
||||
|
||||
const PAST = '2000-01-01'
|
||||
const FUTURE = '2999-01-01'
|
||||
|
||||
const MIGRATION_SQL = readFileSync(
|
||||
join(
|
||||
process.cwd(),
|
||||
'supabase/migrations/20260607120000_supplier_invoice_overdue_skip_paid_and_credit_notes.sql',
|
||||
),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
async function insertSupplier(userId: string, companyId: string): Promise<string> {
|
||||
const id = randomUUID()
|
||||
await getPool().query(
|
||||
`INSERT INTO public.suppliers
|
||||
(id, user_id, company_id, name, supplier_type, country, default_payment_terms, default_currency)
|
||||
VALUES ($1, $2, $3, 'Leverantör AB', 'swedish_business', 'SE', 30, 'SEK')`,
|
||||
[id, userId, companyId],
|
||||
)
|
||||
return id
|
||||
}
|
||||
|
||||
async function insertSupplierInvoice(params: {
|
||||
userId: string
|
||||
companyId: string
|
||||
supplierId: string
|
||||
status: string
|
||||
dueDate: string
|
||||
total: number
|
||||
remaining: number
|
||||
paidAmount?: number
|
||||
isCreditNote?: boolean
|
||||
paidAt?: string | null
|
||||
}): Promise<string> {
|
||||
const id = randomUUID()
|
||||
const arrivalNumber = (Date.now() % 1_000_000_000) + Math.floor(Math.random() * 100_000)
|
||||
await getPool().query(
|
||||
`INSERT INTO public.supplier_invoices
|
||||
(id, user_id, company_id, supplier_id, arrival_number, supplier_invoice_number,
|
||||
invoice_date, due_date, received_date, status, currency,
|
||||
subtotal, vat_amount, total, paid_amount, remaining_amount, paid_at,
|
||||
vat_treatment, reverse_charge, is_credit_note)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $7, $7, $8, 'SEK',
|
||||
$9, 0, $9, $10, $11, $12, 'standard_25', false, $13)`,
|
||||
[
|
||||
id,
|
||||
params.userId,
|
||||
params.companyId,
|
||||
params.supplierId,
|
||||
arrivalNumber,
|
||||
`LF-${arrivalNumber}`,
|
||||
params.dueDate,
|
||||
params.status,
|
||||
params.total,
|
||||
params.paidAmount ?? 0,
|
||||
params.remaining,
|
||||
params.paidAt ?? null,
|
||||
params.isCreditNote ?? false,
|
||||
],
|
||||
)
|
||||
return id
|
||||
}
|
||||
|
||||
async function statusOf(id: string): Promise<string> {
|
||||
const { rows } = await getPool().query(
|
||||
'SELECT status FROM public.supplier_invoices WHERE id = $1',
|
||||
[id],
|
||||
)
|
||||
return rows[0].status
|
||||
}
|
||||
|
||||
describe('update_overdue_supplier_invoices()', () => {
|
||||
it('marks a genuinely-unpaid, past-due invoice overdue', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const supplierId = await insertSupplier(userId, companyId)
|
||||
const id = await insertSupplierInvoice({
|
||||
userId, companyId, supplierId,
|
||||
status: 'approved', dueDate: PAST, total: 1000, remaining: 1000,
|
||||
})
|
||||
|
||||
await getPool().query('SELECT public.update_overdue_supplier_invoices()')
|
||||
|
||||
expect(await statusOf(id)).toBe('overdue')
|
||||
})
|
||||
|
||||
it('never marks a credit note overdue (remaining 0, status registered)', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const supplierId = await insertSupplier(userId, companyId)
|
||||
// Mirrors how the credit routes create a credit note: registered, fully
|
||||
// settled (remaining 0), due today (here: long past).
|
||||
const id = await insertSupplierInvoice({
|
||||
userId, companyId, supplierId,
|
||||
status: 'registered', dueDate: PAST, total: 1000, remaining: 0,
|
||||
isCreditNote: true,
|
||||
})
|
||||
|
||||
await getPool().query('SELECT public.update_overdue_supplier_invoices()')
|
||||
|
||||
expect(await statusOf(id)).toBe('registered')
|
||||
})
|
||||
|
||||
it('never marks a fully-paid (remaining ~0) invoice overdue', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const supplierId = await insertSupplier(userId, companyId)
|
||||
const id = await insertSupplierInvoice({
|
||||
userId, companyId, supplierId,
|
||||
status: 'approved', dueDate: PAST, total: 1000, remaining: 0, paidAmount: 1000,
|
||||
})
|
||||
|
||||
await getPool().query('SELECT public.update_overdue_supplier_invoices()')
|
||||
|
||||
expect(await statusOf(id)).toBe('approved')
|
||||
})
|
||||
|
||||
it('leaves not-yet-due invoices untouched', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const supplierId = await insertSupplier(userId, companyId)
|
||||
const id = await insertSupplierInvoice({
|
||||
userId, companyId, supplierId,
|
||||
status: 'approved', dueDate: FUTURE, total: 1000, remaining: 1000,
|
||||
})
|
||||
|
||||
await getPool().query('SELECT public.update_overdue_supplier_invoices()')
|
||||
|
||||
expect(await statusOf(id)).toBe('approved')
|
||||
})
|
||||
})
|
||||
|
||||
describe('overdue backfill (migration 20260607120000)', () => {
|
||||
it('reverts a credit note wrongly stuck on overdue back to registered', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const supplierId = await insertSupplier(userId, companyId)
|
||||
const id = await insertSupplierInvoice({
|
||||
userId, companyId, supplierId,
|
||||
status: 'overdue', dueDate: PAST, total: 1000, remaining: 0, isCreditNote: true,
|
||||
})
|
||||
|
||||
// Idempotent: re-running the migration only touches status='overdue' rows.
|
||||
await getPool().query(MIGRATION_SQL)
|
||||
|
||||
expect(await statusOf(id)).toBe('registered')
|
||||
})
|
||||
|
||||
it('marks a fully-paid invoice stuck on overdue as paid (and stamps paid_at)', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const supplierId = await insertSupplier(userId, companyId)
|
||||
const id = await insertSupplierInvoice({
|
||||
userId, companyId, supplierId,
|
||||
status: 'overdue', dueDate: PAST, total: 1000, remaining: 0, paidAmount: 1000,
|
||||
paidAt: null,
|
||||
})
|
||||
|
||||
await getPool().query(MIGRATION_SQL)
|
||||
|
||||
const { rows } = await getPool().query(
|
||||
'SELECT status, paid_at FROM public.supplier_invoices WHERE id = $1',
|
||||
[id],
|
||||
)
|
||||
expect(rows[0].status).toBe('paid')
|
||||
expect(rows[0].paid_at).not.toBeNull()
|
||||
})
|
||||
|
||||
it('leaves a genuinely-overdue unpaid invoice on overdue', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const supplierId = await insertSupplier(userId, companyId)
|
||||
const id = await insertSupplierInvoice({
|
||||
userId, companyId, supplierId,
|
||||
status: 'overdue', dueDate: PAST, total: 1000, remaining: 1000,
|
||||
})
|
||||
|
||||
await getPool().query(MIGRATION_SQL)
|
||||
|
||||
expect(await statusOf(id)).toBe('overdue')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,253 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
seedCompany,
|
||||
insertCashAccount,
|
||||
insertTransaction,
|
||||
insertDraftJournalEntry,
|
||||
} from '@/tests/pg/fixtures'
|
||||
import { getPool } from '@/tests/pg/setup'
|
||||
|
||||
/**
|
||||
* pg-real coverage for transactions.cash_account_id
|
||||
* (20260606120000_transactions_cash_account_id.sql + the paired backfill
|
||||
* 20260606120100_..._backfill.sql).
|
||||
*
|
||||
* Locks in:
|
||||
* - FK ON DELETE SET NULL — deleting a cash account never deletes the bank
|
||||
* transaction (räkenskapsinformation, BFL 7 kap), only nulls the link.
|
||||
* - The four backfill passes (booked single 19xx line, PSD2 external_id,
|
||||
* single-account-of-currency, leave NULL) and their guards.
|
||||
* - The headline isolation: a query scoped to one account (with the
|
||||
* NULL→currency fallback) never returns another same-currency account's
|
||||
* rows. This is the regression test for "shows 1930 even when you switch /
|
||||
* sums all transactions".
|
||||
* - Cross-company isolation of the backfill.
|
||||
*/
|
||||
|
||||
// Run the real backfill migration SQL (idempotent: only touches NULL rows) so
|
||||
// the test exercises exactly what ships, not a re-implementation.
|
||||
const BACKFILL_SQL = readFileSync(
|
||||
join(
|
||||
process.cwd(),
|
||||
'supabase/migrations/20260606120100_transactions_cash_account_id_backfill.sql',
|
||||
),
|
||||
'utf8',
|
||||
)
|
||||
async function runBackfill(): Promise<void> {
|
||||
await getPool().query(BACKFILL_SQL)
|
||||
}
|
||||
|
||||
// Insert a journal entry (draft) with the given bank-class line account
|
||||
// numbers. One line per account at amount 100 (debit). Balance isn't required
|
||||
// for a draft entry — the balance trigger only fires on draft→posted.
|
||||
async function insertEntryWithBankLines(params: {
|
||||
userId: string
|
||||
companyId: string
|
||||
fiscalPeriodId: string
|
||||
bankAccounts: string[]
|
||||
}): Promise<string> {
|
||||
const jeId = await insertDraftJournalEntry({
|
||||
userId: params.userId,
|
||||
companyId: params.companyId,
|
||||
fiscalPeriodId: params.fiscalPeriodId,
|
||||
})
|
||||
for (const acct of params.bankAccounts) {
|
||||
await getPool().query(
|
||||
`INSERT INTO public.journal_entry_lines
|
||||
(journal_entry_id, account_number, debit_amount, credit_amount)
|
||||
VALUES ($1, $2, 100, 0)`,
|
||||
[jeId, acct],
|
||||
)
|
||||
}
|
||||
return jeId
|
||||
}
|
||||
|
||||
async function getCashAccountId(txId: string): Promise<string | null> {
|
||||
const { rows } = await getPool().query(
|
||||
`SELECT cash_account_id FROM public.transactions WHERE id = $1`,
|
||||
[txId],
|
||||
)
|
||||
return rows[0]?.cash_account_id ?? null
|
||||
}
|
||||
|
||||
describe('transactions.cash_account_id — schema + FK', () => {
|
||||
it('ON DELETE SET NULL keeps the transaction when its cash account is deleted', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const caId = await insertCashAccount({ companyId, ledgerAccount: '1930' })
|
||||
const txId = await insertTransaction({ companyId, userId, cashAccountId: caId })
|
||||
|
||||
await getPool().query(`DELETE FROM public.cash_accounts WHERE id = $1`, [caId])
|
||||
|
||||
const { rows } = await getPool().query(
|
||||
`SELECT id, cash_account_id FROM public.transactions WHERE id = $1`,
|
||||
[txId],
|
||||
)
|
||||
expect(rows).toHaveLength(1) // transaction survived
|
||||
expect(rows[0].cash_account_id).toBeNull() // link was nulled, not cascaded
|
||||
})
|
||||
})
|
||||
|
||||
describe('transactions.cash_account_id — backfill pass (a) booked rows', () => {
|
||||
it('binds a booked transaction via its single bank line; skips multi-bank-line vouchers', async () => {
|
||||
const { userId, companyId, fiscalPeriodId } = await seedCompany()
|
||||
// Two SEK accounts so the single-account fallback (pass c) cannot fire.
|
||||
await insertCashAccount({ companyId, ledgerAccount: '1930' })
|
||||
const ca1931 = await insertCashAccount({ companyId, ledgerAccount: '1931' })
|
||||
|
||||
// Single 1931 line → should bind to the 1931 cash account.
|
||||
const jeSingle = await insertEntryWithBankLines({
|
||||
userId,
|
||||
companyId,
|
||||
fiscalPeriodId,
|
||||
bankAccounts: ['1931'],
|
||||
})
|
||||
const txSingle = await insertTransaction({
|
||||
companyId,
|
||||
userId,
|
||||
journalEntryId: jeSingle,
|
||||
})
|
||||
|
||||
// Two bank lines (1930 + 1931) → ambiguous transfer, must stay NULL.
|
||||
const jeTransfer = await insertEntryWithBankLines({
|
||||
userId,
|
||||
companyId,
|
||||
fiscalPeriodId,
|
||||
bankAccounts: ['1930', '1931'],
|
||||
})
|
||||
const txTransfer = await insertTransaction({
|
||||
companyId,
|
||||
userId,
|
||||
journalEntryId: jeTransfer,
|
||||
})
|
||||
|
||||
await runBackfill()
|
||||
|
||||
expect(await getCashAccountId(txSingle)).toBe(ca1931)
|
||||
expect(await getCashAccountId(txTransfer)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('transactions.cash_account_id — backfill pass (b) PSD2 external_id', () => {
|
||||
it('routes by IBAN and by external_uid embedded in external_id', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
// Two SEK accounts → pass (c) cannot fire, so only the PSD2 identity binds.
|
||||
const caIban = await insertCashAccount({
|
||||
companyId,
|
||||
ledgerAccount: '1930',
|
||||
iban: 'SE4550000000058398257466',
|
||||
})
|
||||
const caUid = await insertCashAccount({
|
||||
companyId,
|
||||
ledgerAccount: '1931',
|
||||
externalUid: 'psd2-uid-b',
|
||||
})
|
||||
|
||||
const txIban = await insertTransaction({
|
||||
companyId,
|
||||
userId,
|
||||
externalId: 'eb_SE4550000000058398257466_tx1',
|
||||
})
|
||||
const txUid = await insertTransaction({
|
||||
companyId,
|
||||
userId,
|
||||
externalId: 'eb_psd2-uid-b_tx2',
|
||||
})
|
||||
const txUnknown = await insertTransaction({
|
||||
companyId,
|
||||
userId,
|
||||
externalId: 'eb_nomatch_tx3',
|
||||
})
|
||||
|
||||
await runBackfill()
|
||||
|
||||
expect(await getCashAccountId(txIban)).toBe(caIban)
|
||||
expect(await getCashAccountId(txUid)).toBe(caUid)
|
||||
expect(await getCashAccountId(txUnknown)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('transactions.cash_account_id — backfill pass (c) single-account-of-currency', () => {
|
||||
it('binds when the company has exactly one enabled account of the currency', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const ca = await insertCashAccount({ companyId, ledgerAccount: '1930', currency: 'SEK' })
|
||||
// CSV-style row: no external_id, unbooked.
|
||||
const tx = await insertTransaction({ companyId, userId, currency: 'SEK' })
|
||||
|
||||
await runBackfill()
|
||||
|
||||
expect(await getCashAccountId(tx)).toBe(ca)
|
||||
})
|
||||
|
||||
it('leaves NULL when the company has two same-currency accounts', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
await insertCashAccount({ companyId, ledgerAccount: '1930', currency: 'SEK' })
|
||||
await insertCashAccount({ companyId, ledgerAccount: '1931', currency: 'SEK' })
|
||||
const tx = await insertTransaction({ companyId, userId, currency: 'SEK' })
|
||||
|
||||
await runBackfill()
|
||||
|
||||
expect(await getCashAccountId(tx)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('transactions.cash_account_id — account-scoped query isolation', () => {
|
||||
it('scopes to one account with a NULL→currency fallback, never leaking same-currency rows', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const ca1930 = await insertCashAccount({ companyId, ledgerAccount: '1930', currency: 'SEK' })
|
||||
const ca1931 = await insertCashAccount({ companyId, ledgerAccount: '1931', currency: 'SEK' })
|
||||
|
||||
const tx1930 = await insertTransaction({ companyId, userId, currency: 'SEK', cashAccountId: ca1930 })
|
||||
const tx1931 = await insertTransaction({ companyId, userId, currency: 'SEK', cashAccountId: ca1931 })
|
||||
const txNullSek = await insertTransaction({ companyId, userId, currency: 'SEK' })
|
||||
const txNullEur = await insertTransaction({ companyId, userId, currency: 'EUR' })
|
||||
|
||||
// Mirror the runtime predicate:
|
||||
// cash_account_id = X OR (cash_account_id IS NULL AND currency = cur)
|
||||
const scoped = async (cashAccountId: string, currency: string): Promise<string[]> => {
|
||||
const { rows } = await getPool().query(
|
||||
`SELECT id FROM public.transactions
|
||||
WHERE company_id = $1
|
||||
AND (cash_account_id = $2 OR (cash_account_id IS NULL AND currency = $3))`,
|
||||
[companyId, cashAccountId, currency],
|
||||
)
|
||||
return rows.map((r) => r.id)
|
||||
}
|
||||
|
||||
const for1930 = await scoped(ca1930, 'SEK')
|
||||
expect(for1930).toContain(tx1930)
|
||||
expect(for1930).toContain(txNullSek) // legacy NULL row visible via fallback
|
||||
expect(for1930).not.toContain(tx1931) // the other account never leaks
|
||||
expect(for1930).not.toContain(txNullEur) // wrong-currency NULL excluded
|
||||
|
||||
const for1931 = await scoped(ca1931, 'SEK')
|
||||
expect(for1931).toContain(tx1931)
|
||||
expect(for1931).toContain(txNullSek)
|
||||
expect(for1931).not.toContain(tx1930)
|
||||
})
|
||||
})
|
||||
|
||||
describe('transactions.cash_account_id — cross-company isolation', () => {
|
||||
it('backfill never binds a transaction to another company\'s cash account', async () => {
|
||||
const a = await seedCompany()
|
||||
const b = await seedCompany()
|
||||
|
||||
const caA = await insertCashAccount({ companyId: a.companyId, ledgerAccount: '1930' })
|
||||
const caB = await insertCashAccount({ companyId: b.companyId, ledgerAccount: '1930' })
|
||||
|
||||
const jeA = await insertEntryWithBankLines({
|
||||
userId: a.userId,
|
||||
companyId: a.companyId,
|
||||
fiscalPeriodId: a.fiscalPeriodId,
|
||||
bankAccounts: ['1930'],
|
||||
})
|
||||
const txA = await insertTransaction({ companyId: a.companyId, userId: a.userId, journalEntryId: jeA })
|
||||
|
||||
await runBackfill()
|
||||
|
||||
const boundA = await getCashAccountId(txA)
|
||||
expect(boundA).toBe(caA)
|
||||
expect(boundA).not.toBe(caB)
|
||||
})
|
||||
})
|
||||
@@ -402,6 +402,12 @@ export interface Transaction {
|
||||
bank_connection_id: string | null
|
||||
external_id: string | null // For deduplication
|
||||
|
||||
// The cash account (cash_accounts row) this transaction settled on. Drives
|
||||
// per-account bank reconciliation isolation and the correct bank leg when
|
||||
// booking. Null on legacy/unresolved rows — callers fall back to currency.
|
||||
// See 20260606120000_transactions_cash_account_id.sql.
|
||||
cash_account_id: string | null
|
||||
|
||||
// Details
|
||||
date: string
|
||||
description: string // Mutable working title — user-editable while unbooked (see PATCH /api/transactions/[id])
|
||||
|
||||
Reference in New Issue
Block a user