Add/stripe connect transactions (#1139)
* fix(mcp-oauth): allow ChatGPT connector callbacks and resume OAuth after login Add chatgpt.com/connector/oauth/* (per-instance) and the legacy chatgpt.com/connector_platform_oauth_redirect to the built-in OAuth redirect allowlist so ChatGPT MCP connectors can register and authorize. Fix the login page dropping the ?next= destination: an OAuth-initiated visit that required login previously ended on the dashboard and the connection flow silently died. Login now resumes to the sanitized next path (hard navigation, since the consent page is route-handler HTML), carries it through the MFA step-up as returnTo, and /mfa/verify hard-navigates for /api/ destinations. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(transactions): dedup incoming feed rows against booked hand-entered twins Users who bookkeep via MCP/chat first and connect their bank afterwards got the same movement twice: the synced row's external_id lives in a different namespace, the free-form manual title never text-bridges the bank's raw string, and the cross-channel mirror deliberately excluded manual/mcp rows. Extend the mirror with a booked-hand-entered track: an incoming feed row is skipped when a BOOKED manual/mcp row shares its (date, ore) bucket count- symmetrically. Gates beyond the feed-vs-feed mirror: stored row must be booked (staged rows never consume an import), currencies must not contradict (bucket key is date+ore only), the cash-account guard applies to the count exactly as to consumption, and symmetry uses the Layer-1-unmatched incoming count so an already-stored row cannot inflate it. Consumption stamps the batch cash_account_id onto an account-unbound hand row, so one hand row can never consume feed rows on other accounts in later syncs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(bookkeeping): inline verifikat rattelse (strike lines + text/date edit) Second sanctioned correction track under BFL 5 kap 5/9 pp, Fortnox-style: strike lines inside a posted verifikat with replacements in the same voucher, and correct description/entry_date without an andringsverifikat. Envelope: posted entries, open unlocked periods, company lock date, same-period date moves, structural/FX/doc-linked lines excluded, and a reconciliation guard preserving per-account net on bank/reskontra sides of externally linked entries. Every rattelse writes an immutable who/when row (journal_entry_rattelse_log, WORM, archived as rakenskapsinformation) and struck originals render struck-through in the verifikat; list rows and the detail header carry a Rattad marker. CLAUDE.md hard rule 1 and the swedish-accounting-compliance skill are amended to state the two-track rule. Staging carries the DDL; prod gets it on merge. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: live saldo in booking form, prior-year window comparison, hideable assistant FAB - Manual journal entry: saldo column now shows before -> after computed from the typed debit/credit amounts (direction feedback while booking) - Resultatrapport: a narrowed date range now compares against the same window shifted one year back (#862), merged across fiscal periods for brutet rakenskapsar; P&L rows report window activity instead of rolled-forward YTD closing - Assistant FAB: per-user hide toggle (user_preferences.hide_assistant_fab, settings > assistant), sidebar entry unaffected; collapsed sessions keep their reopen handle Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(stripe): sync balance transactions as a bank feed on 1686 Import the connected Stripe balance into the transactions inbox, opt-in per connection (transaction_sync_enabled on stripe_connections): - Balance transactions map to feed rows with the two-row gross+fee split and frozen external_id formats (stripe_{acct}_{txn} / _fee), dated on created, bound to a provisioned "Stripe-saldo" cash account on 1686 so booking settles against the clearing account by construction. - Double-booking protection: settled payment-link charges import pre-linked to their settlement entry; payout rows import pre-linked to the payout entry; processPayoutPaidEvent claims the payout's fee rows at booking time (linkPayoutFeedRows, idempotent from both directions). - Cursor last_balance_txn_synced_at with 24h overlap; first run backfills 90 days floored at the day after the company lock date. - Nightly cron /api/extensions/stripe/transactions/cron (03:30), transaction-sync toggle route, "Synka nu" covers both feeds, settings panel toggle with last-synced/backfill note, sv+en strings. - Migration 20260723200000 (applied to staging). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(transactions): offer match-to-voucher on unbooked history rows Unbooked transactions with is_business already set (e.g. left behind when a voucher was removed without a full uncategorize) land in the history list instead of the inbox, where the match-against-existing-voucher action did not exist, leaving them with no path back to voucher matching. Add the same menu item to the history list for unbooked rows. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(transactions): enhance ownership checks and error handling in journal entry routes --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -10,7 +10,7 @@ Accounted exposes its bookkeeping engine as an MCP server for Claude Desktop/Cod
|
||||
|
||||
**MCP extension** (`extensions/general/mcp-server/`): 90+ tools covering transactions, categorization, customers/suppliers, invoices, accounts, fiscal periods, reports (trial balance, GL, BS, IS, AR/supplier ledger, VAT, KPI), reconciliation, salary runs, AGI, year-end, document upload, and loadable skills. JSON-RPC 2.0. Endpoint: `/api/extensions/ext/mcp-server/mcp`.
|
||||
|
||||
**OAuth 2.1** for Claude connectors: `.well-known/oauth-protected-resource` + `.well-known/oauth-authorization-server` discovery; `/api/mcp-oauth/authorize`, `/token` (PKCE), `/register`. Stateless AES-256-GCM auth codes (`lib/auth/oauth-codes.ts`). Single-use via `oauth_used_codes`. Allowlist: `claude.ai/api/*`, `claude.com/api/*`, `localhost`.
|
||||
**OAuth 2.1** for Claude and ChatGPT connectors: `.well-known/oauth-protected-resource` + `.well-known/oauth-authorization-server` discovery; `/api/mcp-oauth/authorize`, `/token` (PKCE), `/register`. Stateless AES-256-GCM auth codes (`lib/auth/oauth-codes.ts`). Single-use via `oauth_used_codes`. Allowlist: `claude.ai/api/*`, `claude.com/api/*`, `chatgpt.com/connector/oauth/*`, `chatgpt.com/connector_platform_oauth_redirect`, `localhost`.
|
||||
|
||||
**npm package** (`packages/gnubok-mcp`): Stdio-to-HTTP bridge; users run `npx gnubok-mcp` with API key.
|
||||
|
||||
|
||||
@@ -47,7 +47,11 @@ A verifikation must contain:
|
||||
Verifikationer must be numbered in a systematisk serie without gaps. If a verifikation is corrected, the original must be preserved and the correction linked.
|
||||
|
||||
### Rättelse (BFL 5 kap 5§)
|
||||
A rättelse of a bokföringspost must be documented so that both the original and the corrected post are visible. You cannot simply overwrite. Implement as: new correcting verifikation referencing the original.
|
||||
A rättelse of a bokföringspost must be documented so that both the original and the corrected post are visible, and it must be recorded when the rättelse was made and who made it. You can never silently overwrite. BFL permits two tracks:
|
||||
1. **Särskild rättelsepost** (storno + correcting verifikation referencing the original): always allowed, and the only track once the period is locked/closed or the bokföring has been relied upon (filed declarations, bokslut).
|
||||
2. **Rättelse in the same verifikat** (strike-and-replace of lines, or correcting the verifikation's text/date per BFL 5 kap 9 §): allowed while the period is open and unlocked, provided the original remains readable (struck lines stay visible) and who/when is recorded immutably. Fortnox and Visma implement this track; in Accounted it is the `correct_entry_metadata` / `correct_entry_lines_inline` RPC envelope logging to `journal_entry_rattelse_log`.
|
||||
|
||||
A correction path that erases the original without a trace violates the law under both tracks.
|
||||
|
||||
### Arkivering (BFL 7 kap)
|
||||
- Räkenskapsinformation must be preserved for 7 years after the end of the calendar year the räkenskapsår ended
|
||||
|
||||
@@ -90,11 +90,12 @@ Every verifikation must have an underlag (kvitto, faktura, bankutdrag, avtal, et
|
||||
### Multiple verification series
|
||||
BFL allows multiple verification series (e.g., "A" for supplier invoices, "B" for customer invoices, "K" for bank). Each series must have unbroken numbering within the räkenskapsår. This is common in practice and your software should support it.
|
||||
|
||||
### Rättelser (5 kap 5§)
|
||||
- An incorrect bokföringspost must be corrected with a new verifikation
|
||||
- The original post must remain visible (no overwriting)
|
||||
- The correcting post must reference the original
|
||||
- Implement as: rättelsepost with a link/reference to the original verifikation
|
||||
### Rättelser (5 kap 5§ and 5 kap 9§)
|
||||
- The original post must remain visible (no silent overwriting), and it must be recorded when the rättelse was made and who made it
|
||||
- Two permitted tracks:
|
||||
1. **Särskild rättelsepost**: a correcting verifikation with a link/reference to the original. Always allowed; the only track once the period is locked/closed or the bokföring has been relied upon (filed declarations, bokslut)
|
||||
2. **Rättelse in the same verifikat**: strike-and-replace of lines with the struck originals kept readable, or correction of the verifikation's text/date (5 kap 9 §). Allowed in open, unlocked periods with an immutable who/when trail. This is the track Fortnox/Visma expose as "ändra verifikat"
|
||||
- When rättelse happens through a särskild rättelsepost, it must be easy to become aware of the rättelse when inspecting the corrected post ("utan svårighet gå att få kännedom om rättelsen")
|
||||
|
||||
## 5. Avslutning av bokföringen (BFL 6 kap)
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ Swedish accounting SaaS: double-entry bookkeeping under Swedish accounting law (
|
||||
|
||||
The accounting rules are Swedish law, enforced by DB triggers. Code that violates them fails at runtime; code that works around the triggers breaks legal compliance. Never do either.
|
||||
|
||||
1. **Never edit or delete a posted journal entry.** Committed vouchers are immutable. Cancel with `reverseEntry()`; correct with `correctEntry()` (`lib/core/bookkeeping/storno-service.ts`). Storno, never edit.
|
||||
1. **Never edit or delete a posted journal entry outside the two sanctioned rättelse paths.** BFL 5 kap 5 § allows two correction tracks: (a) storno: cancel with `reverseEntry()`, correct with `correctEntry()` (`lib/core/bookkeeping/storno-service.ts`); (b) inline rättelse (founder-approved 2026-07-23): `correct_entry_metadata` / `correct_entry_lines_inline` RPCs, which strike-and-replace inside the same verifikat with an immutable who/when log (`journal_entry_rattelse_log`), only in open unlocked periods. Past a lock/close/declared state, storno is the only path. Never write to posted entries or their lines through any other route; never delete.
|
||||
2. **All journal writes go through `lib/bookkeeping/engine.ts`.** Never insert into journal tables directly: voucher numbers are assigned atomically by the `commit_journal_entry` RPC and must stay sequential, and gaps require documented explanations (BFNAR 2013:2, `voucher_gap_explanations`).
|
||||
3. **Every entry balances**: `sum(debits) === sum(credits)`, both `> 0`.
|
||||
4. **Respect period locks.** DB triggers block writes to closed/locked periods and behind the company lock date. Don't work around them: fix the flow that tried to write there.
|
||||
|
||||
+10
-1
@@ -331,6 +331,15 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-07-23] Removed the accountingMethod parameter from calculateVatDeclaration (and the dead company_settings.accounting_method reads in the xlsx/pdf/eskd routes) instead of restoring the settings read: the value was verifiably unused (declared _accountingMethod, zero body references) because the method is baked into journal entry timing, and keeping an ignored parameter invites a future branch that silently sees the caller's hard-coded 'accrual'; the v1 API still accepts accounting_method for wire compat but its docs now state it has no effect.
|
||||
[2026-07-23] Closed the mark_entry_as_opening_balance TOCTOU (link committing between the RPC's EXISTS check and commit) with a transactions-side trigger (20260723190000, FOR KEY SHARE on journal_entries) instead of a shared advisory lock in every linking code path: the trigger enforces the invariant from both directions in one place, needs no app-code changes, and FOR KEY SHARE conflicts with exactly the RPC's FOR UPDATE and nothing weaker; prod verified to have zero pre-existing violating rows.
|
||||
[2026-07-23] Declined the suggested composite index (company_id, is_active, account_class, sort_order, id) on chart_of_accounts for list_company_accounts: the RPC exists to eliminate cross-region HTTP round trips, per-company row counts (p95 ~1250) make the filter+sort a few ms via the existing company_id index, and a 5-column index taxes every account write for no user-visible gain.
|
||||
[2026-07-23] Stripe transaction sync provisions a real cash_accounts row on ledger 1686 ("Stripe-saldo") instead of importing balance transactions as accountless inbox rows: booking/categorize/match routes resolve the settlement leg from cash_account_id -> ledger_account, so binding to 1686 makes user booking settle against the clearing account by construction, gives the dedup account guard a scope, and lets the feed reconcile against the actual Stripe balance.
|
||||
[2026-07-23] Stripe feed rows use the two-row gross+fee split with fee rows claimed by the payout booking (linkPayoutFeedRows at processPayoutPaidEvent time AND from the sync for already-booked payouts) rather than holding fee rows back until payout: the feed stays complete intra-week; the short window where a user could manually book a fee row before its payout is accepted and idempotently guarded (only journal_entry_id IS NULL rows are claimed). Payout rows import pre-linked instead of being skipped so the feed sums to Stripe's real balance movements.
|
||||
[2026-07-23] Stripe feed row dates use balance_transaction.created, not available_on: created is the economic event (payment date) that booking, invoice matching, and month boundaries need; available_on is Stripe's internal settlement schedule days later. First-run backfill is 90 days floored at the day after bookkeeping_locked_through: rows behind the lock can never be booked and would be permanent inbox noise.
|
||||
[2026-07-23] The transaction-sync toggle lives on stripe_connections (transaction_sync_enabled), not company_settings: the cursor lives there, the crons iterate connections, and a future second connected account wants independent toggles.
|
||||
[2026-07-23] Import dedup extended with a booked-hand-entered mirror (lib/transactions/ingest.ts): an incoming feed row is skipped when a BOOKED manual/mcp row shares its (date, ore) bucket count-symmetrically, gated on currency + cash-account compatibility; consumption stamps the batch cash_account_id onto a null-account hand row (one-consume-ever across accounts). Chosen over (a) strict account match, which misses the real MCP-then-connect-bank case (mcp rows have null cash_account_id), and (b) external_id adoption, because agents supply their own external_ids (prod: TRANSFER-... on mcp rows) that must not be overwritten. Unbooked hand rows stay excluded: staged intent is not ledger evidence. Symmetry uses the Layer-1-unmatched incoming count and the count map applies the same guards as consumption (adversarial-review findings).
|
||||
[2026-07-23] Inline verifikat rattelse (founder-approved both cards): built Fortnox-style strike-and-replace inside posted verifikat + metadata (text/date) edit WITHOUT andringsverifikat, as a second sanctioned correction track under BFL 5 kap 5/9 pp. One SECURITY DEFINER RPC per operation (correct_entry_metadata, correct_entry_lines_inline), transaction-local GUC carve-outs in the immutability triggers (precedent: notes edit, source_type retag, dimension retag), append-only WORM journal_entry_rattelse_log holding full struck-line snapshots (classified as rakenskapsinformation in full-archive-export, NOT excluded like dimension_retag_log). Envelope: posted entries, open+unlocked periods, company lock date, same-fiscal-period date moves only, structural source types excluded (storno/opening_balance/year_end/vat_settlement), FX lines and doc-attached lines excluded (storno path), reconciliation guard = per-account net on 19xx/cash_accounts (bank-linked), 15xx (invoice_payments-linked), 24xx (supplier-linked) must be preserved. CLAUDE.md hard rule 1 + swedish-accounting-compliance skill amended in the same change to state the two-track rule (adversarial reviewer: feature was otherwise condemned by its own repo docs).
|
||||
[2026-07-23] Inline rattelse deliberate accepts from the adversarial pass: (a) direct INSERT into journal_entry_lines on posted entries is unguarded platform-wide (pre-existing; blocking it blind risks breaking match_batch_allocate/bulk_book SQL RPC booking flows, needs its own audited change) - FLAGGED FOLLOW-UP; (b) strike racing an in-flight multi-statement storno can produce a stale-mirror storno (storno-service holds no row lock until its final CAS) - rare, pre-existing race class, follow-up = FOR UPDATE in storno-service step 0; (c) RPCs granted to authenticated bypass the withRouteContext MFA gate like every existing RPC (retag precedent) - platform-level posture; (d) replacement lines never carry tax_code (nothing reads journal_entry_lines.tax_code in reports today); (e) VAT-declared months are gated by dialog copy + the existing period-lock culture, same as the storno flow - follow-up: banner on VAT report for post-declaration rattelser; (f) rattelse-log WORM trigger blocks cascaded company hard-deletes (shared latent defect with dimension_retag_log, test-company cleanup only).
|
||||
[2026-07-23] Resultatrapport signedAmount moved from closing_* to period_*: trial balance rolls pre-window activity into opening, so closing on P&L rows silently showed YTD for month/quarter windows; period activity is the correct windowed value and equals closing in the full-period case. Enables same-window prior-year comparison (#862).
|
||||
[2026-07-23] Assistant FAB is now user-hideable (user_preferences.hide_assistant_fab, toggle under Installningar > Assistenten): deliberately overrides the keep-visible-as-conversion-surface design for users who opt out; a collapsed chat session keeps its reopen handle even when hidden.
|
||||
[2026-07-21] Floden & agenter concept (dev_docs/flows_agents_concept.md + interactive artifact): founder locked hybrid builder (chat builds, linear flow view edits; no free node canvas), trust-ladder autonomy on pending_operations risk tiers, per-company before byra, ships alongside Assistenten. Engine recommendation is a hand-rolled Postgres state machine in lib/flows/ generalizing the webhook-dispatcher pattern; every external engine (Inngest/Trigger/Temporal/Vercel Workflow/Restate/DBOS/pg-boss) fails zero-extra-services self-host, Supabase-only durability, AGPL compatibility, or free days-long approval pauses. Flows compile deterministically at build time; LLM steps are explicit, marked step types. Awaiting founder visual sign-off before any implementation PR.
|
||||
[2026-07-21] Official Claude Code plugin ships in-tree (claude-plugin/ + root .claude-plugin/marketplace.json), not in the claude-for-swedish-small-business marketplace repo: the wrapper-reference sync test must live next to the MCP server source, and the official plugin belongs with the product. Wrappers are thin by design: regulatory knowledge stays server-side in agent_atom_registry and is loaded via gnubok_load_skill, never bundled. Plugin licensed MIT matching the gnubok-mcp precedent.
|
||||
[2026-07-22] Swish inflows via Swishrapport file import (#1114), not Swish API: the Commerce API cannot list incoming payments (only API-created ones) and prod data shows banks strip payer/message from PSD2 remittance info.
|
||||
@@ -339,4 +348,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-07-23] Frame layout (UI-migration PR 1) is md:-gated and the panel owns desktop scroll: mobile keeps document flow + bottom nav (concept is desktop-first), and since Next's window scroll-to-top never fires for an inner scroll container, MainContainer resets panel scroll on route change. Button default size drops fixed h-10 for natural pill height (7px/16px padding per locked convention 3); sm/lg/icon keep their heights.
|
||||
[2026-07-23] Nav PR 2: ui_state persisted as one jsonb bag on user_preferences (founder-approved migration 20260723120000) rather than per-preference columns: cosmetic, never load-bearing, grows with PR 3/4 split-button modes. Sidebar width driven by an inline --nav-w CSS variable on #dash-shell instead of a [data-nav-collapsed] attribute rule: the Tailwind 4/Lightning CSS pipeline silently dropped the top-level attribute-selector rule from compiled output, and the inline variable is pipeline-proof. Register/Bokslut folds default closed (concept tidiness), forced open by an active child route. Discord-community row skipped: no invite URL exists in the repo; add when one lands.
|
||||
[2026-07-23] PR 3 primitives: new --attn token pair (38 50% 34% light / 38 45% 62% dark) for the one-sentence AttnLine instead of reusing --warning: the warning tone fails WCAG AA as 12.5px body text on the page background; chips/charts keep --warning. Stagger applied via className on the five target pages' DataList/TableBody containers (plan item 7), not baked into the DataList primitive: remaining pages adopt it in their own migration PRs where their skeletons are aligned at the same time.
|
||||
[2026-07-23] PR 4 template booking books directly through the normal journal-entries endpoint without the editor's account-activation retry flow: template accounts are standard BAS accounts that are active in practice, and an inactive-account error surfaces as a normal error toast pointing at the full editor. Voucher preview in the commit ConfirmDialog is indicative (voucher-sequences/next for today's period/default series); the atomic number assigned at commit is what the success toast shows.
|
||||
[2026-07-23] PR 4 template booking books directly through the normal journal-entries endpoint without the editor's account-activation retry flow: template accounts are standard BAS accounts that are active in practice, and an inactive-account error surfaces as a normal error toast pointing at the full editor. Voucher preview in the commit ConfirmDialog is indicative (voucher-sequences/next for today's period/default series); the atomic number assigned at commit is what the success toast shows.
|
||||
@@ -16,6 +16,7 @@ import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-messag
|
||||
import { isBankIdEnabled } from '@/lib/auth/bankid'
|
||||
import { getBranding } from '@/lib/branding/service'
|
||||
import { detectWebmailHint } from '@/lib/auth/webmail-search'
|
||||
import { safeReturnTo } from '@/lib/auth/safe-return-to'
|
||||
import { AuthPageSkeleton } from '@/components/auth/AuthPageSkeleton'
|
||||
|
||||
const branding = getBranding()
|
||||
@@ -50,6 +51,10 @@ function LoginPageContent() {
|
||||
const searchParams = useSearchParams()
|
||||
const callbackError = searchParams.get('error')
|
||||
const callbackFlow = searchParams.get('flow')
|
||||
// Post-login destination, set e.g. by the MCP OAuth authorize endpoint
|
||||
// (/login?next=/api/mcp-oauth/authorize?...). Sanitized to a same-origin
|
||||
// relative path; '/' means no explicit destination.
|
||||
const nextPath = safeReturnTo(searchParams.get('next'), '/')
|
||||
const supabase = createClient()
|
||||
const bankIdEnabled = isBankIdEnabled()
|
||||
const tAuth = useTranslations('auth')
|
||||
@@ -131,6 +136,13 @@ function LoginPageContent() {
|
||||
document.cookie = 'gnubok-invite-token=; path=/; max-age=0'
|
||||
}
|
||||
|
||||
if (nextPath !== '/') {
|
||||
// An explicit destination (e.g. the MCP OAuth consent page, raw
|
||||
// HTML from a route handler) outranks the company picker.
|
||||
window.location.assign(nextPath)
|
||||
return
|
||||
}
|
||||
|
||||
// Always land on the picker after BankID login so the user sees
|
||||
// fresh CompanyRoles fetched during this session's enrichment.
|
||||
router.push('/select-company')
|
||||
@@ -175,7 +187,11 @@ function LoginPageContent() {
|
||||
const { data: aal } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel()
|
||||
|
||||
if (aal?.nextLevel === 'aal2' && aal?.currentLevel === 'aal1') {
|
||||
router.push('/mfa/verify')
|
||||
router.push(
|
||||
nextPath === '/'
|
||||
? '/mfa/verify'
|
||||
: `/mfa/verify?returnTo=${encodeURIComponent(nextPath)}`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -203,6 +219,14 @@ function LoginPageContent() {
|
||||
document.cookie = 'gnubok-invite-token=; path=/; max-age=0'
|
||||
}
|
||||
|
||||
if (nextPath !== '/') {
|
||||
// Full navigation: the destination can be a route handler that
|
||||
// returns raw HTML (the MCP OAuth consent page), which the client
|
||||
// router cannot render.
|
||||
window.location.assign(nextPath)
|
||||
return
|
||||
}
|
||||
|
||||
router.push('/')
|
||||
router.refresh()
|
||||
} catch (error) {
|
||||
|
||||
@@ -138,6 +138,13 @@ function MfaVerifyContent() {
|
||||
document.cookie = 'gnubok-invite-token=; path=/; max-age=0'
|
||||
}
|
||||
|
||||
if (returnTo.startsWith('/api/')) {
|
||||
// Route-handler destinations (e.g. the MCP OAuth consent page)
|
||||
// return raw HTML the client router cannot render: hard-navigate.
|
||||
window.location.assign(returnTo)
|
||||
return
|
||||
}
|
||||
|
||||
router.push(returnTo)
|
||||
router.refresh()
|
||||
} catch {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { AccountNumber } from '@/components/ui/account-number'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Loader2, ArrowLeft, Paperclip, AlertTriangle, Lock, MessageSquare, Pencil, Check, X, Copy, ChevronDown, CalendarClock, FileText, Link2, RotateCcw } from 'lucide-react'
|
||||
import { Loader2, ArrowLeft, Paperclip, AlertTriangle, Lock, MessageSquare, Pencil, Check, X, Copy, ChevronDown, CalendarClock, FileText, Link2, RotateCcw, Scissors, PenLine } from 'lucide-react'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
@@ -23,6 +23,8 @@ import JournalEntryAttachments from '@/components/bookkeeping/JournalEntryAttach
|
||||
import JournalEntryStatusBadge, { useSourceTypeLabels } from '@/components/bookkeeping/JournalEntryStatusBadge'
|
||||
import CorrectionEntryDialog from '@/components/bookkeeping/CorrectionEntryDialog'
|
||||
import CorrectOpeningBalanceDialog from '@/components/bookkeeping/CorrectOpeningBalanceDialog'
|
||||
import StrikeLinesDialog from '@/components/bookkeeping/StrikeLinesDialog'
|
||||
import CorrectMetadataDialog from '@/components/bookkeeping/CorrectMetadataDialog'
|
||||
import EditDraftEntryDialog from '@/components/bookkeeping/EditDraftEntryDialog'
|
||||
import RecordateEntryDialog from '@/components/bookkeeping/RecordateEntryDialog'
|
||||
import AgentSparkleButton from '@/components/agent/AgentSparkleButton'
|
||||
@@ -37,6 +39,29 @@ import { fetchDimensions, type DimensionDto } from '@/components/dimensions/type
|
||||
import type { JournalEntry, JournalEntryLine } from '@/types'
|
||||
import type { UnderlagReference } from '@/lib/core/bookkeeping/journal-entry-references'
|
||||
|
||||
// Snapshot of a struck line, as stored in journal_entry_rattelse_log.
|
||||
type StruckLineSnapshot = {
|
||||
id: string
|
||||
account_number: string
|
||||
debit_amount: number | string
|
||||
credit_amount: number | string
|
||||
line_description: string | null
|
||||
sort_order: number
|
||||
}
|
||||
|
||||
type RattelseLogRow = {
|
||||
id: string
|
||||
rattelse_type: 'metadata' | 'lines'
|
||||
old_description: string | null
|
||||
new_description: string | null
|
||||
old_entry_date: string | null
|
||||
new_entry_date: string | null
|
||||
struck_lines: StruckLineSnapshot[] | null
|
||||
added_lines: StruckLineSnapshot[] | null
|
||||
actor: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export default function JournalEntryDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = use(params)
|
||||
const router = useRouter()
|
||||
@@ -50,6 +75,9 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showCorrection, setShowCorrection] = useState(false)
|
||||
const [showCorrectIB, setShowCorrectIB] = useState(false)
|
||||
const [showStrikeLines, setShowStrikeLines] = useState(false)
|
||||
const [showCorrectMetadata, setShowCorrectMetadata] = useState(false)
|
||||
const [rattelseLog, setRattelseLog] = useState<RattelseLogRow[]>([])
|
||||
const [showEdit, setShowEdit] = useState(false)
|
||||
const [showRecordate, setShowRecordate] = useState(false)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
@@ -96,15 +124,20 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const [chainRes, refsRes, retagRes] = await Promise.all([
|
||||
const [chainRes, refsRes, retagRes, rattelseRes] = await Promise.all([
|
||||
fetch(`/api/bookkeeping/journal-entries/${id}/chain`),
|
||||
fetch(`/api/bookkeeping/journal-entries/${id}/references`),
|
||||
fetch(`/api/bookkeeping/journal-entries/${id}/retag-log`),
|
||||
fetch(`/api/bookkeeping/journal-entries/${id}/rattelse-log`),
|
||||
])
|
||||
if (retagRes.ok) {
|
||||
const retagPayload = await retagRes.json()
|
||||
setRetagLog(Array.isArray(retagPayload.data) ? retagPayload.data : [])
|
||||
}
|
||||
if (rattelseRes.ok) {
|
||||
const rattelsePayload = await rattelseRes.json()
|
||||
setRattelseLog(Array.isArray(rattelsePayload.data) ? rattelsePayload.data : [])
|
||||
}
|
||||
if (!chainRes.ok) {
|
||||
const { error: msg } = await chainRes.json()
|
||||
setError(msg || t('error_load_failed'))
|
||||
@@ -277,6 +310,37 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
|
||||
// correction (or the original) and corrects that one.
|
||||
const canCorrect = entry.status === 'posted' && entry.source_type !== 'storno'
|
||||
|
||||
// Inline rättelse (strike lines in the same verifikat) keeps structural
|
||||
// entry types on their dedicated flows: storno mirrors its original, IB
|
||||
// feeds opening_balance_entry_id, year-end feeds dispositions. The RPC
|
||||
// enforces the same rule server-side; this just hides the dead menu item.
|
||||
const canInlineRattelse =
|
||||
entry.status === 'posted' &&
|
||||
!['storno', 'opening_balance', 'year_end', 'vat_settlement'].includes(entry.source_type)
|
||||
|
||||
// Struck lines (from the immutable rättelse log) render inline in the
|
||||
// lines table with strikethrough, Fortnox-style: the original stays
|
||||
// visible per BFL 5 kap 5 § even though it no longer counts.
|
||||
const struckDisplayLines = rattelseLog
|
||||
.filter((r) => r.rattelse_type === 'lines')
|
||||
.flatMap((r) =>
|
||||
(r.struck_lines ?? []).map((s) => ({ ...s, struck_at: r.created_at }))
|
||||
)
|
||||
|
||||
// Live and struck lines interleaved by original position.
|
||||
const displayRows: Array<
|
||||
| { kind: 'live'; line: JournalEntryLine }
|
||||
| { kind: 'struck'; line: StruckLineSnapshot & { struck_at: string } }
|
||||
> = [
|
||||
...lines.map((l) => ({ kind: 'live' as const, line: l })),
|
||||
...struckDisplayLines.map((s) => ({ kind: 'struck' as const, line: s })),
|
||||
].sort(
|
||||
(a, b) =>
|
||||
(a.line.sort_order ?? 0) - (b.line.sort_order ?? 0) ||
|
||||
// On a sort_order tie the struck original renders above its replacement.
|
||||
(a.kind === 'struck' ? -1 : 0) - (b.kind === 'struck' ? -1 : 0)
|
||||
)
|
||||
|
||||
// An opening-balance verifikat must be corrected through the IB-aware flow
|
||||
// (storno + rebook + relink the period's opening_balance_entry_id), never the
|
||||
// generic "Rätta rader": that books a `correction` entry but leaves the
|
||||
@@ -343,6 +407,11 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
|
||||
{formatVoucher(entry)}
|
||||
</h1>
|
||||
<JournalEntryStatusBadge entry={entry} />
|
||||
{rattelseLog.length > 0 && (
|
||||
<Badge variant="outline" className="text-xs font-normal" title={t('rattelse_history_title')}>
|
||||
Rättad
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted-foreground">{entry.description}</p>
|
||||
</div>
|
||||
@@ -411,6 +480,17 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{canInlineRattelse && (
|
||||
<DropdownMenuItem onClick={() => setShowStrikeLines(true)}>
|
||||
<Scissors className="mr-2 h-4 w-4" />
|
||||
{t('strike_lines')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={() => setShowCorrectMetadata(true)}>
|
||||
<PenLine className="mr-2 h-4 w-4" />
|
||||
{t('correct_metadata')}
|
||||
</DropdownMenuItem>
|
||||
{canInlineRattelse && <DropdownMenuSeparator />}
|
||||
<DropdownMenuItem onClick={() => setShowCorrection(true)}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
{t('correct_lines')}
|
||||
@@ -643,7 +723,34 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{lines.map((line) => {
|
||||
{displayRows.map((row) => {
|
||||
if (row.kind === 'struck') {
|
||||
const s = row.line
|
||||
return (
|
||||
<tr key={`struck-${s.id}`} className="border-b last:border-0 text-muted-foreground">
|
||||
<td className="py-2 line-through decoration-muted-foreground/70">
|
||||
<AccountNumber number={s.account_number} showName />
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<span className="line-through decoration-muted-foreground/70">
|
||||
{s.line_description || ''}
|
||||
</span>
|
||||
<span className="ml-2 no-underline text-xs">
|
||||
{t('struck_marker', { date: formatDate(s.struck_at) })}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 text-right tabular-nums line-through decoration-muted-foreground/70">
|
||||
{Number(s.debit_amount) > 0 &&
|
||||
Number(s.debit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 })}
|
||||
</td>
|
||||
<td className="py-2 text-right tabular-nums line-through decoration-muted-foreground/70">
|
||||
{Number(s.credit_amount) > 0 &&
|
||||
Number(s.credit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 })}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
const line = row.line
|
||||
const hasForeignCurrency = line.currency && line.currency !== 'SEK' && line.amount_in_currency != null
|
||||
return (
|
||||
<tr key={line.id} className="border-b last:border-0">
|
||||
@@ -709,7 +816,32 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
|
||||
|
||||
{/* Mobile cards */}
|
||||
<div className="sm:hidden space-y-2">
|
||||
{lines.map((line) => {
|
||||
{displayRows.map((row) => {
|
||||
if (row.kind === 'struck') {
|
||||
const s = row.line
|
||||
return (
|
||||
<div key={`struck-${s.id}`} className="flex items-center justify-between py-2 border-b last:border-0 gap-2 text-muted-foreground">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm line-through decoration-muted-foreground/70">
|
||||
<AccountNumber number={s.account_number} showName />
|
||||
</div>
|
||||
{s.line_description && (
|
||||
<p className="text-xs truncate line-through decoration-muted-foreground/70">{s.line_description}</p>
|
||||
)}
|
||||
<p className="text-xs">{t('struck_marker', { date: formatDate(s.struck_at) })}</p>
|
||||
</div>
|
||||
<div className="text-right shrink-0 text-sm tabular-nums line-through decoration-muted-foreground/70">
|
||||
{Number(s.debit_amount) > 0 && (
|
||||
<p>{Number(s.debit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} D</p>
|
||||
)}
|
||||
{Number(s.credit_amount) > 0 && (
|
||||
<p>{Number(s.credit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} K</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const line = row.line
|
||||
const hasForeignCurrency = line.currency && line.currency !== 'SEK' && line.amount_in_currency != null
|
||||
return (
|
||||
<div key={line.id} className="flex items-center justify-between py-2 border-b last:border-0 gap-2">
|
||||
@@ -818,6 +950,69 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Rättelsehistorik (BFL 5 kap 5 § / 9 §): the immutable who/when trail
|
||||
behind inline rättelser. Stays Swedish (voucher detail surface). */}
|
||||
{rattelseLog.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium">{t('rattelse_history_title')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{rattelseLog.map((row) => (
|
||||
<div key={row.id} className="text-sm border-b last:border-0 pb-3 last:pb-0">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-muted-foreground tabular-nums">{formatDate(row.created_at)}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{row.rattelse_type === 'metadata' ? t('rattelse_kind_metadata') : t('rattelse_kind_lines')}
|
||||
</span>
|
||||
</div>
|
||||
{row.rattelse_type === 'metadata' ? (
|
||||
<div className="space-y-0.5">
|
||||
{row.old_description !== row.new_description && (
|
||||
<p>
|
||||
<span className="text-muted-foreground line-through">{row.old_description}</span>
|
||||
{' → '}
|
||||
<span>{row.new_description}</span>
|
||||
</p>
|
||||
)}
|
||||
{row.old_entry_date !== row.new_entry_date && (
|
||||
<p className="tabular-nums">
|
||||
<span className="text-muted-foreground line-through">{formatDate(row.old_entry_date || '')}</span>
|
||||
{' → '}
|
||||
<span>{formatDate(row.new_entry_date || '')}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{(row.struck_lines ?? []).map((s) => (
|
||||
<p key={s.id} className="tabular-nums text-muted-foreground">
|
||||
<span className="line-through decoration-muted-foreground/70">
|
||||
{s.account_number}
|
||||
{' '}
|
||||
{Number(s.debit_amount) > 0
|
||||
? `${Number(s.debit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} D`
|
||||
: `${Number(s.credit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} K`}
|
||||
</span>
|
||||
</p>
|
||||
))}
|
||||
{(row.added_lines ?? []).map((a) => (
|
||||
<p key={a.id} className="tabular-nums">
|
||||
{a.account_number}
|
||||
{' '}
|
||||
{Number(a.debit_amount) > 0
|
||||
? `${Number(a.debit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} D`
|
||||
: `${Number(a.credit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} K`}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Dimension retag history (dimensions plan PR6): the immutable
|
||||
before/after trail. Stays Swedish (voucher detail surface). */}
|
||||
{dimensionsEnabled && retagLog.length > 0 && (
|
||||
@@ -861,6 +1056,30 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
|
||||
onRetagged={fetchData}
|
||||
/>
|
||||
|
||||
{/* Inline rättelse dialogs (strike lines / metadata) */}
|
||||
{showStrikeLines && entry && (
|
||||
<StrikeLinesDialog
|
||||
entry={entry}
|
||||
open={showStrikeLines}
|
||||
onOpenChange={setShowStrikeLines}
|
||||
onCorrected={() => {
|
||||
setShowStrikeLines(false)
|
||||
fetchData()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{showCorrectMetadata && entry && (
|
||||
<CorrectMetadataDialog
|
||||
entry={entry}
|
||||
open={showCorrectMetadata}
|
||||
onOpenChange={setShowCorrectMetadata}
|
||||
onCorrected={() => {
|
||||
setShowCorrectMetadata(false)
|
||||
fetchData()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Correction dialog */}
|
||||
{showCorrection && entry && (
|
||||
<CorrectionEntryDialog
|
||||
|
||||
@@ -170,9 +170,11 @@ export default async function DashboardLayout({
|
||||
// select returns exactly the caller's companies, letting non-active rows
|
||||
// show company_settings.company_name instead of the frozen companies.name.
|
||||
supabase.from('company_settings').select('company_id, company_name'),
|
||||
// Per-user UI state (nav collapse/fold state): server-rendered so the
|
||||
// sidebar width is right on first paint, no post-hydration jump.
|
||||
supabase.from('user_preferences').select('ui_state').eq('user_id', user.id).maybeSingle(),
|
||||
// Per-user UI state (nav collapse/fold state), server-rendered so the
|
||||
// sidebar width is right on first paint, plus the hide-assistant-FAB
|
||||
// preference (Inställningar → Assistenten). Batched here so it costs no
|
||||
// extra round-trip on the dashboard critical path.
|
||||
supabase.from('user_preferences').select('ui_state, hide_assistant_fab').eq('user_id', user.id).maybeSingle(),
|
||||
])
|
||||
|
||||
// company_id -> current display name for every company the user belongs to.
|
||||
@@ -316,7 +318,7 @@ export default async function DashboardLayout({
|
||||
<main id="main-content" className={MAIN_PANEL_CLASS} role="main">
|
||||
<MainContainer companyId={companyId}>{children}</MainContainer>
|
||||
</main>
|
||||
<AgentTrigger />
|
||||
<AgentTrigger hidden={userPrefs?.hide_assistant_fab === true} />
|
||||
<LazyCommandPalette />
|
||||
<SettingsHotkey />
|
||||
{settingsModal}
|
||||
|
||||
@@ -2467,6 +2467,7 @@ export default function TransactionsPage() {
|
||||
onOpenMatchDialog={openMatchDialog}
|
||||
onOpenCategoryDialog={openCategoryDialog}
|
||||
onOpenAttachDocument={openAttachDocumentDialog}
|
||||
onOpenMatchVoucher={openMatchVoucherDialog}
|
||||
onDelete={handleDeleteTransaction}
|
||||
onSkvBokfor={handleSkvBokfor}
|
||||
onSkvMatch={r => setSkvMatchTarget(r)}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Tests for POST /api/bookkeeping/journal-entries/[id]/correct-metadata
|
||||
* (metadata rättelse of a posted verifikat via the audited RPC).
|
||||
*
|
||||
* Covers: 401, validation 400 (empty body / blank description / bad date),
|
||||
* rule-violation 409 passthrough (Swedish RPC messages verbatim), tenant
|
||||
* guard 403, unexpected RPC failure 500, and the happy path.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import {
|
||||
createQueuedMockSupabase,
|
||||
createMockRequest,
|
||||
createMockRouteParams,
|
||||
parseJsonResponse,
|
||||
} from '@/tests/helpers'
|
||||
|
||||
const { supabase, reset } = createQueuedMockSupabase()
|
||||
|
||||
const rpcMock = vi.fn()
|
||||
;(supabase as { rpc?: unknown }).rpc = rpcMock
|
||||
|
||||
const requireAuthMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-auth', () => ({
|
||||
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
|
||||
|
||||
import { POST } from '../route'
|
||||
|
||||
const params = () => createMockRouteParams({ id: 'entry-1' })
|
||||
|
||||
function makeRequest(body: unknown) {
|
||||
return createMockRequest('/api/bookkeeping/journal-entries/entry-1/correct-metadata', {
|
||||
method: 'POST',
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
describe('POST /api/bookkeeping/journal-entries/[id]/correct-metadata', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase })
|
||||
})
|
||||
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
|
||||
const response = await POST(makeRequest({ description: 'Rättad text' }), params())
|
||||
expect(response.status).toBe(401)
|
||||
expect(rpcMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['empty body', {}],
|
||||
['blank description', { description: ' ' }],
|
||||
['bad date', { entry_date: 'inte-ett-datum' }],
|
||||
])('rejects invalid body (%s) with 400', async (_label, body) => {
|
||||
const response = await POST(makeRequest(body), params())
|
||||
expect(response.status).toBe(400)
|
||||
expect(rpcMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('passes rule violations through as 409 with the Swedish message', async () => {
|
||||
rpcMock.mockResolvedValue({
|
||||
data: null,
|
||||
error: { code: 'P0001', message: 'Perioden är stängd eller låst — använd rättelseverifikat (storno).' },
|
||||
})
|
||||
|
||||
const response = await POST(makeRequest({ description: 'Rättad text' }), params())
|
||||
const { body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(response.status).toBe(409)
|
||||
expect(body.error).toContain('låst')
|
||||
})
|
||||
|
||||
it('maps the tenant guard (42501) to 403', async () => {
|
||||
rpcMock.mockResolvedValue({
|
||||
data: null,
|
||||
error: { code: '42501', message: 'unauthorized: caller is not a member of company company-1' },
|
||||
})
|
||||
|
||||
const response = await POST(makeRequest({ description: 'Rättad text' }), params())
|
||||
expect(response.status).toBe(403)
|
||||
})
|
||||
|
||||
it('returns 500 on unexpected RPC failure', async () => {
|
||||
rpcMock.mockResolvedValue({ data: null, error: { code: '57P01', message: 'connection refused' } })
|
||||
|
||||
const response = await POST(makeRequest({ description: 'Rättad text' }), params())
|
||||
expect(response.status).toBe(500)
|
||||
})
|
||||
|
||||
it('corrects description and date via the RPC with the caller as actor (happy path)', async () => {
|
||||
rpcMock.mockResolvedValue({
|
||||
data: {
|
||||
changed: true,
|
||||
log_id: 'log-1',
|
||||
old_description: 'Gamal text',
|
||||
new_description: 'Rättad text',
|
||||
old_entry_date: '2026-07-01',
|
||||
new_entry_date: '2026-07-05',
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
|
||||
const response = await POST(
|
||||
makeRequest({ description: 'Rättad text', entry_date: '2026-07-05' }),
|
||||
params(),
|
||||
)
|
||||
const { body } = await parseJsonResponse<{ data: { changed: boolean; log_id: string } }>(response)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(body.data.changed).toBe(true)
|
||||
expect(rpcMock).toHaveBeenCalledWith('correct_entry_metadata', {
|
||||
p_company_id: 'company-1',
|
||||
p_entry_id: 'entry-1',
|
||||
p_description: 'Rättad text',
|
||||
p_entry_date: '2026-07-05',
|
||||
p_user_id: 'user-1',
|
||||
})
|
||||
})
|
||||
|
||||
it('sends null for omitted fields (description-only edit)', async () => {
|
||||
rpcMock.mockResolvedValue({ data: { changed: true, log_id: 'log-2' }, error: null })
|
||||
|
||||
const response = await POST(makeRequest({ description: 'Bara texten' }), params())
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(rpcMock).toHaveBeenCalledWith(
|
||||
'correct_entry_metadata',
|
||||
expect.objectContaining({ p_description: 'Bara texten', p_entry_date: null }),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { CorrectEntryMetadataSchema } from '@/lib/api/schemas'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
|
||||
/**
|
||||
* POST /api/bookkeeping/journal-entries/[id]/correct-metadata
|
||||
*
|
||||
* Metadata rättelse (BFL 5 kap 9 §): correct the description and/or the
|
||||
* entry date (within the same fiscal period) of a POSTED verifikat, without
|
||||
* a rättelseverifikation. The correct_entry_metadata RPC enforces everything
|
||||
* (posted status, open/unlocked period, company lock date, same-period date,
|
||||
* writer role) and writes the immutable journal_entry_rattelse_log row
|
||||
* before the carve-out UPDATE. Cross-period date moves stay on the
|
||||
* recordate (storno) flow.
|
||||
*/
|
||||
export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
'bookkeeping.journal_entry.correct_metadata',
|
||||
async (request, { supabase, companyId, user, log }, { params }) => {
|
||||
const { id } = await params
|
||||
|
||||
const validation = await validateBody(request, CorrectEntryMetadataSchema)
|
||||
if (!validation.success) return validation.response
|
||||
|
||||
const { description, entry_date } = validation.data
|
||||
|
||||
const { data, error } = await supabase.rpc('correct_entry_metadata', {
|
||||
p_company_id: companyId,
|
||||
p_entry_id: id,
|
||||
p_description: description ?? null,
|
||||
p_entry_date: entry_date ?? null,
|
||||
p_user_id: user.id,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
// Rule violations are plain RAISE EXCEPTION (P0001) with user-facing
|
||||
// Swedish messages: surface verbatim as 409. Tenant guard raises 42501.
|
||||
if (error.code === 'P0001') {
|
||||
return NextResponse.json({ error: getErrorMessage(error) }, { status: 409 })
|
||||
}
|
||||
if (error.code === '42501') {
|
||||
return NextResponse.json({ error: getErrorMessage(error) }, { status: 403 })
|
||||
}
|
||||
log.error('correct_entry_metadata failed', new Error(error.message), { entryId: id })
|
||||
return NextResponse.json({ error: 'Kunde inte rätta verifikationen' }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Tests for GET /api/bookkeeping/journal-entries/[id]/rattelse-log
|
||||
* (the immutable inline rättelse history, BFL 5 kap 5 § / 9 §).
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import {
|
||||
createQueuedMockSupabase,
|
||||
createMockRequest,
|
||||
createMockRouteParams,
|
||||
parseJsonResponse,
|
||||
} from '@/tests/helpers'
|
||||
|
||||
const { supabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
|
||||
const requireAuthMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-auth', () => ({
|
||||
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
|
||||
|
||||
import { GET } from '../route'
|
||||
|
||||
const params = () => createMockRouteParams({ id: 'entry-1' })
|
||||
const makeGet = () =>
|
||||
createMockRequest('/api/bookkeeping/journal-entries/entry-1/rattelse-log', { method: 'GET' })
|
||||
|
||||
describe('GET /api/bookkeeping/journal-entries/[id]/rattelse-log', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase })
|
||||
})
|
||||
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
const response = await GET(makeGet(), params())
|
||||
expect(response.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 404 when the entry belongs to another company', async () => {
|
||||
enqueue({ data: null, error: null }) // ownership check finds nothing
|
||||
|
||||
const response = await GET(makeGet(), params())
|
||||
const { body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
expect(body.error).toContain('hittades inte')
|
||||
})
|
||||
|
||||
it('returns the rättelse rows newest first', async () => {
|
||||
enqueue({ data: { id: 'entry-1' }, error: null }) // ownership check
|
||||
enqueue({
|
||||
data: [
|
||||
{
|
||||
id: 'log-2',
|
||||
rattelse_type: 'lines',
|
||||
old_description: null,
|
||||
new_description: null,
|
||||
old_entry_date: null,
|
||||
new_entry_date: null,
|
||||
struck_lines: [
|
||||
{ id: 'line-1', account_number: '5410', debit_amount: 500, credit_amount: 0, line_description: null, sort_order: 1 },
|
||||
],
|
||||
added_lines: [
|
||||
{ id: 'line-9', account_number: '5420', debit_amount: 500, credit_amount: 0, line_description: null, sort_order: 3 },
|
||||
],
|
||||
actor: 'user-1',
|
||||
created_at: '2026-07-23T12:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'log-1',
|
||||
rattelse_type: 'metadata',
|
||||
old_description: 'Gamal text',
|
||||
new_description: 'Rättad text',
|
||||
old_entry_date: '2026-07-01',
|
||||
new_entry_date: '2026-07-01',
|
||||
struck_lines: null,
|
||||
added_lines: null,
|
||||
actor: 'user-1',
|
||||
created_at: '2026-07-22T12:00:00Z',
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
|
||||
const response = await GET(makeGet(), params())
|
||||
const { body } = await parseJsonResponse<{ data: { id: string; rattelse_type: string }[] }>(response)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(body.data).toHaveLength(2)
|
||||
expect(body.data[0].id).toBe('log-2')
|
||||
expect(body.data[0].rattelse_type).toBe('lines')
|
||||
})
|
||||
|
||||
it('returns 500 with a Swedish message when the query fails', async () => {
|
||||
enqueue({ data: { id: 'entry-1' }, error: null }) // ownership check
|
||||
enqueue({ data: null, error: { message: 'boom' } })
|
||||
|
||||
const response = await GET(makeGet(), params())
|
||||
const { body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(response.status).toBe(500)
|
||||
expect(body.error).toContain('historik')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
|
||||
/**
|
||||
* GET /api/bookkeeping/journal-entries/[id]/rattelse-log
|
||||
*
|
||||
* The entry's inline rättelse history (BFL 5 kap 5 § / 9 §): the immutable
|
||||
* who/when trail behind every metadata edit and line strike, newest first.
|
||||
* Struck lines render with strikethrough in the verifikat detail view from
|
||||
* the struck_lines snapshots here.
|
||||
*/
|
||||
export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
'bookkeeping.journal_entry.rattelse_log',
|
||||
async (_request, { supabase, companyId }, { params }) => {
|
||||
const { id } = await params
|
||||
|
||||
// Ownership gate: 404 for entries outside the caller's company, so the
|
||||
// empty-log response cannot be used to probe entry existence cross-tenant.
|
||||
const { data: entry, error: entryError } = await supabase
|
||||
.from('journal_entries')
|
||||
.select('id')
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
|
||||
if (entryError) {
|
||||
return NextResponse.json({ error: 'Kunde inte hämta rättelsehistorik' }, { status: 500 })
|
||||
}
|
||||
if (!entry) {
|
||||
return NextResponse.json({ error: 'Verifikatet hittades inte' }, { status: 404 })
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('journal_entry_rattelse_log')
|
||||
.select('id, rattelse_type, old_description, new_description, old_entry_date, new_entry_date, struck_lines, added_lines, actor, created_at')
|
||||
.eq('company_id', companyId)
|
||||
.eq('journal_entry_id', id)
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: 'Kunde inte hämta rättelsehistorik' }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: data ?? [] })
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Tests for POST /api/bookkeeping/journal-entries/[id]/strike-lines
|
||||
* (inline line rättelse of a posted verifikat via the audited RPC).
|
||||
*
|
||||
* Covers: 401, validation 400 (empty rättelse / bad account / bad uuid),
|
||||
* rule-violation 409 passthrough (Swedish RPC messages verbatim), tenant
|
||||
* guard 403, unexpected RPC failure 500, the happy path (incl. BAS account
|
||||
* backfill before the RPC) and the strike-only path.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import {
|
||||
createQueuedMockSupabase,
|
||||
createMockRequest,
|
||||
createMockRouteParams,
|
||||
parseJsonResponse,
|
||||
} from '@/tests/helpers'
|
||||
|
||||
const { supabase, reset } = createQueuedMockSupabase()
|
||||
|
||||
const rpcMock = vi.fn()
|
||||
;(supabase as { rpc?: unknown }).rpc = rpcMock
|
||||
|
||||
const backfillMock = vi.fn().mockResolvedValue([])
|
||||
vi.mock('@/lib/bookkeeping/account-backfill', () => ({
|
||||
backfillStandardBASAccounts: (...args: unknown[]) => backfillMock(...args),
|
||||
}))
|
||||
|
||||
const requireAuthMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-auth', () => ({
|
||||
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
|
||||
|
||||
import { POST } from '../route'
|
||||
|
||||
const LINE_ID = '11111111-1111-4111-8111-111111111111'
|
||||
|
||||
const params = () => createMockRouteParams({ id: 'entry-1' })
|
||||
|
||||
function makeRequest(body: unknown) {
|
||||
return createMockRequest('/api/bookkeeping/journal-entries/entry-1/strike-lines', {
|
||||
method: 'POST',
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
describe('POST /api/bookkeeping/journal-entries/[id]/strike-lines', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
backfillMock.mockResolvedValue([])
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase })
|
||||
})
|
||||
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
|
||||
const response = await POST(makeRequest({ strike_line_ids: [LINE_ID] }), params())
|
||||
expect(response.status).toBe(401)
|
||||
expect(rpcMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['empty rättelse', { strike_line_ids: [], lines: [] }],
|
||||
['bad line id', { strike_line_ids: ['not-a-uuid'] }],
|
||||
['bad account number', { lines: [{ account_number: '19', debit_amount: 100 }] }],
|
||||
['negative amount', { lines: [{ account_number: '1930', debit_amount: -5 }] }],
|
||||
])('rejects invalid body (%s) with 400', async (_label, body) => {
|
||||
const response = await POST(makeRequest(body), params())
|
||||
expect(response.status).toBe(400)
|
||||
expect(rpcMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('passes rule violations through as 409 with the Swedish message', async () => {
|
||||
rpcMock.mockResolvedValue({
|
||||
data: null,
|
||||
error: { code: 'P0001', message: 'Verifikationen balanserar inte efter rättelsen (debet 100.00, kredit 0.00).' },
|
||||
})
|
||||
|
||||
const response = await POST(makeRequest({ strike_line_ids: [LINE_ID] }), params())
|
||||
const { body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(response.status).toBe(409)
|
||||
expect(body.error).toContain('balanserar')
|
||||
})
|
||||
|
||||
it('maps the tenant guard (42501) to 403', async () => {
|
||||
rpcMock.mockResolvedValue({
|
||||
data: null,
|
||||
error: { code: '42501', message: 'unauthorized: caller is not a member of company company-1' },
|
||||
})
|
||||
|
||||
const response = await POST(makeRequest({ strike_line_ids: [LINE_ID] }), params())
|
||||
expect(response.status).toBe(403)
|
||||
})
|
||||
|
||||
it('returns 500 on unexpected RPC failure', async () => {
|
||||
rpcMock.mockResolvedValue({ data: null, error: { code: '57P01', message: 'connection refused' } })
|
||||
|
||||
const response = await POST(makeRequest({ strike_line_ids: [LINE_ID] }), params())
|
||||
expect(response.status).toBe(500)
|
||||
})
|
||||
|
||||
it('strikes and replaces via the RPC, backfilling BAS accounts first (happy path)', async () => {
|
||||
rpcMock.mockResolvedValue({
|
||||
data: { log_id: 'log-1', struck_count: 1, added_count: 1, total_debit: 500, total_credit: 500 },
|
||||
error: null,
|
||||
})
|
||||
|
||||
const response = await POST(
|
||||
makeRequest({
|
||||
strike_line_ids: [LINE_ID],
|
||||
lines: [{ account_number: '5420', debit_amount: 500, line_description: 'Programvara' }],
|
||||
}),
|
||||
params(),
|
||||
)
|
||||
const { body } = await parseJsonResponse<{ data: { struck_count: number; added_count: number } }>(response)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(body.data.struck_count).toBe(1)
|
||||
expect(body.data.added_count).toBe(1)
|
||||
expect(backfillMock).toHaveBeenCalledWith(supabase, 'company-1', 'user-1', ['5420'])
|
||||
expect(rpcMock).toHaveBeenCalledWith('correct_entry_lines_inline', {
|
||||
p_company_id: 'company-1',
|
||||
p_entry_id: 'entry-1',
|
||||
p_strike_line_ids: [LINE_ID],
|
||||
p_new_lines: [
|
||||
{
|
||||
account_number: '5420',
|
||||
debit_amount: 500,
|
||||
credit_amount: 0,
|
||||
line_description: 'Programvara',
|
||||
dimensions: {},
|
||||
},
|
||||
],
|
||||
p_user_id: 'user-1',
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts a strike-only rättelse without new lines (skips backfill)', async () => {
|
||||
rpcMock.mockResolvedValue({
|
||||
data: { log_id: 'log-2', struck_count: 2, added_count: 0, total_debit: 100, total_credit: 100 },
|
||||
error: null,
|
||||
})
|
||||
|
||||
const response = await POST(
|
||||
makeRequest({ strike_line_ids: [LINE_ID, '22222222-2222-4222-8222-222222222222'] }),
|
||||
params(),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(backfillMock).not.toHaveBeenCalled()
|
||||
expect(rpcMock).toHaveBeenCalledWith(
|
||||
'correct_entry_lines_inline',
|
||||
expect.objectContaining({ p_new_lines: [] }),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { StrikeLinesSchema } from '@/lib/api/schemas'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { backfillStandardBASAccounts } from '@/lib/bookkeeping/account-backfill'
|
||||
|
||||
/**
|
||||
* POST /api/bookkeeping/journal-entries/[id]/strike-lines
|
||||
*
|
||||
* Inline line rättelse (BFL 5 kap 5 §): strike lines inside a POSTED
|
||||
* verifikat and add replacement lines in the same verifikat, without a
|
||||
* rättelseverifikation. The correct_entry_lines_inline RPC enforces the full
|
||||
* envelope (posted status, open/unlocked period, company lock date, effective
|
||||
* balance to the öre, ≥2 remaining lines, writer role) and snapshots the
|
||||
* struck originals to the immutable journal_entry_rattelse_log. Past a
|
||||
* lock/close, the storno correction flow remains the only path.
|
||||
*/
|
||||
export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
'bookkeeping.journal_entry.strike_lines',
|
||||
async (request, { supabase, companyId, user, log }, { params }) => {
|
||||
const { id } = await params
|
||||
|
||||
const validation = await validateBody(request, StrikeLinesSchema)
|
||||
if (!validation.success) return validation.response
|
||||
|
||||
const { strike_line_ids, lines } = validation.data
|
||||
|
||||
// Seed standard BAS accounts the replacement lines reference but the
|
||||
// company chart lacks (same courtesy as the engine/storno flow); unknown
|
||||
// numbers stay missing and fail the RPC's chart check with a clear error.
|
||||
const accountNumbers = [...new Set(lines.map((l) => l.account_number))]
|
||||
if (accountNumbers.length > 0) {
|
||||
await backfillStandardBASAccounts(supabase, companyId, user.id, accountNumbers)
|
||||
}
|
||||
|
||||
const { data, error } = await supabase.rpc('correct_entry_lines_inline', {
|
||||
p_company_id: companyId,
|
||||
p_entry_id: id,
|
||||
p_strike_line_ids: strike_line_ids,
|
||||
p_new_lines: lines.map((l) => ({
|
||||
account_number: l.account_number,
|
||||
debit_amount: l.debit_amount,
|
||||
credit_amount: l.credit_amount,
|
||||
line_description: l.line_description ?? null,
|
||||
dimensions: l.dimensions ?? {},
|
||||
})),
|
||||
p_user_id: user.id,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
// Rule violations are plain RAISE EXCEPTION (P0001) with user-facing
|
||||
// Swedish messages: surface verbatim as 409. Tenant guard raises 42501.
|
||||
if (error.code === 'P0001') {
|
||||
return NextResponse.json({ error: getErrorMessage(error) }, { status: 409 })
|
||||
}
|
||||
if (error.code === '42501') {
|
||||
return NextResponse.json({ error: getErrorMessage(error) }, { status: 403 })
|
||||
}
|
||||
// Defensive: the RPC pre-checks document links, but if the RESTRICT FK
|
||||
// still fires (racing attachment), surface the same guidance.
|
||||
if (error.code === '23503') {
|
||||
return NextResponse.json(
|
||||
{ error: 'En rad som ska strykas har ett kopplat underlag — använd rättelseverifikat (storno).' },
|
||||
{ status: 409 },
|
||||
)
|
||||
}
|
||||
log.error('correct_entry_lines_inline failed', new Error(error.message), { entryId: id })
|
||||
return NextResponse.json({ error: 'Kunde inte rätta verifikationen' }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Tests for GET /api/bookkeeping/journal-entries/rattelse-flags
|
||||
* (which entries carry inline rättelser, for the list "Rättad" marker).
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import {
|
||||
createQueuedMockSupabase,
|
||||
createMockRequest,
|
||||
parseJsonResponse,
|
||||
} from '@/tests/helpers'
|
||||
|
||||
const { supabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
|
||||
const requireAuthMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-auth', () => ({
|
||||
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
|
||||
|
||||
import { GET } from '../route'
|
||||
|
||||
const makeGet = (ids: string) =>
|
||||
createMockRequest(`/api/bookkeeping/journal-entries/rattelse-flags?ids=${ids}`, { method: 'GET' })
|
||||
|
||||
describe('GET /api/bookkeeping/journal-entries/rattelse-flags', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase })
|
||||
})
|
||||
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
const response = await GET(makeGet('a'), undefined as never)
|
||||
expect(response.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns an empty list without querying when no ids are given', async () => {
|
||||
const response = await GET(makeGet(''), undefined as never)
|
||||
const { body } = await parseJsonResponse<{ data: string[] }>(response)
|
||||
expect(response.status).toBe(200)
|
||||
expect(body.data).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects more than 200 ids with 400', async () => {
|
||||
const ids = Array.from({ length: 201 }, (_, i) => `id-${i}`).join(',')
|
||||
const response = await GET(makeGet(ids), undefined as never)
|
||||
expect(response.status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns the distinct entry ids that have rättelser', async () => {
|
||||
enqueue({
|
||||
data: [
|
||||
{ journal_entry_id: 'entry-1' },
|
||||
{ journal_entry_id: 'entry-1' },
|
||||
{ journal_entry_id: 'entry-3' },
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
|
||||
const response = await GET(makeGet('entry-1,entry-2,entry-3'), undefined as never)
|
||||
const { body } = await parseJsonResponse<{ data: string[] }>(response)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(body.data.sort()).toEqual(['entry-1', 'entry-3'])
|
||||
})
|
||||
|
||||
it('returns 500 with a Swedish message when the query fails', async () => {
|
||||
enqueue({ data: null, error: { message: 'boom' } })
|
||||
|
||||
const response = await GET(makeGet('entry-1'), undefined as never)
|
||||
expect(response.status).toBe(500)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
|
||||
/**
|
||||
* GET /api/bookkeeping/journal-entries/rattelse-flags?ids=a,b,c
|
||||
*
|
||||
* Which of the given entries have inline rättelser (rows in
|
||||
* journal_entry_rattelse_log). Drives the "Rättad" marker in the voucher
|
||||
* list so a rättelse is discoverable without opening the verifikat
|
||||
* (BFL 5 kap 5 §: the correction must be easy to become aware of).
|
||||
* Capped at 200 ids per request (one list page).
|
||||
*/
|
||||
export const GET = withRouteContext(
|
||||
'bookkeeping.journal_entries.rattelse_flags',
|
||||
async (request, { supabase, companyId }) => {
|
||||
const idsParam = new URL(request.url).searchParams.get('ids') ?? ''
|
||||
const ids = idsParam.split(',').map((s) => s.trim()).filter(Boolean)
|
||||
|
||||
if (ids.length === 0) {
|
||||
return NextResponse.json({ data: [] })
|
||||
}
|
||||
if (ids.length > 200) {
|
||||
return NextResponse.json({ error: 'Högst 200 verifikat per anrop.' }, { status: 400 })
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('journal_entry_rattelse_log')
|
||||
.select('journal_entry_id')
|
||||
.eq('company_id', companyId)
|
||||
.in('journal_entry_id', ids)
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: 'Kunde inte hämta rättelsemarkeringar' }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: [...new Set((data ?? []).map((r) => r.journal_entry_id))] })
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Tests for the nightly Stripe balance-transaction sync cron: auth, config
|
||||
* short-circuit, per-connection processing with capability gating, and
|
||||
* isolated per-connection failures.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
const verifyCronSecret = vi.fn<(request: Request) => Response | null>(() => null)
|
||||
vi.mock('@/lib/auth/cron', () => ({
|
||||
verifyCronSecret: (request: Request) => verifyCronSecret(request),
|
||||
}))
|
||||
|
||||
const connectionsResult: { data: unknown; error: unknown } = { data: null, error: null }
|
||||
vi.mock('@supabase/supabase-js', () => ({
|
||||
createClient: vi.fn(() => ({
|
||||
from: vi.fn(() => {
|
||||
const chain: Record<string, unknown> = {}
|
||||
for (const method of ['select', 'eq', 'order', 'limit']) {
|
||||
chain[method] = vi.fn(() => chain)
|
||||
}
|
||||
chain.then = (resolve: (v: unknown) => unknown) =>
|
||||
Promise.resolve(connectionsResult).then(resolve)
|
||||
return chain
|
||||
}),
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/entitlements/has-capability', () => ({
|
||||
hasCapability: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/extensions/general/stripe/lib/transaction-sync', () => ({
|
||||
syncStripeBalanceTransactions: vi.fn(),
|
||||
}))
|
||||
|
||||
import { hasCapability } from '@/lib/entitlements/has-capability'
|
||||
import { syncStripeBalanceTransactions } from '@/extensions/general/stripe/lib/transaction-sync'
|
||||
import { GET } from '../route'
|
||||
|
||||
function cronRequest(): Request {
|
||||
return new Request('http://localhost:3000/api/extensions/stripe/transactions/cron')
|
||||
}
|
||||
|
||||
function makeConnection(id: string, companyId: string) {
|
||||
return {
|
||||
id,
|
||||
company_id: companyId,
|
||||
user_id: 'user-1',
|
||||
stripe_account_id: `acct_${id}`,
|
||||
status: 'active',
|
||||
transaction_sync_enabled: true,
|
||||
last_balance_txn_synced_at: null,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
verifyCronSecret.mockReturnValue(null)
|
||||
connectionsResult.data = null
|
||||
connectionsResult.error = null
|
||||
vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', 'https://example.supabase.co')
|
||||
vi.stubEnv('SUPABASE_SERVICE_ROLE_KEY', 'service-role-key')
|
||||
vi.stubEnv('STRIPE_SECRET_KEY', 'sk_test_123')
|
||||
vi.stubEnv('STRIPE_CONNECT_CLIENT_ID', 'ca_123')
|
||||
vi.mocked(hasCapability).mockResolvedValue(true)
|
||||
vi.mocked(syncStripeBalanceTransactions).mockResolvedValue({
|
||||
fetched: 3,
|
||||
imported: 2,
|
||||
duplicates: 1,
|
||||
linked: 1,
|
||||
errors: 0,
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
describe('GET /api/extensions/stripe/transactions/cron', () => {
|
||||
it('rejects requests without a valid cron secret', async () => {
|
||||
verifyCronSecret.mockReturnValue(
|
||||
new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 }),
|
||||
)
|
||||
|
||||
const response = await GET(cronRequest())
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(vi.mocked(syncStripeBalanceTransactions)).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('no-ops when Stripe Connect is not configured', async () => {
|
||||
vi.stubEnv('STRIPE_CONNECT_CLIENT_ID', '')
|
||||
|
||||
const response = await GET(cronRequest())
|
||||
const json = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(json).toEqual({ message: 'Stripe Connect not configured', processed: 0 })
|
||||
})
|
||||
|
||||
it('no-ops when no connection has transaction sync enabled', async () => {
|
||||
connectionsResult.data = []
|
||||
|
||||
const response = await GET(cronRequest())
|
||||
const json = await response.json()
|
||||
|
||||
expect(json.processed).toBe(0)
|
||||
expect(vi.mocked(syncStripeBalanceTransactions)).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('syncs entitled connections and aggregates totals', async () => {
|
||||
connectionsResult.data = [
|
||||
makeConnection('conn-1', 'company-1'),
|
||||
makeConnection('conn-2', 'company-2'),
|
||||
]
|
||||
|
||||
const response = await GET(cronRequest())
|
||||
const json = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(vi.mocked(syncStripeBalanceTransactions)).toHaveBeenCalledTimes(2)
|
||||
expect(json.processed).toBe(2)
|
||||
expect(json.imported).toBe(4)
|
||||
expect(json.linked).toBe(2)
|
||||
expect(json.results).toEqual([
|
||||
{ connectionId: 'conn-1', imported: 2, duplicates: 1, linked: 1, status: 'synced' },
|
||||
{ connectionId: 'conn-2', imported: 2, duplicates: 1, linked: 1, status: 'synced' },
|
||||
])
|
||||
})
|
||||
|
||||
it('skips connections whose company lacks the stripe_payments capability', async () => {
|
||||
connectionsResult.data = [
|
||||
makeConnection('conn-1', 'company-1'),
|
||||
makeConnection('conn-2', 'company-2'),
|
||||
]
|
||||
vi.mocked(hasCapability).mockImplementation(async (_sb, companyId) =>
|
||||
companyId !== 'company-1',
|
||||
)
|
||||
|
||||
const response = await GET(cronRequest())
|
||||
const json = await response.json()
|
||||
|
||||
expect(vi.mocked(syncStripeBalanceTransactions)).toHaveBeenCalledTimes(1)
|
||||
expect(json.processed).toBe(1)
|
||||
expect(json.results[0].connectionId).toBe('conn-2')
|
||||
})
|
||||
|
||||
it('isolates a failing connection and keeps processing the rest', async () => {
|
||||
connectionsResult.data = [
|
||||
makeConnection('conn-1', 'company-1'),
|
||||
makeConnection('conn-2', 'company-2'),
|
||||
]
|
||||
vi.mocked(syncStripeBalanceTransactions)
|
||||
.mockRejectedValueOnce(new Error('stripe boom'))
|
||||
.mockResolvedValueOnce({ fetched: 1, imported: 1, duplicates: 0, linked: 0, errors: 0 })
|
||||
|
||||
const response = await GET(cronRequest())
|
||||
const json = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(json.processed).toBe(2)
|
||||
expect(json.results).toEqual([
|
||||
{ connectionId: 'conn-1', imported: 0, duplicates: 0, linked: 0, status: 'error' },
|
||||
{ connectionId: 'conn-2', imported: 1, duplicates: 0, linked: 0, status: 'synced' },
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,130 @@
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { withCronContext } from '@/lib/api/with-cron-context'
|
||||
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { hasCapability } from '@/lib/entitlements/has-capability'
|
||||
import { CAPABILITY } from '@/lib/entitlements/keys'
|
||||
import { syncStripeBalanceTransactions } from '@/extensions/general/stripe/lib/transaction-sync'
|
||||
import type { StripeConnection } from '@/extensions/general/stripe/types'
|
||||
|
||||
export const maxDuration = 300
|
||||
|
||||
/**
|
||||
* GET /api/extensions/stripe/transactions/cron
|
||||
* Nightly balance-transaction sync for connections that opted in
|
||||
* (transaction_sync_enabled): imports the connected Stripe balance into the
|
||||
* transactions inbox as a bank-style feed on the 1686 cash account.
|
||||
*
|
||||
* Read-only against Stripe, and it never posts to the journal: rows land
|
||||
* unbooked (or pre-linked to entries the deterministic settle/payout flows
|
||||
* already created); booking stays a human decision. Idempotent via the
|
||||
* (company_id, external_id) unique index, so overlapping windows and re-runs
|
||||
* are no-ops. Unlike the 15-minute event sync, this does not emit events, so
|
||||
* no ensureInitialized() is needed.
|
||||
*/
|
||||
export const GET = withCronContext('cron.stripe_transaction_sync', async (_request, ctx) => {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
|
||||
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY
|
||||
|
||||
if (!supabaseUrl || !supabaseServiceKey) {
|
||||
return errorResponseFromCode('INTERNAL_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { reason: 'Missing Supabase configuration' },
|
||||
})
|
||||
}
|
||||
if (!process.env.STRIPE_SECRET_KEY || !process.env.STRIPE_CONNECT_CLIENT_ID) {
|
||||
return NextResponse.json({ message: 'Stripe Connect not configured', processed: 0 })
|
||||
}
|
||||
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceKey)
|
||||
|
||||
const { data: connections, error: connError } = await supabase
|
||||
.from('stripe_connections')
|
||||
.select('*')
|
||||
.eq('status', 'active')
|
||||
.eq('transaction_sync_enabled', true)
|
||||
.order('last_balance_txn_synced_at', { ascending: true, nullsFirst: true })
|
||||
.limit(50)
|
||||
|
||||
if (connError) {
|
||||
ctx.log.error('failed to fetch stripe connections', connError, {
|
||||
message: connError.message,
|
||||
code: connError.code,
|
||||
})
|
||||
return errorResponse(connError, ctx.log, { requestId: ctx.requestId })
|
||||
}
|
||||
|
||||
if (!connections || connections.length === 0) {
|
||||
return NextResponse.json({ message: 'No connections with transaction sync enabled', processed: 0 })
|
||||
}
|
||||
|
||||
const startTime = Date.now()
|
||||
const TIME_BUDGET_MS = 240_000 // leave a minute of margin inside maxDuration
|
||||
// Shared with syncStripeBalanceTransactions: it stops between ingest chunks
|
||||
// and persists its cursor, so a truncated connection resumes next night.
|
||||
const deadlineMs = startTime + TIME_BUDGET_MS
|
||||
|
||||
const results: Array<{
|
||||
connectionId: string
|
||||
imported: number
|
||||
duplicates: number
|
||||
linked: number
|
||||
status: 'synced' | 'revoked' | 'error'
|
||||
}> = []
|
||||
|
||||
for (const connection of connections as StripeConnection[]) {
|
||||
if (Date.now() >= deadlineMs) {
|
||||
ctx.log.info('time budget reached', { processedSoFar: results.length })
|
||||
break
|
||||
}
|
||||
|
||||
if (!(await hasCapability(supabase, connection.company_id, CAPABILITY.stripe_payments))) {
|
||||
ctx.log.info('skip: capability not entitled', { companyId: connection.company_id })
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const summary = await syncStripeBalanceTransactions(supabase, connection, ctx.log, deadlineMs)
|
||||
if (summary.deadlineReached) {
|
||||
ctx.log.info('connection stopped early on time budget; remaining rows resume next run', {
|
||||
connectionId: connection.id,
|
||||
})
|
||||
}
|
||||
results.push({
|
||||
connectionId: connection.id,
|
||||
imported: summary.imported,
|
||||
duplicates: summary.duplicates,
|
||||
linked: summary.linked,
|
||||
status: summary.revoked ? 'revoked' : 'synced',
|
||||
})
|
||||
} catch (error) {
|
||||
ctx.log.error('stripe transaction sync failed for connection', error as Error, {
|
||||
connectionId: connection.id,
|
||||
companyId: connection.company_id,
|
||||
})
|
||||
results.push({
|
||||
connectionId: connection.id,
|
||||
imported: 0,
|
||||
duplicates: 0,
|
||||
linked: 0,
|
||||
status: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const totals = results.reduce(
|
||||
(acc, r) => ({
|
||||
imported: acc.imported + r.imported,
|
||||
linked: acc.linked + r.linked,
|
||||
}),
|
||||
{ imported: 0, linked: 0 },
|
||||
)
|
||||
ctx.log.info('stripe transaction sync summary', {
|
||||
processed: results.length,
|
||||
totalImported: totals.imported,
|
||||
totalLinked: totals.linked,
|
||||
failed: results.filter((r) => r.status === 'error').length,
|
||||
})
|
||||
|
||||
return NextResponse.json({ processed: results.length, ...totals, results })
|
||||
})
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { parseJsonResponse } from '@/tests/helpers'
|
||||
|
||||
const requireAuthMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-auth', () => ({
|
||||
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
|
||||
}))
|
||||
|
||||
import { GET, PATCH } from '../route'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
function unauthed() {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: null,
|
||||
supabase: null,
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
}
|
||||
|
||||
function authedForGet(row: { hide_assistant_fab: boolean } | null) {
|
||||
const maybeSingle = vi.fn().mockResolvedValue({ data: row, error: null })
|
||||
const supabase = {
|
||||
from: vi.fn(() => ({
|
||||
select: vi.fn(() => ({
|
||||
eq: vi.fn(() => ({ maybeSingle })),
|
||||
})),
|
||||
})),
|
||||
}
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
|
||||
return { supabase }
|
||||
}
|
||||
|
||||
function authedForPatch(upsertError: { message: string } | null = null) {
|
||||
const upsert = vi.fn().mockResolvedValue({ error: upsertError })
|
||||
const supabase = { from: vi.fn(() => ({ upsert })) }
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
|
||||
return { upsert }
|
||||
}
|
||||
|
||||
function patchRequest(body: unknown) {
|
||||
return new Request('http://localhost/api/user/preferences', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
describe('GET /api/user/preferences', () => {
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
unauthed()
|
||||
const res = await GET()
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns the stored preference', async () => {
|
||||
authedForGet({ hide_assistant_fab: true })
|
||||
const res = await GET()
|
||||
const { status, body } = await parseJsonResponse<{ data: unknown }>(res)
|
||||
expect(status).toBe(200)
|
||||
expect(body.data).toEqual({ hide_assistant_fab: true })
|
||||
})
|
||||
|
||||
it('defaults to false when no preferences row exists', async () => {
|
||||
authedForGet(null)
|
||||
const res = await GET()
|
||||
const { body } = await parseJsonResponse<{ data: unknown }>(res)
|
||||
expect(body.data).toEqual({ hide_assistant_fab: false })
|
||||
})
|
||||
})
|
||||
|
||||
describe('PATCH /api/user/preferences', () => {
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
unauthed()
|
||||
const res = await PATCH(patchRequest({ hide_assistant_fab: true }))
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('rejects an invalid body with 400', async () => {
|
||||
authedForPatch()
|
||||
const res = await PATCH(patchRequest({ hide_assistant_fab: 'yes' }))
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('rejects unknown keys with 400', async () => {
|
||||
authedForPatch()
|
||||
const res = await PATCH(patchRequest({ hide_assistant_fab: true, locale: 'en' }))
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('upserts the preference for the authenticated user', async () => {
|
||||
const { upsert } = authedForPatch()
|
||||
const res = await PATCH(patchRequest({ hide_assistant_fab: true }))
|
||||
const { status, body } = await parseJsonResponse<{ data: unknown }>(res)
|
||||
expect(status).toBe(200)
|
||||
expect(body.data).toEqual({ hide_assistant_fab: true })
|
||||
expect(upsert).toHaveBeenCalledWith(
|
||||
{ user_id: 'user-1', hide_assistant_fab: true },
|
||||
{ onConflict: 'user_id' }
|
||||
)
|
||||
})
|
||||
|
||||
it('returns 500 when the upsert fails', async () => {
|
||||
authedForPatch({ message: 'boom' })
|
||||
const res = await PATCH(patchRequest({ hide_assistant_fab: false }))
|
||||
expect(res.status).toBe(500)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { requireAuth } from '@/lib/auth/require-auth'
|
||||
|
||||
// User-level UI preferences (not company-scoped), stored on user_preferences.
|
||||
// Mirrors the /api/user/locale pattern: requireAuth directly because these
|
||||
// must work even when the user has no active company.
|
||||
|
||||
const BodySchema = z
|
||||
.object({
|
||||
hide_assistant_fab: z.boolean(),
|
||||
})
|
||||
.strict()
|
||||
|
||||
export async function GET() {
|
||||
const { user, supabase, error } = await requireAuth()
|
||||
if (error) return error
|
||||
|
||||
const { data } = await supabase
|
||||
.from('user_preferences')
|
||||
.select('hide_assistant_fab')
|
||||
.eq('user_id', user.id)
|
||||
.maybeSingle()
|
||||
|
||||
return NextResponse.json({
|
||||
data: { hide_assistant_fab: data?.hide_assistant_fab ?? false },
|
||||
})
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request) {
|
||||
const { user, supabase, error } = await requireAuth()
|
||||
if (error) return error
|
||||
|
||||
let body: unknown
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
|
||||
}
|
||||
|
||||
const parsed = BodySchema.safeParse(body)
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'Invalid preferences' }, { status: 400 })
|
||||
}
|
||||
|
||||
const { error: upsertError } = await supabase
|
||||
.from('user_preferences')
|
||||
.upsert(
|
||||
{ user_id: user.id, hide_assistant_fab: parsed.data.hide_assistant_fab },
|
||||
{ onConflict: 'user_id' }
|
||||
)
|
||||
|
||||
if (upsertError) {
|
||||
return NextResponse.json({ error: 'Could not save preference' }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: parsed.data })
|
||||
}
|
||||
@@ -26,12 +26,18 @@ import { CAPABILITY } from '@/lib/entitlements/keys'
|
||||
// row-level "Fråga [namn]" button in TransactionInboxCard, and the matching
|
||||
// "Fråga assistenten" in Dokumentinkorgen: both passing a transaction_id the
|
||||
// pathname-only FAB can't know.)
|
||||
export default function AgentTrigger() {
|
||||
export default function AgentTrigger({ hidden = false }: { hidden?: boolean }) {
|
||||
const { openAgentSheet, expandAgentSheet, isOpen, collapsed, identity } = useAgentSheet()
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const hasAi = useCapability(CAPABILITY.ai)
|
||||
|
||||
// User opt-out (Inställningar → Assistenten): the sidebar entry stays, the
|
||||
// floating button goes. A collapsed session keeps its reopen handle even
|
||||
// when hidden: it's the only way back to a minimized conversation, and its
|
||||
// existence implies the user is actively using the assistant right now.
|
||||
if (hidden && !collapsed) return null
|
||||
|
||||
// Sheet open AND visible → hide the FAB so the icon doesn't double up. When
|
||||
// the session is merely collapsed we KEEP the FAB: it's the handle that
|
||||
// brings the minimized conversation back.
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import type { JournalEntry } from '@/types'
|
||||
|
||||
interface Props {
|
||||
entry: JournalEntry
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onCorrected: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata rättelse (BFL 5 kap 9 §): correct the verifikationstext and/or
|
||||
* the date (within the same fiscal period) of a posted verifikat without an
|
||||
* ändringsverifikation. Who/when is recorded in the immutable rättelse log
|
||||
* and shown in the verifikat's history. Stays Swedish (verifikat surface,
|
||||
* .claude/rules/i18n.md).
|
||||
*/
|
||||
export default function CorrectMetadataDialog({ entry, open, onOpenChange, onCorrected }: Props) {
|
||||
const { toast } = useToast()
|
||||
const [description, setDescription] = useState('')
|
||||
const [entryDate, setEntryDate] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setDescription(entry.description || '')
|
||||
setEntryDate(entry.entry_date?.slice(0, 10) || '')
|
||||
}
|
||||
}, [open, entry.id]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const descriptionChanged = description.trim() !== (entry.description || '')
|
||||
const dateChanged = entryDate !== (entry.entry_date?.slice(0, 10) || '')
|
||||
const hasChange = (descriptionChanged && description.trim().length > 0) || (dateChanged && entryDate.length > 0)
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!hasChange) return
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const payload: { description?: string; entry_date?: string } = {}
|
||||
if (descriptionChanged && description.trim().length > 0) payload.description = description.trim()
|
||||
if (dateChanged && entryDate.length > 0) payload.entry_date = entryDate
|
||||
|
||||
const res = await fetch(`/api/bookkeeping/journal-entries/${entry.id}/correct-metadata`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
const result = await res.json()
|
||||
if (!res.ok) {
|
||||
const error = new Error('Failed to correct metadata') as Error & { body?: unknown; status?: number }
|
||||
error.body = result
|
||||
error.status = res.status
|
||||
throw error
|
||||
}
|
||||
toast({
|
||||
title: 'Verifikationen rättad',
|
||||
description: 'Ändringen har loggats i verifikatets rättelsehistorik.',
|
||||
})
|
||||
onOpenChange(false)
|
||||
onCorrected()
|
||||
} catch (err) {
|
||||
const anyErr = err as { body?: unknown; status?: number }
|
||||
toast({
|
||||
title: 'Kunde inte rätta verifikationen',
|
||||
description: getErrorMessage(anyErr.body ?? err, { context: 'journal_entry', statusCode: anyErr.status }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Ändra text eller datum</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="rounded-lg bg-muted/50 border p-3 text-sm text-muted-foreground">
|
||||
<p>
|
||||
Verifikationstexten och datumet kan rättas utan ändringsverifikation. Rättelsen loggas
|
||||
med vem och när, och det gamla värdet förblir synligt i rättelsehistoriken. Datumet kan
|
||||
bara flyttas inom samma bokföringsperiod: använd "Flytta till annat datum" för att
|
||||
byta period. Om månaden redan är momsdeklarerad kan en datumflytt påverka den inlämnade
|
||||
deklarationen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="rattelse-description">Verifikationstext</Label>
|
||||
<Input
|
||||
id="rattelse-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
maxLength={500}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="rattelse-date">Datum</Label>
|
||||
<Input
|
||||
id="rattelse-date"
|
||||
type="date"
|
||||
value={entryDate}
|
||||
onChange={(e) => setEntryDate(e.target.value)}
|
||||
disabled={['storno', 'opening_balance', 'year_end'].includes(entry.source_type)}
|
||||
/>
|
||||
{['storno', 'opening_balance', 'year_end'].includes(entry.source_type) && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Datumet på den här verifikationstypen kan inte ändras.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
|
||||
Avbryt
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={!hasChange || isSubmitting}>
|
||||
{isSubmitting ? 'Rättar...' : 'Spara rättelse'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
} from '@/lib/hooks/use-submit-with-account-activation'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import { formatVoucher, resolveDefaultSeriesForSource } from '@/lib/bookkeeping/voucher-series-resolver'
|
||||
import { useUnsavedChanges } from '@/lib/hooks/use-unsaved-changes'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
@@ -357,8 +358,9 @@ export default function JournalEntryForm({
|
||||
)
|
||||
|
||||
// Fetch per-account saldo as of entryDate for the accounts currently on the
|
||||
// form. Balances are reference-only ("saldo before this entry"): they ignore
|
||||
// the draft lines the user is typing, by design.
|
||||
// form. The fetched value is always "saldo before this entry"; the render
|
||||
// layer adds the typed draft amounts on top (draftDeltas) so the column
|
||||
// shows where the account is heading.
|
||||
useEffect(() => {
|
||||
if (!accountsKey) {
|
||||
setAccountBalances({})
|
||||
@@ -414,6 +416,22 @@ export default function JournalEntryForm({
|
||||
}
|
||||
}, [accountsKey, entryDate])
|
||||
|
||||
// What the typed-but-unposted rows would do to each account's saldo. The
|
||||
// /account-balances convention is debit-positive for every class, so
|
||||
// delta = debit - credit encodes direction without needing the account type:
|
||||
// rendering "before -> after" gives instant feedback on whether the chosen
|
||||
// side increases or decreases the account.
|
||||
const draftDeltas = useMemo(() => {
|
||||
const deltas: Record<string, number> = {}
|
||||
for (const l of lines) {
|
||||
if (!/^\d{4}$/.test(l.account_number)) continue
|
||||
const delta = (parseFloat(l.debit_amount) || 0) - (parseFloat(l.credit_amount) || 0)
|
||||
if (delta === 0) continue
|
||||
deltas[l.account_number] = roundOre((deltas[l.account_number] ?? 0) + delta)
|
||||
}
|
||||
return deltas
|
||||
}, [lines])
|
||||
|
||||
// New rows inherit the current header default (a row without a per-row
|
||||
// override follows the header (see setHeaderDimension).
|
||||
const makeBlankLine = useCallback(
|
||||
@@ -1446,9 +1464,24 @@ export default function JournalEntryForm({
|
||||
{accountBalances[line.account_number] === null || accountBalances[line.account_number] === undefined ? (
|
||||
<Skeleton className="h-3 w-20" />
|
||||
) : (
|
||||
<span>
|
||||
{t('saldo_label')} {formatCurrency(accountBalances[line.account_number] as number)}
|
||||
</span>
|
||||
(() => {
|
||||
const bal = accountBalances[line.account_number] as number
|
||||
const delta = draftDeltas[line.account_number]
|
||||
if (!delta) {
|
||||
return (
|
||||
<span>
|
||||
{t('saldo_label')} {formatCurrency(bal)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
const after = roundOre(bal + delta)
|
||||
return (
|
||||
<span>
|
||||
{t('saldo_label')} {formatCurrency(bal)}{' '}
|
||||
<span className="text-foreground">→ {formatCurrency(after)}</span>
|
||||
</span>
|
||||
)
|
||||
})()
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -1579,7 +1612,15 @@ export default function JournalEntryForm({
|
||||
if (bal === null || bal === undefined) {
|
||||
return <Skeleton className="h-4 w-20 ml-auto" />
|
||||
}
|
||||
return formatCurrency(bal)
|
||||
const delta = draftDeltas[line.account_number]
|
||||
if (!delta) return formatCurrency(bal)
|
||||
const after = roundOre(bal + delta)
|
||||
return (
|
||||
<span className="inline-flex flex-col items-end leading-tight">
|
||||
<span className="text-[11px]">{formatCurrency(bal)}</span>
|
||||
<span className="text-foreground">→ {formatCurrency(after)}</span>
|
||||
</span>
|
||||
)
|
||||
})()}
|
||||
</td>
|
||||
<td className="py-1.5">
|
||||
|
||||
@@ -103,6 +103,10 @@ export default function JournalEntryList() {
|
||||
const [count, setCount] = useState(0)
|
||||
const [page, setPage] = useState(0)
|
||||
const [attachmentCounts, setAttachmentCounts] = useState<Record<string, number>>({})
|
||||
// Entries with inline rättelser (journal_entry_rattelse_log rows): drives
|
||||
// the "Rättad" marker so a rättelse is discoverable from the list
|
||||
// (BFL 5 kap 5 §), not only on the detail page.
|
||||
const [rattelseFlags, setRattelseFlags] = useState<Set<string>>(new Set())
|
||||
const [noDocRequired, setNoDocRequired] = useState<Map<string, string | null>>(new Map())
|
||||
const [showMissingOnly, setShowMissingOnly] = useState(false)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
@@ -212,6 +216,23 @@ export default function JournalEntryList() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchRattelseFlags = useCallback(async (entryIds: string[]) => {
|
||||
if (entryIds.length === 0) {
|
||||
setRattelseFlags(new Set())
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/bookkeeping/journal-entries/rattelse-flags?ids=${entryIds.join(',')}`
|
||||
)
|
||||
if (!res.ok) return
|
||||
const { data } = await res.json()
|
||||
setRattelseFlags(new Set((data || []) as string[]))
|
||||
} catch {
|
||||
// Non-critical: silently ignore
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchNoDocRequired = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/bookkeeping/no-doc-required')
|
||||
@@ -371,9 +392,10 @@ export default function JournalEntryList() {
|
||||
}
|
||||
setLoading(false)
|
||||
|
||||
// Fetch attachment counts for the loaded entries
|
||||
// Fetch attachment counts + rättelse markers for the loaded entries
|
||||
const ids = loadedEntries.map((e: JournalEntry) => e.id)
|
||||
fetchAttachmentCounts(ids)
|
||||
fetchRattelseFlags(ids)
|
||||
}
|
||||
|
||||
// Cheap count-only query for the "Utkast" badge, all years, so the badge
|
||||
@@ -1103,6 +1125,15 @@ export default function JournalEntryList() {
|
||||
{(entry.status === 'reversed' || entry.status === 'draft' || entry.source_type === 'storno' || entry.source_type === 'correction') && (
|
||||
<JournalEntryStatusBadge entry={entry} showStatus={entry.status === 'reversed' || entry.status === 'draft'} />
|
||||
)}
|
||||
{rattelseFlags.has(entry.id) && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs font-normal shrink-0"
|
||||
title={t('rattelse_badge_tooltip')}
|
||||
>
|
||||
{t('rattelse_badge')}
|
||||
</Badge>
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right tabular-nums rr-mask')}>
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
|
||||
import { AccountNumber } from '@/components/ui/account-number'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { changeCorrectionLineAccount, getSelectableCorrectionCatalog } from '@/lib/bookkeeping/correction-line-account'
|
||||
import { loadBasCatalog, type CatalogAccount } from '@/lib/bookkeeping/bas-catalog-client'
|
||||
import { Loader2, Plus, Trash2 } from 'lucide-react'
|
||||
import type { JournalEntry, JournalEntryLine, BASAccount } from '@/types'
|
||||
|
||||
interface NewLine {
|
||||
account_number: string
|
||||
debit_amount: string
|
||||
credit_amount: string
|
||||
line_description: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
entry: JournalEntry
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onCorrected: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline line rättelse (BFL 5 kap 5 §): strike lines in a posted verifikat
|
||||
* and add replacement lines in the SAME verifikat, without an
|
||||
* ändringsverifikation. The struck originals stay visible (strikethrough)
|
||||
* in the verifikat via the immutable rättelse log. Stays Swedish
|
||||
* (verifikat surface, .claude/rules/i18n.md).
|
||||
*/
|
||||
export default function StrikeLinesDialog({ entry, open, onOpenChange, onCorrected }: Props) {
|
||||
const { toast } = useToast()
|
||||
const t = useTranslations('journal_detail')
|
||||
const [accounts, setAccounts] = useState<BASAccount[]>([])
|
||||
const [catalog, setCatalog] = useState<CatalogAccount[]>([])
|
||||
const [accountsStatus, setAccountsStatus] = useState<'loading' | 'ready' | 'error'>('loading')
|
||||
const [strikeIds, setStrikeIds] = useState<Set<string>>(new Set())
|
||||
const [newLines, setNewLines] = useState<NewLine[]>([])
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const activeAccounts = useMemo(
|
||||
() => accounts.filter((account) => account.is_active),
|
||||
[accounts],
|
||||
)
|
||||
const selectableCatalog = useMemo(
|
||||
() => getSelectableCorrectionCatalog(accounts, catalog),
|
||||
[accounts, catalog],
|
||||
)
|
||||
|
||||
const originalLines = ((entry.lines || []) as JournalEntryLine[])
|
||||
.slice()
|
||||
.sort((a, b) => a.sort_order - b.sort_order)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setStrikeIds(new Set())
|
||||
setNewLines([])
|
||||
void fetchAccounts()
|
||||
}
|
||||
}, [open, entry.id]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
async function fetchAccounts() {
|
||||
setAccountsStatus('loading')
|
||||
try {
|
||||
const [res, basCatalog] = await Promise.all([
|
||||
fetch('/api/bookkeeping/accounts?active=false'),
|
||||
loadBasCatalog(),
|
||||
])
|
||||
if (!res.ok) throw new Error(`accounts ${res.status}`)
|
||||
const { data } = await res.json()
|
||||
setAccounts(data || [])
|
||||
setCatalog(basCatalog)
|
||||
setAccountsStatus('ready')
|
||||
} catch {
|
||||
setAccounts([])
|
||||
setCatalog([])
|
||||
setAccountsStatus('error')
|
||||
}
|
||||
}
|
||||
|
||||
const toggleStrike = (lineId: string) => {
|
||||
setStrikeIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(lineId)) next.delete(lineId)
|
||||
else next.add(lineId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const updateNewLine = (index: number, field: keyof NewLine, value: string) => {
|
||||
setNewLines((prev) => prev.map((l, i) => (i === index ? { ...l, [field]: value } : l)))
|
||||
}
|
||||
|
||||
const updateNewLineAccount = (index: number, accountNumber: string) => {
|
||||
setNewLines((prev) => prev.map((line, lineIndex) => (
|
||||
lineIndex === index
|
||||
? changeCorrectionLineAccount(line, accountNumber, [...accounts, ...catalog])
|
||||
: line
|
||||
)))
|
||||
}
|
||||
|
||||
const addNewLine = () => {
|
||||
setNewLines((prev) => [...prev, { account_number: '', debit_amount: '', credit_amount: '', line_description: '' }])
|
||||
}
|
||||
|
||||
const removeNewLine = (index: number) => {
|
||||
setNewLines((prev) => prev.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
// Effective verifikat after the rättelse: remaining original lines + new lines.
|
||||
const remaining = originalLines.filter((l) => !strikeIds.has(l.id))
|
||||
const remainingDebit = remaining.reduce((sum, l) => sum + (Number(l.debit_amount) || 0), 0)
|
||||
const remainingCredit = remaining.reduce((sum, l) => sum + (Number(l.credit_amount) || 0), 0)
|
||||
const newDebit = newLines.reduce((sum, l) => sum + (parseFloat(l.debit_amount) || 0), 0)
|
||||
const newCredit = newLines.reduce((sum, l) => sum + (parseFloat(l.credit_amount) || 0), 0)
|
||||
const totalDebit = Math.round((remainingDebit + newDebit) * 100) / 100
|
||||
const totalCredit = Math.round((remainingCredit + newCredit) * 100) / 100
|
||||
const isBalanced = totalDebit === totalCredit && totalDebit > 0
|
||||
|
||||
const newLinesValid = newLines.every((l) => {
|
||||
const debit = parseFloat(l.debit_amount) || 0
|
||||
const credit = parseFloat(l.credit_amount) || 0
|
||||
return l.account_number.length === 4 && debit >= 0 && credit >= 0 && (debit > 0) !== (credit > 0)
|
||||
})
|
||||
const hasChange = strikeIds.size > 0 || newLines.length > 0
|
||||
const effectiveCount = remaining.length + newLines.length
|
||||
const canSubmit = hasChange && newLinesValid && isBalanced && effectiveCount >= 2
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!canSubmit) return
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const res = await fetch(`/api/bookkeeping/journal-entries/${entry.id}/strike-lines`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
strike_line_ids: [...strikeIds],
|
||||
lines: newLines.map((l) => ({
|
||||
account_number: l.account_number,
|
||||
debit_amount: parseFloat(l.debit_amount) || 0,
|
||||
credit_amount: parseFloat(l.credit_amount) || 0,
|
||||
line_description: l.line_description || undefined,
|
||||
})),
|
||||
}),
|
||||
})
|
||||
const result = await res.json()
|
||||
if (!res.ok) {
|
||||
const error = new Error('Failed to strike lines') as Error & { body?: unknown; status?: number }
|
||||
error.body = result
|
||||
error.status = res.status
|
||||
throw error
|
||||
}
|
||||
toast({
|
||||
title: 'Verifikationen rättad',
|
||||
description: 'De strukna raderna visas överstrukna i verifikatet.',
|
||||
})
|
||||
onOpenChange(false)
|
||||
onCorrected()
|
||||
} catch (err) {
|
||||
const anyErr = err as { body?: unknown; status?: number }
|
||||
toast({
|
||||
title: 'Kunde inte rätta verifikationen',
|
||||
description: getErrorMessage(anyErr.body ?? err, { context: 'journal_entry', statusCode: anyErr.status }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-3xl max-h-[95dvh] sm:max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Stryk rader i verifikatet</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="rounded-lg bg-muted/50 border p-3 text-sm text-muted-foreground">
|
||||
<p className="font-medium text-foreground mb-1">Rättelse i samma verifikat</p>
|
||||
<p>
|
||||
Felaktiga rader stryks och ersätts direkt i verifikatet, utan ändringsverifikation.
|
||||
De strukna raderna förblir synliga (överstrukna) och rättelsen loggas med vem och när,
|
||||
enligt bokföringslagen. Fungerar bara i öppna, olåsta perioder. Om månaden redan är
|
||||
momsdeklarerad kan en ändring av momskonton påverka den inlämnade deklarationen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Original lines with strike checkboxes */}
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">Markera rader som ska strykas</p>
|
||||
<div className="rounded-lg border divide-y">
|
||||
{originalLines.map((line) => {
|
||||
const struck = strikeIds.has(line.id)
|
||||
// FX lines carry conversion data replacements cannot reproduce;
|
||||
// the RPC rejects striking them, so the checkbox is disabled.
|
||||
const isForeign = !!line.currency && line.currency !== 'SEK'
|
||||
return (
|
||||
<label
|
||||
key={line.id}
|
||||
className={`flex items-center gap-3 px-3 py-2 text-sm transition-colors ${isForeign ? 'opacity-60' : 'cursor-pointer hover:bg-secondary/60'}`}
|
||||
title={isForeign ? 'Rader i utländsk valuta rättas med ändringsverifikat' : undefined}
|
||||
>
|
||||
<Checkbox
|
||||
checked={struck}
|
||||
disabled={isForeign}
|
||||
onCheckedChange={() => toggleStrike(line.id)}
|
||||
/>
|
||||
<span className={`flex-1 min-w-0 ${struck ? 'line-through text-muted-foreground' : ''}`}>
|
||||
<AccountNumber number={line.account_number} showName />
|
||||
{line.line_description && (
|
||||
<span className="text-muted-foreground ml-2">{line.line_description}</span>
|
||||
)}
|
||||
</span>
|
||||
<span className={`tabular-nums shrink-0 ${struck ? 'line-through text-muted-foreground' : ''}`}>
|
||||
{Number(line.debit_amount) > 0
|
||||
? `${Number(line.debit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} D`
|
||||
: `${Number(line.credit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} K`}
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Replacement lines */}
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">Ersättningsrader</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Lägg till de rader som ska gälla i stället. Verifikationen måste balansera efter rättelsen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{accountsStatus !== 'ready' && (
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border bg-muted/50 p-3 text-sm text-muted-foreground">
|
||||
<span className="flex items-center gap-2">
|
||||
{accountsStatus === 'loading' && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{accountsStatus === 'loading' ? t('accounts_loading') : t('accounts_load_failed')}
|
||||
</span>
|
||||
{accountsStatus === 'error' && (
|
||||
<Button variant="outline" size="sm" onClick={() => void fetchAccounts()}>
|
||||
{t('accounts_retry')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{newLines.map((line, index) => (
|
||||
<div key={index} className="space-y-2 sm:space-y-0 sm:grid sm:grid-cols-[1fr_1fr_120px_120px_auto] sm:gap-2 sm:items-start border-b sm:border-0 pb-3 sm:pb-0 last:border-0">
|
||||
<div className="grid grid-cols-[1fr_auto] sm:contents gap-2">
|
||||
<AccountCombobox
|
||||
value={line.account_number}
|
||||
accounts={activeAccounts}
|
||||
catalog={selectableCatalog}
|
||||
onChange={(v) => updateNewLineAccount(index, v)}
|
||||
disabled={accountsStatus !== 'ready'}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 min-h-[44px] min-w-[44px] sm:order-last"
|
||||
onClick={() => removeNewLine(index)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
value={line.line_description}
|
||||
onChange={(e) => updateNewLine(index, 'line_description', e.target.value)}
|
||||
placeholder="Beskrivning"
|
||||
className="h-8"
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-2 sm:contents">
|
||||
<Input
|
||||
type="number"
|
||||
value={line.debit_amount}
|
||||
onChange={(e) => updateNewLine(index, 'debit_amount', e.target.value)}
|
||||
placeholder="Debet"
|
||||
className="h-8 text-right"
|
||||
min={0}
|
||||
step="0.01"
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
value={line.credit_amount}
|
||||
onChange={(e) => updateNewLine(index, 'credit_amount', e.target.value)}
|
||||
placeholder="Kredit"
|
||||
className="h-8 text-right"
|
||||
min={0}
|
||||
step="0.01"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button variant="outline" size="sm" onClick={addNewLine}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Lägg till rad
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Effective balance after the rättelse */}
|
||||
<div className="flex justify-end gap-6 text-sm pt-2 border-t">
|
||||
<div>
|
||||
<span className="text-muted-foreground mr-2">Debet efter rättelse:</span>
|
||||
<span className={!isBalanced ? 'text-destructive font-medium' : 'font-medium'}>
|
||||
{totalDebit.toLocaleString('sv-SE', { minimumFractionDigits: 2 })}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground mr-2">Kredit efter rättelse:</span>
|
||||
<span className={!isBalanced ? 'text-destructive font-medium' : 'font-medium'}>
|
||||
{totalCredit.toLocaleString('sv-SE', { minimumFractionDigits: 2 })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasChange && !isBalanced && (
|
||||
<p className="text-sm text-destructive">
|
||||
Debet och kredit måste vara lika och större än 0 efter rättelsen.
|
||||
</p>
|
||||
)}
|
||||
{hasChange && isBalanced && effectiveCount < 2 && (
|
||||
<p className="text-sm text-destructive">
|
||||
Verifikationen måste ha minst två rader efter rättelsen. Använd Återför (storno) för att
|
||||
makulera hela verifikatet.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
|
||||
Avbryt
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={!canSubmit || isSubmitting}>
|
||||
{isSubmitting ? 'Rättar...' : 'Rätta verifikatet'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -781,8 +781,18 @@ export function ResultatrapportView({ periodId, dateRange, dimensionFilter = nul
|
||||
<tr className="border-b text-[11px] uppercase tracking-wider text-muted-foreground">
|
||||
<th className="text-left font-medium px-4 py-2 w-20">Konto</th>
|
||||
<th className="text-left font-medium px-4 py-2">Kontonamn</th>
|
||||
<th className="text-right font-medium px-4 py-2 w-32 tabular-nums">Innevarande</th>
|
||||
<th className="text-right font-medium px-4 py-2 w-32 tabular-nums">Föregående</th>
|
||||
<th
|
||||
className="text-right font-medium px-4 py-2 w-32 tabular-nums"
|
||||
title={`${data.period.start} till ${data.period.end}`}
|
||||
>
|
||||
Innevarande
|
||||
</th>
|
||||
<th
|
||||
className="text-right font-medium px-4 py-2 w-32 tabular-nums"
|
||||
title={hasPrior ? `${data.prior_period!.start} till ${data.prior_period!.end}` : undefined}
|
||||
>
|
||||
Föregående
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useSearchParams, useRouter } from 'next/navigation'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { AgentMemoryPanel } from '@/components/settings/AgentMemoryPanel'
|
||||
import { AgentSkillsPanel } from '@/components/settings/AgentSkillsPanel'
|
||||
import { AgentKnowledgePanel } from '@/components/agent-knowledge/AgentKnowledgePanel'
|
||||
@@ -31,24 +35,92 @@ export function AssistantSettingsContent() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs value={view} onValueChange={setView} className="space-y-6">
|
||||
<TabsList>
|
||||
<TabsTrigger value="knowledge">Kunskap</TabsTrigger>
|
||||
<TabsTrigger value="memory">Minne</TabsTrigger>
|
||||
<TabsTrigger value="skills">Kompetens</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="space-y-8">
|
||||
<Tabs value={view} onValueChange={setView} className="space-y-6">
|
||||
<TabsList>
|
||||
<TabsTrigger value="knowledge">Kunskap</TabsTrigger>
|
||||
<TabsTrigger value="memory">Minne</TabsTrigger>
|
||||
<TabsTrigger value="skills">Kompetens</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Radix unmounts the inactive panel, so each panel's data is fetched
|
||||
lazily the first time its tab is opened. */}
|
||||
<TabsContent value="knowledge">
|
||||
<AgentKnowledgePanel />
|
||||
</TabsContent>
|
||||
<TabsContent value="memory">
|
||||
<AgentMemoryPanel />
|
||||
</TabsContent>
|
||||
<TabsContent value="skills">
|
||||
<AgentSkillsPanel />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
{/* Radix unmounts the inactive panel, so each panel's data is fetched
|
||||
lazily the first time its tab is opened. */}
|
||||
<TabsContent value="knowledge">
|
||||
<AgentKnowledgePanel />
|
||||
</TabsContent>
|
||||
<TabsContent value="memory">
|
||||
<AgentMemoryPanel />
|
||||
</TabsContent>
|
||||
<TabsContent value="skills">
|
||||
<AgentSkillsPanel />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<FabVisibilityCard />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Per-user toggle for the floating assistant button bottom-right. The value
|
||||
// lives on user_preferences (server-rendered into the dashboard layout), so
|
||||
// a successful save triggers router.refresh() to make the button react
|
||||
// immediately instead of on next navigation.
|
||||
function FabVisibilityCard() {
|
||||
const t = useTranslations('settings_assistant')
|
||||
const router = useRouter()
|
||||
// null = not yet loaded (switch disabled meanwhile)
|
||||
const [hideFab, setHideFab] = useState<boolean | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
fetch('/api/user/preferences')
|
||||
.then((res) => res.json())
|
||||
.then((body) => {
|
||||
if (!cancelled) setHideFab(Boolean(body?.data?.hide_assistant_fab))
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setHideFab(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
async function handleToggle(showFab: boolean) {
|
||||
const nextHide = !showFab
|
||||
const previous = hideFab
|
||||
setHideFab(nextHide)
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await fetch('/api/user/preferences', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ hide_assistant_fab: nextHide }),
|
||||
})
|
||||
if (!res.ok) throw new Error('save failed')
|
||||
router.refresh()
|
||||
} catch {
|
||||
setHideFab(previous)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-6 flex items-center justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">{t('fab_title')}</p>
|
||||
<p className="text-sm text-muted-foreground">{t('fab_description')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={hideFab === null ? true : !hideFab}
|
||||
onCheckedChange={handleToggle}
|
||||
disabled={hideFab === null || saving}
|
||||
aria-label={t('fab_title')}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
FileText,
|
||||
Landmark,
|
||||
Link2,
|
||||
FileSearch,
|
||||
Loader2,
|
||||
MoreHorizontal,
|
||||
Paperclip,
|
||||
@@ -55,6 +56,11 @@ interface TransactionHistoryListProps {
|
||||
onOpenCategoryDialog: (transaction: TransactionWithInvoice) => void
|
||||
/** Open the attach-underlag dialog (pin an inbox doc / fresh upload). */
|
||||
onOpenAttachDocument?: (transaction: TransactionWithInvoice) => void
|
||||
/** Open the match-against-existing-voucher dialog. Unbooked rows can end up
|
||||
* here (not in the inbox) when is_business is already set, e.g. after a
|
||||
* voucher was removed without a full uncategorize; without this item such
|
||||
* rows have no path back to voucher matching. */
|
||||
onOpenMatchVoucher?: (transaction: TransactionWithInvoice) => void
|
||||
onDelete?: (id: string) => void
|
||||
onSkvBokfor?: (row: StoredSkattekontoTransaction) => void
|
||||
onSkvMatch?: (row: StoredSkattekontoTransaction) => void
|
||||
@@ -78,6 +84,7 @@ export default function TransactionHistoryList({
|
||||
onOpenMatchDialog,
|
||||
onOpenCategoryDialog,
|
||||
onOpenAttachDocument,
|
||||
onOpenMatchVoucher,
|
||||
onDelete,
|
||||
onSkvBokfor,
|
||||
onSkvMatch,
|
||||
@@ -203,6 +210,7 @@ export default function TransactionHistoryList({
|
||||
onOpenMatchDialog={onOpenMatchDialog}
|
||||
onOpenCategoryDialog={onOpenCategoryDialog}
|
||||
onOpenAttachDocument={onOpenAttachDocument}
|
||||
onOpenMatchVoucher={onOpenMatchVoucher}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
) : (
|
||||
@@ -243,6 +251,7 @@ function BankHistoryRow({
|
||||
onOpenMatchDialog,
|
||||
onOpenCategoryDialog,
|
||||
onOpenAttachDocument,
|
||||
onOpenMatchVoucher,
|
||||
onDelete,
|
||||
}: {
|
||||
transaction: TransactionWithInvoice
|
||||
@@ -250,6 +259,7 @@ function BankHistoryRow({
|
||||
onOpenMatchDialog: (transaction: TransactionWithInvoice) => void
|
||||
onOpenCategoryDialog: (transaction: TransactionWithInvoice) => void
|
||||
onOpenAttachDocument?: (transaction: TransactionWithInvoice) => void
|
||||
onOpenMatchVoucher?: (transaction: TransactionWithInvoice) => void
|
||||
onDelete?: (id: string) => void
|
||||
}) {
|
||||
const t = useTranslations('tx_history')
|
||||
@@ -275,8 +285,11 @@ function BankHistoryRow({
|
||||
const hasJeDoc = jeStatus === 'has'
|
||||
const missingUnderlag = isBooked && !transaction.document_id && jeStatus === 'missing'
|
||||
const showAttachItem = canWrite && !!onOpenAttachDocument
|
||||
// Same affordance as the inbox card: an unbooked row may need to be linked
|
||||
// to an already-booked voucher (e.g. the other leg of a transfer).
|
||||
const showMatchVoucherItem = canWrite && !isBooked && !!onOpenMatchVoucher
|
||||
const showOverflowMenu =
|
||||
hasInvoiceMatch || (canDelete && !!onDelete) || (isBooked && canWrite) || showAttachItem
|
||||
hasInvoiceMatch || (canDelete && !!onDelete) || (isBooked && canWrite) || showAttachItem || showMatchVoucherItem
|
||||
|
||||
const isPrivate = transaction.is_business === false
|
||||
const categoryLabel =
|
||||
@@ -394,6 +407,12 @@ function BankHistoryRow({
|
||||
})}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{showMatchVoucherItem && (
|
||||
<DropdownMenuItem onSelect={() => onOpenMatchVoucher!(transaction)}>
|
||||
<FileSearch className="h-3.5 w-3.5" />
|
||||
{t('match_voucher')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{/* Attach underlag: available on both booked rows (the route
|
||||
propagates the doc onto the verifikation) and unbooked. */}
|
||||
{showAttachItem && (
|
||||
@@ -413,7 +432,7 @@ function BankHistoryRow({
|
||||
)}
|
||||
{canDelete && onDelete && (
|
||||
<>
|
||||
{(hasInvoiceMatch || showAttachItem) && <DropdownMenuSeparator />}
|
||||
{(hasInvoiceMatch || showAttachItem || showMatchVoucherItem) && <DropdownMenuSeparator />}
|
||||
<DropdownMenuItem
|
||||
onSelect={() => onDelete(transaction.id)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
|
||||
@@ -15,7 +15,14 @@ vi.mock('@/lib/bookkeeping/engine', () => ({
|
||||
findFiscalPeriod: vi.fn(),
|
||||
}))
|
||||
|
||||
// The feed-row claim is transaction-sync's concern; here we only assert the
|
||||
// payout flow invokes it with the booked entry and the payout's balance txns.
|
||||
vi.mock('../lib/transaction-sync', () => ({
|
||||
linkPayoutFeedRows: vi.fn().mockResolvedValue(0),
|
||||
}))
|
||||
|
||||
import { createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
|
||||
import { linkPayoutFeedRows } from '../lib/transaction-sync'
|
||||
import { processPayoutPaidEvent } from '../lib/payouts'
|
||||
import type { StripeConnection } from '../types'
|
||||
|
||||
@@ -30,6 +37,8 @@ const CONNECTION: StripeConnection = {
|
||||
display_name: null,
|
||||
last_event_created_at: null,
|
||||
last_event_id: null,
|
||||
transaction_sync_enabled: false,
|
||||
last_balance_txn_synced_at: null,
|
||||
error_message: null,
|
||||
connected_at: '2026-07-01T00:00:00.000Z',
|
||||
disconnected_at: null,
|
||||
@@ -120,6 +129,46 @@ describe('processPayoutPaidEvent', () => {
|
||||
expect(Math.round(debits * 100)).toBe(Math.round(credits * 100))
|
||||
})
|
||||
|
||||
it('claims the payout feed rows against the booked entry', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: [{ id: 'po-row-1' }] }) // claim
|
||||
enqueue({ data: { vat_registered: true } }) // company_settings
|
||||
enqueue({ data: null }) // finalize row
|
||||
|
||||
await processPayoutPaidEvent(
|
||||
supabase as unknown as SupabaseClient,
|
||||
CONNECTION,
|
||||
makePayoutEvent(),
|
||||
)
|
||||
|
||||
expect(vi.mocked(linkPayoutFeedRows)).toHaveBeenCalledTimes(1)
|
||||
const [, companyId, accountId, journalEntryId, txns] =
|
||||
vi.mocked(linkPayoutFeedRows).mock.calls[0]
|
||||
expect(companyId).toBe('company-1')
|
||||
expect(accountId).toBe('acct_1')
|
||||
expect(journalEntryId).toBe('je-po-1')
|
||||
expect(txns).toEqual(CLEAN_TXNS)
|
||||
})
|
||||
|
||||
it('does not claim feed rows when the payout is not auto-booked', async () => {
|
||||
stubBalanceTxns([
|
||||
...CLEAN_TXNS,
|
||||
{ type: 'refund', amount: -10000, fee: 0, currency: 'sek' },
|
||||
])
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: [{ id: 'po-row-1' }] })
|
||||
enqueue({ data: { vat_registered: true } })
|
||||
enqueue({ data: null })
|
||||
|
||||
await processPayoutPaidEvent(
|
||||
supabase as unknown as SupabaseClient,
|
||||
CONNECTION,
|
||||
makePayoutEvent(),
|
||||
)
|
||||
|
||||
expect(vi.mocked(linkPayoutFeedRows)).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips a payout already claimed by an earlier run', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: [] }) // claim conflict
|
||||
|
||||
@@ -28,6 +28,8 @@ const CONNECTION: StripeConnection = {
|
||||
display_name: 'Test AB',
|
||||
last_event_created_at: null,
|
||||
last_event_id: null,
|
||||
transaction_sync_enabled: false,
|
||||
last_balance_txn_synced_at: null,
|
||||
error_message: null,
|
||||
connected_at: '2026-07-01T00:00:00.000Z',
|
||||
disconnected_at: null,
|
||||
|
||||
@@ -0,0 +1,484 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
|
||||
const balanceTransactionsList = vi.fn()
|
||||
|
||||
vi.mock('@/lib/stripe/client', () => ({
|
||||
getStripe: () => ({ balanceTransactions: { list: balanceTransactionsList } }),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/transactions/ingest', () => ({
|
||||
ingestTransactions: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/cash-accounts/service', () => ({
|
||||
ensureManualCashAccount: vi.fn().mockResolvedValue('cash-account-1'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/import/account-sync', () => ({
|
||||
syncMappedAccounts: vi.fn().mockResolvedValue({ error: null }),
|
||||
}))
|
||||
|
||||
import { ingestTransactions } from '@/lib/transactions/ingest'
|
||||
import { ensureManualCashAccount } from '@/lib/cash-accounts/service'
|
||||
import {
|
||||
BACKFILL_DAYS,
|
||||
STRIPE_IMPORT_SOURCE,
|
||||
STRIPE_LEDGER_ACCOUNT,
|
||||
linkPayoutFeedRows,
|
||||
mapBalanceTransaction,
|
||||
stripeExternalId,
|
||||
stripeFeeExternalId,
|
||||
syncStripeBalanceTransactions,
|
||||
type BalanceTxnLike,
|
||||
} from '../lib/transaction-sync'
|
||||
import type { StripeConnection } from '../types'
|
||||
|
||||
const CONNECTION: StripeConnection = {
|
||||
id: 'conn-1',
|
||||
company_id: 'company-1',
|
||||
user_id: 'user-1',
|
||||
stripe_account_id: 'acct_1',
|
||||
livemode: false,
|
||||
status: 'active',
|
||||
oauth_state: null,
|
||||
display_name: null,
|
||||
last_event_created_at: null,
|
||||
last_event_id: null,
|
||||
transaction_sync_enabled: true,
|
||||
last_balance_txn_synced_at: null,
|
||||
error_message: null,
|
||||
connected_at: '2026-07-01T00:00:00.000Z',
|
||||
disconnected_at: null,
|
||||
created_at: '2026-07-01T00:00:00.000Z',
|
||||
updated_at: '2026-07-01T00:00:00.000Z',
|
||||
}
|
||||
|
||||
// 2026-07-10T12:00:00Z
|
||||
const CREATED = 1_783_425_600
|
||||
const CREATED_DATE = new Date(CREATED * 1000).toISOString().split('T')[0]
|
||||
|
||||
function makeCharge(overrides: Partial<BalanceTxnLike> = {}): BalanceTxnLike {
|
||||
return {
|
||||
id: 'txn_charge_1',
|
||||
type: 'charge',
|
||||
amount: 50_000,
|
||||
fee: 1_450,
|
||||
currency: 'sek',
|
||||
created: CREATED,
|
||||
description: 'Payment for invoice',
|
||||
source: {
|
||||
id: 'ch_1',
|
||||
object: 'charge',
|
||||
payment_intent: 'pi_1',
|
||||
billing_details: { name: 'Anna Andersson' },
|
||||
} as unknown as BalanceTxnLike['source'],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makePayoutTxn(overrides: Partial<BalanceTxnLike> = {}): BalanceTxnLike {
|
||||
return {
|
||||
id: 'txn_payout_1',
|
||||
type: 'payout',
|
||||
amount: -48_550,
|
||||
fee: 0,
|
||||
currency: 'sek',
|
||||
created: CREATED + 3600,
|
||||
description: 'STRIPE PAYOUT',
|
||||
source: { id: 'po_1', object: 'payout' } as unknown as BalanceTxnLike['source'],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function stubList(byWindow: BalanceTxnLike[], byPayout: BalanceTxnLike[] = []) {
|
||||
balanceTransactionsList.mockImplementation((params: Record<string, unknown>) => ({
|
||||
autoPagingToArray: vi.fn().mockResolvedValue('payout' in params ? byPayout : byWindow),
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Arg-capturing Supabase mock: each from(table) consumes the next queued
|
||||
* result for that table and records every chained call, so tests can assert
|
||||
* filters and update payloads (createQueuedMockSupabase discards args).
|
||||
*/
|
||||
interface CapturedQuery {
|
||||
table: string
|
||||
ops: Array<{ method: string; args: unknown[] }>
|
||||
}
|
||||
|
||||
function createCaptureSupabase(resultsByTable: Record<string, unknown[]> = {}) {
|
||||
const queries: CapturedQuery[] = []
|
||||
const queues = new Map<string, unknown[]>(
|
||||
Object.entries(resultsByTable).map(([table, results]) => [table, [...results]]),
|
||||
)
|
||||
const supabase = {
|
||||
from(table: string) {
|
||||
const captured: CapturedQuery = { table, ops: [] }
|
||||
queries.push(captured)
|
||||
const queue = queues.get(table)
|
||||
const result = queue && queue.length > 0 ? queue.shift() : { data: null, error: null }
|
||||
const chain: Record<string, unknown> = {}
|
||||
const recorder =
|
||||
(method: string) =>
|
||||
(...args: unknown[]) => {
|
||||
captured.ops.push({ method, args })
|
||||
return chain
|
||||
}
|
||||
for (const method of [
|
||||
'select', 'update', 'insert', 'upsert', 'eq', 'neq', 'in', 'is', 'not',
|
||||
'order', 'limit', 'gte', 'lte', 'maybeSingle', 'single',
|
||||
]) {
|
||||
chain[method] = recorder(method)
|
||||
}
|
||||
chain.then = (resolve: (v: unknown) => unknown) => Promise.resolve(result).then(resolve)
|
||||
return chain
|
||||
},
|
||||
}
|
||||
const queriesFor = (table: string) => queries.filter((q) => q.table === table)
|
||||
const op = (query: CapturedQuery, method: string) =>
|
||||
query.ops.find((o) => o.method === method)
|
||||
return { supabase: supabase as unknown as SupabaseClient, queries, queriesFor, op }
|
||||
}
|
||||
|
||||
function listWindowGte(): number {
|
||||
const params = balanceTransactionsList.mock.calls[0][0] as {
|
||||
created: { gte: number }
|
||||
}
|
||||
return params.created.gte
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-07-23T10:00:00.000Z'))
|
||||
vi.mocked(ingestTransactions).mockResolvedValue({
|
||||
imported: 0,
|
||||
duplicates: 0,
|
||||
reconciled: 0,
|
||||
auto_categorized: 0,
|
||||
auto_matched_invoices: 0,
|
||||
errors: 0,
|
||||
transaction_ids: [],
|
||||
} as never)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('external id formats', () => {
|
||||
// ⚠️ FROZEN FORMATS: these strings are stored keys in
|
||||
// transactions.external_id. If either assertion fails, you are about to
|
||||
// orphan every previously imported Stripe row and re-import the whole feed
|
||||
// (the June 2026 Enable Banking incident, again). Do NOT update the
|
||||
// expected values without a coordinated backfill of existing rows.
|
||||
it('main row id is stripe_{acct}_{txn}', () => {
|
||||
expect(stripeExternalId('acct_1', 'txn_abc')).toBe('stripe_acct_1_txn_abc')
|
||||
})
|
||||
|
||||
it('fee row id is stripe_{acct}_{txn}_fee', () => {
|
||||
expect(stripeFeeExternalId('acct_1', 'txn_abc')).toBe('stripe_acct_1_txn_abc_fee')
|
||||
})
|
||||
})
|
||||
|
||||
describe('mapBalanceTransaction', () => {
|
||||
it('splits a charge into a gross row and a negative fee row', () => {
|
||||
const rows = mapBalanceTransaction('acct_1', makeCharge())
|
||||
|
||||
expect(rows).toHaveLength(2)
|
||||
expect(rows[0]).toMatchObject({
|
||||
date: CREATED_DATE,
|
||||
amount: 500,
|
||||
currency: 'SEK',
|
||||
external_id: 'stripe_acct_1_txn_charge_1',
|
||||
import_source: STRIPE_IMPORT_SOURCE,
|
||||
description: 'Stripe-betalning Anna Andersson',
|
||||
})
|
||||
expect(rows[1]).toMatchObject({
|
||||
date: CREATED_DATE,
|
||||
amount: -14.5,
|
||||
currency: 'SEK',
|
||||
external_id: 'stripe_acct_1_txn_charge_1_fee',
|
||||
description: 'Stripe-avgift (Stripe-betalning Anna Andersson)',
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the charge description when billing details carry no name', () => {
|
||||
const rows = mapBalanceTransaction(
|
||||
'acct_1',
|
||||
makeCharge({
|
||||
source: {
|
||||
id: 'ch_1',
|
||||
object: 'charge',
|
||||
description: 'Order 1042',
|
||||
billing_details: { name: null },
|
||||
} as unknown as BalanceTxnLike['source'],
|
||||
}),
|
||||
)
|
||||
expect(rows[0].description).toBe('Stripe-betalning Order 1042')
|
||||
})
|
||||
|
||||
it('maps a zero-fee refund to a single negative row', () => {
|
||||
const rows = mapBalanceTransaction('acct_1', {
|
||||
id: 'txn_refund_1',
|
||||
type: 'refund',
|
||||
amount: -20_000,
|
||||
fee: 0,
|
||||
currency: 'sek',
|
||||
created: CREATED,
|
||||
description: 'REFUND FOR CHARGE',
|
||||
})
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0]).toMatchObject({
|
||||
amount: -200,
|
||||
description: 'Stripe-återbetalning',
|
||||
external_id: 'stripe_acct_1_txn_refund_1',
|
||||
})
|
||||
})
|
||||
|
||||
it('maps the payout row with the po_ id in the description (matches the payout entry)', () => {
|
||||
const rows = mapBalanceTransaction('acct_1', makePayoutTxn())
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0]).toMatchObject({
|
||||
amount: -485.5,
|
||||
description: 'Stripe-utbetalning po_1',
|
||||
external_id: 'stripe_acct_1_txn_payout_1',
|
||||
})
|
||||
})
|
||||
|
||||
it('labels dispute adjustments as tvist via reporting_category', () => {
|
||||
const rows = mapBalanceTransaction('acct_1', {
|
||||
id: 'txn_adj_1',
|
||||
type: 'adjustment',
|
||||
amount: -50_000,
|
||||
fee: 1_500,
|
||||
currency: 'sek',
|
||||
created: CREATED,
|
||||
description: 'Chargeback withdrawal for ch_1',
|
||||
reporting_category: 'dispute',
|
||||
})
|
||||
expect(rows).toHaveLength(2)
|
||||
expect(rows[0].description).toBe('Stripe-tvist')
|
||||
expect(rows[1]).toMatchObject({
|
||||
amount: -15,
|
||||
external_id: 'stripe_acct_1_txn_adj_1_fee',
|
||||
})
|
||||
})
|
||||
|
||||
it('dates rows on created, not available_on semantics', () => {
|
||||
// created is the only date input: a mapped row for a txn created on the
|
||||
// 10th must land on the 10th even though Stripe settles days later.
|
||||
const rows = mapBalanceTransaction('acct_1', makeCharge())
|
||||
expect(rows.every((r) => r.date === CREATED_DATE)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('linkPayoutFeedRows', () => {
|
||||
it('links the payout row and every fee row to the payout entry, unlinked rows only', async () => {
|
||||
const { supabase, queriesFor, op } = createCaptureSupabase({
|
||||
transactions: [{ data: [{ id: 't1' }, { id: 't2' }, { id: 't3' }], error: null }],
|
||||
})
|
||||
|
||||
const linked = await linkPayoutFeedRows(supabase, 'company-1', 'acct_1', 'je-po-1', [
|
||||
{ id: 'txn_c1', type: 'charge', fee: 1450 },
|
||||
{ id: 'txn_c2', type: 'charge', fee: 0 },
|
||||
{ id: 'txn_p1', type: 'payout', fee: 0 },
|
||||
])
|
||||
|
||||
expect(linked).toBe(3)
|
||||
const query = queriesFor('transactions')[0]
|
||||
expect(op(query, 'update')!.args[0]).toEqual({ journal_entry_id: 'je-po-1' })
|
||||
expect(op(query, 'in')!.args).toEqual([
|
||||
'external_id',
|
||||
['stripe_acct_1_txn_c1_fee', 'stripe_acct_1_txn_p1'],
|
||||
])
|
||||
expect(op(query, 'is')!.args).toEqual(['journal_entry_id', null])
|
||||
expect(op(query, 'eq')!.args).toEqual(['company_id', 'company-1'])
|
||||
})
|
||||
|
||||
it('is a no-op without fee or payout rows', async () => {
|
||||
const { supabase, queries } = createCaptureSupabase()
|
||||
const linked = await linkPayoutFeedRows(supabase, 'company-1', 'acct_1', 'je-1', [
|
||||
{ id: 'txn_c1', type: 'charge', fee: 0 },
|
||||
])
|
||||
expect(linked).toBe(0)
|
||||
expect(queries).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('syncStripeBalanceTransactions', () => {
|
||||
it('backfills 90 days on the first run', async () => {
|
||||
stubList([makeCharge()])
|
||||
const { supabase } = createCaptureSupabase({
|
||||
company_settings: [{ data: { bookkeeping_locked_through: null }, error: null }],
|
||||
})
|
||||
|
||||
await syncStripeBalanceTransactions(supabase, { ...CONNECTION })
|
||||
|
||||
const expected = Math.floor(
|
||||
(Date.parse('2026-07-23T10:00:00.000Z') - BACKFILL_DAYS * 86_400_000) / 1000,
|
||||
)
|
||||
expect(listWindowGte()).toBe(expected)
|
||||
})
|
||||
|
||||
it('floors the first-run backfill at the day after the company lock date', async () => {
|
||||
stubList([makeCharge()])
|
||||
const { supabase } = createCaptureSupabase({
|
||||
company_settings: [{ data: { bookkeeping_locked_through: '2026-06-30' }, error: null }],
|
||||
})
|
||||
|
||||
await syncStripeBalanceTransactions(supabase, { ...CONNECTION })
|
||||
|
||||
expect(listWindowGte()).toBe(Math.floor(Date.parse('2026-07-01T00:00:00Z') / 1000))
|
||||
})
|
||||
|
||||
it('polls from the cursor minus the 24h overlap on later runs', async () => {
|
||||
stubList([makeCharge()])
|
||||
const { supabase, queriesFor } = createCaptureSupabase()
|
||||
|
||||
await syncStripeBalanceTransactions(supabase, {
|
||||
...CONNECTION,
|
||||
last_balance_txn_synced_at: '2026-07-20T00:00:00.000Z',
|
||||
})
|
||||
|
||||
expect(listWindowGte()).toBe(
|
||||
Math.floor(Date.parse('2026-07-20T00:00:00Z') / 1000) - 86_400,
|
||||
)
|
||||
// No cursor → no company_settings (lock date) lookup.
|
||||
expect(queriesFor('company_settings')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('ingests the mapped rows onto the 1686 cash account without auto-categorization', async () => {
|
||||
stubList([makeCharge()])
|
||||
const { supabase } = createCaptureSupabase({
|
||||
company_settings: [{ data: null, error: null }],
|
||||
})
|
||||
vi.mocked(ingestTransactions).mockResolvedValue({
|
||||
imported: 2, duplicates: 0, reconciled: 0, auto_categorized: 0,
|
||||
auto_matched_invoices: 0, errors: 0, transaction_ids: ['t1', 't2'],
|
||||
} as never)
|
||||
|
||||
const summary = await syncStripeBalanceTransactions(supabase, { ...CONNECTION })
|
||||
|
||||
expect(summary).toMatchObject({ fetched: 1, imported: 2, duplicates: 0, errors: 0 })
|
||||
expect(vi.mocked(ensureManualCashAccount)).toHaveBeenCalledWith(
|
||||
supabase, 'company-1', STRIPE_LEDGER_ACCOUNT, 'SEK', 'Stripe-saldo',
|
||||
)
|
||||
const [, companyId, userId, rows, options] =
|
||||
vi.mocked(ingestTransactions).mock.calls[0]
|
||||
expect(companyId).toBe('company-1')
|
||||
expect(userId).toBe('user-1')
|
||||
expect((rows as unknown[]).length).toBe(2)
|
||||
expect(options).toEqual({
|
||||
settlementAccount: STRIPE_LEDGER_ACCOUNT,
|
||||
skipAutoCategorization: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('advances the cursor to the newest processed transaction', async () => {
|
||||
stubList([makeCharge(), makePayoutTxn()])
|
||||
const { supabase, queriesFor, op } = createCaptureSupabase({
|
||||
company_settings: [{ data: null, error: null }],
|
||||
})
|
||||
|
||||
await syncStripeBalanceTransactions(supabase, { ...CONNECTION })
|
||||
|
||||
const cursorUpdate = queriesFor('stripe_connections')[0]
|
||||
expect(op(cursorUpdate, 'update')!.args[0]).toEqual({
|
||||
last_balance_txn_synced_at: new Date((CREATED + 3600) * 1000).toISOString(),
|
||||
})
|
||||
expect(op(cursorUpdate, 'eq')!.args).toEqual(['id', 'conn-1'])
|
||||
})
|
||||
|
||||
it('pre-links gross rows of charges the checkout flow already settled', async () => {
|
||||
stubList([makeCharge()])
|
||||
const { supabase, queriesFor, op } = createCaptureSupabase({
|
||||
company_settings: [{ data: null, error: null }],
|
||||
stripe_payment_events: [
|
||||
{ data: [{ payment_intent_id: 'pi_1', journal_entry_id: 'je-settle-1' }], error: null },
|
||||
],
|
||||
transactions: [{ data: [{ id: 't1' }], error: null }],
|
||||
})
|
||||
|
||||
const summary = await syncStripeBalanceTransactions(supabase, { ...CONNECTION })
|
||||
|
||||
expect(summary.linked).toBe(1)
|
||||
const eventsQuery = queriesFor('stripe_payment_events')[0]
|
||||
expect(op(eventsQuery, 'in')!.args).toEqual(['payment_intent_id', ['pi_1']])
|
||||
const linkQuery = queriesFor('transactions')[0]
|
||||
expect(op(linkQuery, 'update')!.args[0]).toEqual({ journal_entry_id: 'je-settle-1' })
|
||||
expect(op(linkQuery, 'in')!.args).toEqual(['external_id', ['stripe_acct_1_txn_charge_1']])
|
||||
expect(op(linkQuery, 'is')!.args).toEqual(['journal_entry_id', null])
|
||||
})
|
||||
|
||||
it('claims fee rows and the payout row of an already-booked payout', async () => {
|
||||
const charge = makeCharge()
|
||||
const payoutTxn = makePayoutTxn()
|
||||
stubList([charge, payoutTxn], [charge, payoutTxn])
|
||||
const { supabase, queriesFor, op } = createCaptureSupabase({
|
||||
company_settings: [{ data: null, error: null }],
|
||||
stripe_payment_events: [{ data: [], error: null }],
|
||||
stripe_payouts: [
|
||||
{ data: [{ payout_id: 'po_1', journal_entry_id: 'je-po-1' }], error: null },
|
||||
],
|
||||
transactions: [{ data: [{ id: 't1' }, { id: 't2' }], error: null }],
|
||||
})
|
||||
|
||||
const summary = await syncStripeBalanceTransactions(supabase, { ...CONNECTION })
|
||||
|
||||
expect(summary.linked).toBe(2)
|
||||
const payoutQuery = queriesFor('stripe_payouts')[0]
|
||||
expect(op(payoutQuery, 'in')!.args).toEqual(['payout_id', ['po_1']])
|
||||
// The per-payout balance transaction list resolves the fee rows.
|
||||
const payoutListCall = balanceTransactionsList.mock.calls.find(
|
||||
(c) => 'payout' in (c[0] as Record<string, unknown>),
|
||||
)
|
||||
expect(payoutListCall?.[0]).toMatchObject({ payout: 'po_1' })
|
||||
const linkQuery = queriesFor('transactions')[0]
|
||||
expect(op(linkQuery, 'update')!.args[0]).toEqual({ journal_entry_id: 'je-po-1' })
|
||||
expect(op(linkQuery, 'in')!.args).toEqual([
|
||||
'external_id',
|
||||
['stripe_acct_1_txn_charge_1_fee', 'stripe_acct_1_txn_payout_1'],
|
||||
])
|
||||
})
|
||||
|
||||
it('stops before ingesting when the deadline already passed and reports it', async () => {
|
||||
stubList([makeCharge()])
|
||||
const { supabase } = createCaptureSupabase({
|
||||
company_settings: [{ data: null, error: null }],
|
||||
})
|
||||
|
||||
const summary = await syncStripeBalanceTransactions(
|
||||
supabase, { ...CONNECTION }, undefined, Date.now() - 1,
|
||||
)
|
||||
|
||||
expect(summary.deadlineReached).toBe(true)
|
||||
expect(vi.mocked(ingestTransactions)).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports a revoked connection without throwing', async () => {
|
||||
balanceTransactionsList.mockImplementation(() => ({
|
||||
autoPagingToArray: vi.fn().mockRejectedValue({ type: 'StripePermissionError' }),
|
||||
}))
|
||||
const { supabase } = createCaptureSupabase({
|
||||
company_settings: [{ data: null, error: null }],
|
||||
})
|
||||
|
||||
const summary = await syncStripeBalanceTransactions(supabase, { ...CONNECTION })
|
||||
|
||||
expect(summary.revoked).toBe(true)
|
||||
expect(summary.fetched).toBe(0)
|
||||
})
|
||||
|
||||
it('does nothing for a non-active connection', async () => {
|
||||
const { supabase, queries } = createCaptureSupabase()
|
||||
const summary = await syncStripeBalanceTransactions(supabase, {
|
||||
...CONNECTION,
|
||||
status: 'revoked',
|
||||
})
|
||||
expect(summary.fetched).toBe(0)
|
||||
expect(queries).toHaveLength(0)
|
||||
expect(balanceTransactionsList).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -6,6 +6,7 @@ import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { EmptyState } from '@/components/ui/empty-state'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
@@ -45,6 +46,7 @@ export default function StripeSettingsPanel() {
|
||||
const [disconnecting, setDisconnecting] = useState(false)
|
||||
const [confirmDisconnect, setConfirmDisconnect] = useState(false)
|
||||
const [syncing, setSyncing] = useState(false)
|
||||
const [togglingTransactionSync, setTogglingTransactionSync] = useState(false)
|
||||
const [needsReviewCount, setNeedsReviewCount] = useState(0)
|
||||
const [needsReview, setNeedsReview] = useState<StripeReviewEvent[]>([])
|
||||
|
||||
@@ -129,6 +131,7 @@ export default function StripeSettingsPanel() {
|
||||
const data = (await res.json().catch(() => ({}))) as {
|
||||
settled?: number
|
||||
needsReview?: number
|
||||
transactions?: { imported?: number; linked?: number }
|
||||
error?: string
|
||||
}
|
||||
if (!res.ok) {
|
||||
@@ -139,12 +142,18 @@ export default function StripeSettingsPanel() {
|
||||
})
|
||||
return
|
||||
}
|
||||
const paymentsLine = t('sync_done_description', {
|
||||
settled: data.settled ?? 0,
|
||||
review: data.needsReview ?? 0,
|
||||
})
|
||||
toast({
|
||||
title: t('sync_done_title'),
|
||||
description: t('sync_done_description', {
|
||||
settled: data.settled ?? 0,
|
||||
review: data.needsReview ?? 0,
|
||||
}),
|
||||
description: data.transactions
|
||||
? `${paymentsLine} ${t('sync_done_transactions', {
|
||||
imported: data.transactions.imported ?? 0,
|
||||
linked: data.transactions.linked ?? 0,
|
||||
})}`
|
||||
: paymentsLine,
|
||||
})
|
||||
await loadStatus()
|
||||
} finally {
|
||||
@@ -152,6 +161,36 @@ export default function StripeSettingsPanel() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleTransactionSync(enabled: boolean) {
|
||||
setTogglingTransactionSync(true)
|
||||
try {
|
||||
const res = await fetch('/api/extensions/ext/stripe/transaction-sync', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const data = (await res.json().catch(() => ({}))) as { error?: string }
|
||||
toast({
|
||||
title: t('transaction_sync_toggle_failed'),
|
||||
description: data.error,
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
toast({
|
||||
title: enabled
|
||||
? t('transaction_sync_enabled_toast')
|
||||
: t('transaction_sync_disabled_toast'),
|
||||
})
|
||||
await loadStatus()
|
||||
} catch {
|
||||
toast({ title: t('transaction_sync_toggle_failed'), variant: 'destructive' })
|
||||
} finally {
|
||||
setTogglingTransactionSync(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDisconnect() {
|
||||
if (!connection) return
|
||||
setDisconnecting(true)
|
||||
@@ -178,6 +217,25 @@ export default function StripeSettingsPanel() {
|
||||
}
|
||||
}
|
||||
|
||||
// Hosted: Stripe Connect is not launched yet, so the settings surface is
|
||||
// "coming soon" even where the platform credentials are configured (test
|
||||
// mode): users must not be able to connect or toggle transaction sync until
|
||||
// launch. Self-hosted admins run their own keys and keep the full panel.
|
||||
const isSelfHosted = process.env.NEXT_PUBLIC_SELF_HOSTED === 'true'
|
||||
if (!isSelfHosted) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<EmptyState
|
||||
icon={CreditCard}
|
||||
title={t('coming_soon_title')}
|
||||
description={t('coming_soon_description')}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
@@ -191,31 +249,15 @@ export default function StripeSettingsPanel() {
|
||||
}
|
||||
|
||||
if (!configured) {
|
||||
// Hosted: the Connect platform isn't live yet, so the whole integration
|
||||
// presents as "coming soon" (every server path is already a no-op without
|
||||
// STRIPE_CONNECT_CLIENT_ID). Self-hosted admins get the honest
|
||||
// configuration message instead: for them it's a setup task, not a launch.
|
||||
const isSelfHosted = process.env.NEXT_PUBLIC_SELF_HOSTED === 'true'
|
||||
if (isSelfHosted) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t('title')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<p className="text-sm text-muted-foreground">{t('not_configured')}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
// Self-hosted without STRIPE_CONNECT_CLIENT_ID: honest configuration
|
||||
// message, for these admins it's a setup task, not a launch.
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<EmptyState
|
||||
icon={CreditCard}
|
||||
title={t('coming_soon_title')}
|
||||
description={t('coming_soon_description')}
|
||||
/>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t('title')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<p className="text-sm text-muted-foreground">{t('not_configured')}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
@@ -306,6 +348,36 @@ export default function StripeSettingsPanel() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isActive && connection && (
|
||||
<div className="flex flex-wrap items-start justify-between gap-4 rounded-lg border border-border p-4">
|
||||
<div className="min-w-0 max-w-prose space-y-1">
|
||||
<p className="text-sm font-medium">{t('transaction_sync_title')}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('transaction_sync_description')}
|
||||
</p>
|
||||
{connection.transaction_sync_enabled ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{connection.last_balance_txn_synced_at
|
||||
? t('transaction_sync_last_synced', {
|
||||
date: formatDateLong(connection.last_balance_txn_synced_at),
|
||||
})
|
||||
: t('transaction_sync_never_synced')}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('transaction_sync_backfill_note')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Switch
|
||||
checked={connection.transaction_sync_enabled}
|
||||
onCheckedChange={handleToggleTransactionSync}
|
||||
disabled={togglingTransactionSync}
|
||||
aria-label={t('transaction_sync_title')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isActive && needsReviewCount > 0 && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
handleInvoicePaid,
|
||||
} from './lib/payment-links'
|
||||
import { syncStripeConnection } from './lib/sync'
|
||||
import { syncStripeBalanceTransactions } from './lib/transaction-sync'
|
||||
import { createServiceClientNoCookies } from '@/lib/auth/api-keys'
|
||||
import type { StripeConnection, StripeStatusResponse } from './types'
|
||||
|
||||
@@ -87,7 +88,7 @@ export const stripeExtension: Extension = {
|
||||
const { data: rows } = await supabase
|
||||
.from('stripe_connections')
|
||||
.select(
|
||||
'id, status, stripe_account_id, livemode, display_name, error_message, connected_at, last_event_created_at',
|
||||
'id, status, stripe_account_id, livemode, display_name, error_message, connected_at, last_event_created_at, transaction_sync_enabled, last_balance_txn_synced_at',
|
||||
)
|
||||
.eq('company_id', ctx.companyId)
|
||||
.order('created_at', { ascending: false })
|
||||
@@ -173,11 +174,15 @@ export const stripeExtension: Extension = {
|
||||
|
||||
try {
|
||||
const serviceClient = createServiceClientNoCookies()
|
||||
const summary = await syncStripeConnection(
|
||||
serviceClient,
|
||||
connection as StripeConnection,
|
||||
)
|
||||
return NextResponse.json({ success: true, ...summary })
|
||||
const typedConnection = connection as StripeConnection
|
||||
const summary = await syncStripeConnection(serviceClient, typedConnection)
|
||||
// The manual button covers both feeds: when the balance-transaction
|
||||
// feed is enabled, "Synka nu" also pulls it (same module as the
|
||||
// nightly cron, no separate rate limit needed: one user action).
|
||||
const transactions = typedConnection.transaction_sync_enabled
|
||||
? await syncStripeBalanceTransactions(serviceClient, typedConnection)
|
||||
: undefined
|
||||
return NextResponse.json({ success: true, ...summary, transactions })
|
||||
} catch (error) {
|
||||
log.error('[stripe] Manual sync failed', {
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
@@ -190,6 +195,62 @@ export const stripeExtension: Extension = {
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/transaction-sync',
|
||||
handler: async (request: Request, ctx?: ExtensionContext) => {
|
||||
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
if (!ctx?.companyId) {
|
||||
return NextResponse.json({ error: 'Company context required' }, { status: 400 })
|
||||
}
|
||||
const companyId = ctx.companyId
|
||||
|
||||
const capabilityBlocked = await requireCapability(
|
||||
supabase,
|
||||
companyId,
|
||||
CAPABILITY.stripe_payments,
|
||||
)
|
||||
if (capabilityBlocked) return capabilityBlocked
|
||||
|
||||
const rl = await checkRateLimit({
|
||||
prefix: 'stripe:transaction-sync-toggle',
|
||||
identifier: user.id,
|
||||
...RATE_LIMIT_SYNC,
|
||||
})
|
||||
if (!rl.ok) return rl.response!
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as { enabled?: unknown }
|
||||
if (typeof body.enabled !== 'boolean') {
|
||||
return NextResponse.json(
|
||||
{ error: 'enabled (boolean) krävs.' },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
const { data: updated, error: updateError } = await supabase
|
||||
.from('stripe_connections')
|
||||
.update({ transaction_sync_enabled: body.enabled })
|
||||
.eq('company_id', companyId)
|
||||
.eq('status', 'active')
|
||||
.select('id')
|
||||
|
||||
if (updateError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Kunde inte spara inställningen. Försök igen.' },
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
if (!updated || updated.length === 0) {
|
||||
return NextResponse.json({ error: 'Inget anslutet Stripe-konto.' }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, enabled: body.enabled })
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/connect',
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { createLogger, type Logger } from '@/lib/logger'
|
||||
import type { CreateJournalEntryInput, CreateJournalEntryLineInput } from '@/types'
|
||||
import { connectedAccountOptions } from './connect'
|
||||
import { linkPayoutFeedRows } from './transaction-sync'
|
||||
import type { StripeConnection } from '../types'
|
||||
|
||||
const defaultLog = createLogger('stripe/payouts')
|
||||
@@ -231,6 +232,29 @@ async function evaluateAndBook(
|
||||
if (!entry) {
|
||||
return { status: 'needs_review', reason: 'booking_returned_null', gross, fees }
|
||||
}
|
||||
// Claim this payout's transaction-feed rows (the payout row + the fee
|
||||
// rows of its charges): the entry just booked carries exactly that money,
|
||||
// so leaving them unbooked in the inbox would invite double-booking the
|
||||
// fees. No-op for companies without the balance-transaction feed. The
|
||||
// entry is already posted, so a linking failure must NOT flip the payout
|
||||
// to needs_review: swallow and let the nightly sync retry (idempotent).
|
||||
try {
|
||||
await linkPayoutFeedRows(
|
||||
supabase,
|
||||
connection.company_id,
|
||||
connection.stripe_account_id!,
|
||||
entry.id,
|
||||
txns,
|
||||
log,
|
||||
)
|
||||
} catch (linkErr) {
|
||||
log.warn('payout feed-row linking failed after booking', {
|
||||
connectionId: connection.id,
|
||||
payoutId: payout.id,
|
||||
journalEntryId: entry.id,
|
||||
message: linkErr instanceof Error ? linkErr.message : String(linkErr),
|
||||
})
|
||||
}
|
||||
log.info('booked stripe payout', {
|
||||
connectionId: connection.id,
|
||||
payoutId: payout.id,
|
||||
|
||||
@@ -0,0 +1,558 @@
|
||||
import type Stripe from 'stripe'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { getStripe } from '@/lib/stripe/client'
|
||||
import { ingestTransactions } from '@/lib/transactions/ingest'
|
||||
import { ensureManualCashAccount } from '@/lib/cash-accounts/service'
|
||||
import { syncMappedAccounts } from '@/lib/import/account-sync'
|
||||
import { createLogger, type Logger } from '@/lib/logger'
|
||||
import type { RawTransaction } from '@/types'
|
||||
import { connectedAccountOptions, isRevokedConnectionError } from './connect'
|
||||
import type { StripeConnection } from '../types'
|
||||
|
||||
const defaultLog = createLogger('stripe/transaction-sync')
|
||||
|
||||
/**
|
||||
* Stripe balance-transaction sync: the connected Stripe balance treated as a
|
||||
* bank feed.
|
||||
*
|
||||
* The Stripe balance becomes a cash account on ledger 1686 (Fordringar för
|
||||
* kontokort), and the account's balance transactions land in the transactions
|
||||
* inbox exactly like PSD2 bank rows: deduped on external_id, bound to the
|
||||
* cash account so booking settles against 1686, and categorized/booked by the
|
||||
* user through the normal flows. Nothing here auto-books.
|
||||
*
|
||||
* Row model (two-row gross+fee split): every balance transaction produces a
|
||||
* main row for its gross amount, plus a fee row (negative) when Stripe
|
||||
* deducted a fee. The feed then sums to Stripe's actual balance movements
|
||||
* (+gross -fee per charge, -net per payout), which is what makes the cash
|
||||
* account reconcile against the real Stripe balance.
|
||||
*
|
||||
* Double-booking protection: money the deterministic flows already booked is
|
||||
* imported pre-linked to its journal entry instead of appearing bookable:
|
||||
* - charge gross rows whose checkout session settled an invoice link to the
|
||||
* settlement entry (stripe_payment_events.matched_booked),
|
||||
* - payout rows AND the fee rows of the charges inside a booked payout link
|
||||
* to the payout entry (stripe_payouts.booked): the payout booking carries
|
||||
* the whole payout's fees in aggregate (see payouts.ts), so its 1686+6570
|
||||
* movement equals the payout row plus those fee rows.
|
||||
* Fee rows whose payout has not been booked yet stay unbooked and are claimed
|
||||
* by processPayoutPaidEvent when the payout books (linkPayoutFeedRows below).
|
||||
*
|
||||
* Cursor: stripe_connections.last_balance_txn_synced_at (max `created`
|
||||
* processed), re-polled with a 24h overlap. Safe because balance transactions
|
||||
* are immutable and carry stable txn_... ids: a re-seen transaction collides
|
||||
* on (company_id, external_id) and is skipped. First run backfills 90 days,
|
||||
* floored at the day after the company lock date: rows behind the lock can
|
||||
* never be booked and would only be permanent inbox noise.
|
||||
*/
|
||||
|
||||
/** BAS ledger account for the Stripe balance cash account. */
|
||||
export const STRIPE_LEDGER_ACCOUNT = '1686'
|
||||
/** BAS 2026 name for 1686; used when creating the chart account. */
|
||||
const STRIPE_LEDGER_ACCOUNT_NAME = 'Fordringar för kontokort och kuponger'
|
||||
/** transactions.import_source for Stripe feed rows. */
|
||||
export const STRIPE_IMPORT_SOURCE = 'stripe'
|
||||
/** First-run backfill window (matches the Enable Banking convention). */
|
||||
export const BACKFILL_DAYS = 90
|
||||
/** Cursor re-poll overlap; external_id dedup makes duplicates no-ops. */
|
||||
const CURSOR_OVERLAP_SECONDS = 24 * 60 * 60
|
||||
/** Balance transactions per ingest chunk (each maps to at most 2 rows). */
|
||||
const INGEST_CHUNK_SIZE = 200
|
||||
/** Hard cap per run; the cursor resumes where a truncated run stopped. */
|
||||
const MAX_TXNS_PER_RUN = 10_000
|
||||
|
||||
/**
|
||||
* ⚠️ STORED-KEY FORMATS. These are persisted to transactions.external_id and
|
||||
* dedup compares stored ids byte-for-byte, exactly like the Enable Banking
|
||||
* scheme in lib/transactions/external-id.ts. Changing either template
|
||||
* silently orphans every prior row and re-imports the whole feed on the next
|
||||
* sync. Locked by the frozen-format test in transaction-sync.test.ts; any
|
||||
* change MUST ship a coordinated backfill.
|
||||
*/
|
||||
export function stripeExternalId(stripeAccountId: string, balanceTxnId: string): string {
|
||||
return `stripe_${stripeAccountId}_${balanceTxnId}`
|
||||
}
|
||||
|
||||
/** Fee-split row id for a balance transaction (see stripeExternalId). */
|
||||
export function stripeFeeExternalId(stripeAccountId: string, balanceTxnId: string): string {
|
||||
return `stripe_${stripeAccountId}_${balanceTxnId}_fee`
|
||||
}
|
||||
|
||||
export interface StripeTransactionSyncSummary {
|
||||
/** Balance transactions listed from Stripe. */
|
||||
fetched: number
|
||||
/** New inbox rows inserted. */
|
||||
imported: number
|
||||
/** Rows skipped by external_id / content dedup. */
|
||||
duplicates: number
|
||||
/** Rows pre-linked to journal entries the deterministic flows already booked. */
|
||||
linked: number
|
||||
errors: number
|
||||
/** Set when the caller's time budget ran out before all chunks processed. */
|
||||
deadlineReached?: boolean
|
||||
/** Set when the connection turned out to be revoked upstream. */
|
||||
revoked?: boolean
|
||||
}
|
||||
|
||||
const round = (n: number) => Math.round(n * 100) / 100
|
||||
|
||||
function isoDate(epochSeconds: number): string {
|
||||
return new Date(epochSeconds * 1000).toISOString().split('T')[0]
|
||||
}
|
||||
|
||||
/** Minimal shape shared by live Stripe objects and test fixtures. */
|
||||
export type BalanceTxnLike = Pick<Stripe.BalanceTransaction, 'id' | 'type' | 'fee'> & {
|
||||
amount: number
|
||||
currency: string
|
||||
created: number
|
||||
description?: string | null
|
||||
reporting_category?: string
|
||||
source?: Stripe.BalanceTransaction['source']
|
||||
}
|
||||
|
||||
function sourceId(source: BalanceTxnLike['source']): string | null {
|
||||
if (!source) return null
|
||||
return typeof source === 'string' ? source : source.id
|
||||
}
|
||||
|
||||
/** Expanded charge behind a charge/payment balance transaction, if present. */
|
||||
function chargeOf(txn: BalanceTxnLike): Stripe.Charge | null {
|
||||
const source = txn.source
|
||||
if (!source || typeof source === 'string') return null
|
||||
return source.object === 'charge' ? (source as Stripe.Charge) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Swedish-first display title per balance-transaction type. Deterministic
|
||||
* from immutable Stripe data (charges never change payer name after
|
||||
* creation), so the same transaction always derives the same description:
|
||||
* important because the content-dedup bridge keys off it.
|
||||
*/
|
||||
function describeBalanceTxn(txn: BalanceTxnLike): string {
|
||||
switch (txn.type) {
|
||||
case 'charge':
|
||||
case 'payment': {
|
||||
const charge = chargeOf(txn)
|
||||
const detail =
|
||||
charge?.billing_details?.name?.trim() ||
|
||||
charge?.description?.trim() ||
|
||||
sourceId(txn.source)
|
||||
return detail ? `Stripe-betalning ${detail}` : 'Stripe-betalning'
|
||||
}
|
||||
case 'refund':
|
||||
case 'payment_refund':
|
||||
case 'payment_failure_refund':
|
||||
return 'Stripe-återbetalning'
|
||||
case 'adjustment':
|
||||
// Disputes surface as adjustments; the reporting_category tells them apart.
|
||||
if (txn.reporting_category === 'dispute') return 'Stripe-tvist'
|
||||
return txn.description ? `Stripe-justering: ${txn.description}` : 'Stripe-justering'
|
||||
case 'payout':
|
||||
// Mirrors the payout journal entry description ("Stripe-utbetalning
|
||||
// po_...") so the linked pair reads as one event.
|
||||
return `Stripe-utbetalning ${sourceId(txn.source) ?? txn.id}`
|
||||
default:
|
||||
return txn.description ? `Stripe: ${txn.description}` : `Stripe ${txn.type}`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map one balance transaction to its feed row(s): a main row for the gross
|
||||
* amount and, when Stripe deducted a fee, a separate negative fee row. Dates
|
||||
* use `created` (when the money moved: the economic event), NOT
|
||||
* `available_on` (Stripe's internal settlement schedule, days later): booked
|
||||
* entries, invoice matching, and month boundaries all want the payment date.
|
||||
*/
|
||||
export function mapBalanceTransaction(
|
||||
stripeAccountId: string,
|
||||
txn: BalanceTxnLike,
|
||||
): RawTransaction[] {
|
||||
const date = isoDate(txn.created)
|
||||
const currency = txn.currency.toUpperCase()
|
||||
const description = describeBalanceTxn(txn)
|
||||
|
||||
const rows: RawTransaction[] = [
|
||||
{
|
||||
date,
|
||||
description,
|
||||
amount: round(txn.amount / 100),
|
||||
currency,
|
||||
external_id: stripeExternalId(stripeAccountId, txn.id),
|
||||
import_source: STRIPE_IMPORT_SOURCE,
|
||||
},
|
||||
]
|
||||
if (txn.fee) {
|
||||
rows.push({
|
||||
date,
|
||||
description: `Stripe-avgift (${description})`,
|
||||
amount: round(-txn.fee / 100),
|
||||
currency,
|
||||
external_id: stripeFeeExternalId(stripeAccountId, txn.id),
|
||||
import_source: STRIPE_IMPORT_SOURCE,
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
/**
|
||||
* Link a booked payout's feed rows to the payout journal entry: the payout
|
||||
* row itself plus every fee row among the payout's balance transactions (the
|
||||
* payout entry books those fees in aggregate: 6570 + reverse charge). Called
|
||||
* from processPayoutPaidEvent at booking time AND from the sync when it
|
||||
* imports rows for an already-booked payout; idempotent either way (only
|
||||
* unlinked rows are claimed) and a no-op for companies without the feed.
|
||||
*/
|
||||
export async function linkPayoutFeedRows(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
stripeAccountId: string,
|
||||
journalEntryId: string,
|
||||
txns: Array<Pick<BalanceTxnLike, 'id' | 'type' | 'fee'>>,
|
||||
log: Logger = defaultLog,
|
||||
): Promise<number> {
|
||||
const externalIds: string[] = []
|
||||
for (const txn of txns) {
|
||||
if (txn.type === 'payout') externalIds.push(stripeExternalId(stripeAccountId, txn.id))
|
||||
if (txn.fee) externalIds.push(stripeFeeExternalId(stripeAccountId, txn.id))
|
||||
}
|
||||
if (externalIds.length === 0) return 0
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('transactions')
|
||||
.update({ journal_entry_id: journalEntryId })
|
||||
.eq('company_id', companyId)
|
||||
.in('external_id', externalIds)
|
||||
.is('journal_entry_id', null)
|
||||
.select('id')
|
||||
|
||||
if (error) {
|
||||
// Non-fatal by contract: the payout booking itself must never unwind
|
||||
// because feed-row linking failed. The sync's next run retries.
|
||||
log.warn('failed to link payout feed rows', {
|
||||
companyId,
|
||||
journalEntryId,
|
||||
error: error.message,
|
||||
})
|
||||
return 0
|
||||
}
|
||||
return data?.length ?? 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Window start (epoch seconds) for the balance-transaction list call. With a
|
||||
* cursor: cursor minus the 24h overlap. First run: BACKFILL_DAYS back,
|
||||
* floored at the day AFTER the company lock date (rows on/before it are
|
||||
* unbookable by the enforce_company_lock_date trigger).
|
||||
*/
|
||||
async function resolveWindowStartSeconds(
|
||||
supabase: SupabaseClient,
|
||||
connection: StripeConnection,
|
||||
): Promise<number> {
|
||||
if (connection.last_balance_txn_synced_at) {
|
||||
const cursorSec = Math.floor(Date.parse(connection.last_balance_txn_synced_at) / 1000)
|
||||
return Math.max(0, cursorSec - CURSOR_OVERLAP_SECONDS)
|
||||
}
|
||||
|
||||
let startMs = Date.now() - BACKFILL_DAYS * 86_400_000
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('bookkeeping_locked_through')
|
||||
.eq('company_id', connection.company_id)
|
||||
.maybeSingle()
|
||||
const lockThrough = (settings as { bookkeeping_locked_through?: string | null } | null)
|
||||
?.bookkeeping_locked_through
|
||||
if (lockThrough) {
|
||||
const firstBookableMs = Date.parse(`${lockThrough}T00:00:00Z`) + 86_400_000
|
||||
if (Number.isFinite(firstBookableMs)) startMs = Math.max(startMs, firstBookableMs)
|
||||
}
|
||||
return Math.max(0, Math.floor(startMs / 1000))
|
||||
}
|
||||
|
||||
/**
|
||||
* Make sure the Stripe balance cash account exists (ledger 1686, source
|
||||
* manual so a later remap/promotion follows the normal cash-account rules)
|
||||
* and, on the first run, that 1686 exists in the chart of accounts: the
|
||||
* booking dialog and AccountPicker only list chart accounts.
|
||||
*/
|
||||
async function ensureStripeBalanceAccount(
|
||||
supabase: SupabaseClient,
|
||||
connection: StripeConnection,
|
||||
firstRun: boolean,
|
||||
log: Logger,
|
||||
): Promise<void> {
|
||||
await ensureManualCashAccount(
|
||||
supabase,
|
||||
connection.company_id,
|
||||
STRIPE_LEDGER_ACCOUNT,
|
||||
'SEK',
|
||||
'Stripe-saldo',
|
||||
)
|
||||
if (firstRun) {
|
||||
const sync = await syncMappedAccounts(
|
||||
supabase,
|
||||
connection.company_id,
|
||||
connection.user_id,
|
||||
[
|
||||
{
|
||||
sourceAccount: STRIPE_LEDGER_ACCOUNT,
|
||||
sourceName: STRIPE_LEDGER_ACCOUNT_NAME,
|
||||
targetAccount: STRIPE_LEDGER_ACCOUNT,
|
||||
targetName: STRIPE_LEDGER_ACCOUNT_NAME,
|
||||
confidence: 1,
|
||||
matchType: 'exact',
|
||||
isOverride: false,
|
||||
},
|
||||
],
|
||||
false,
|
||||
)
|
||||
if (sync.error) {
|
||||
// Rows still import and bind to the cash account; only the chart
|
||||
// listing is affected (the account can be added manually), so this is
|
||||
// deliberately non-fatal.
|
||||
log.warn('chart sync for 1686 failed', {
|
||||
companyId: connection.company_id,
|
||||
error: sync.error,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Gross rows of charges the checkout flow already settled → settlement entry. */
|
||||
async function linkSettledCharges(
|
||||
supabase: SupabaseClient,
|
||||
connection: StripeConnection,
|
||||
txns: BalanceTxnLike[],
|
||||
log: Logger,
|
||||
): Promise<number> {
|
||||
const grossIdByPaymentIntent = new Map<string, string>()
|
||||
for (const txn of txns) {
|
||||
if (txn.type !== 'charge' && txn.type !== 'payment') continue
|
||||
const charge = chargeOf(txn)
|
||||
const pi =
|
||||
typeof charge?.payment_intent === 'string'
|
||||
? charge.payment_intent
|
||||
: charge?.payment_intent?.id
|
||||
if (!pi) continue
|
||||
grossIdByPaymentIntent.set(pi, stripeExternalId(connection.stripe_account_id!, txn.id))
|
||||
}
|
||||
if (grossIdByPaymentIntent.size === 0) return 0
|
||||
|
||||
const { data: events, error } = await supabase
|
||||
.from('stripe_payment_events')
|
||||
.select('payment_intent_id, journal_entry_id')
|
||||
.eq('connection_id', connection.id)
|
||||
.eq('status', 'matched_booked')
|
||||
.not('journal_entry_id', 'is', null)
|
||||
.in('payment_intent_id', [...grossIdByPaymentIntent.keys()])
|
||||
if (error) {
|
||||
log.warn('settled-charge lookup failed; rows stay unlinked this run', {
|
||||
connectionId: connection.id,
|
||||
error: error.message,
|
||||
})
|
||||
return 0
|
||||
}
|
||||
if (!events || events.length === 0) return 0
|
||||
|
||||
const externalIdsByEntry = new Map<string, string[]>()
|
||||
for (const event of events as Array<{
|
||||
payment_intent_id: string | null
|
||||
journal_entry_id: string | null
|
||||
}>) {
|
||||
if (!event.payment_intent_id || !event.journal_entry_id) continue
|
||||
const externalId = grossIdByPaymentIntent.get(event.payment_intent_id)
|
||||
if (!externalId) continue
|
||||
const ids = externalIdsByEntry.get(event.journal_entry_id)
|
||||
if (ids) ids.push(externalId)
|
||||
else externalIdsByEntry.set(event.journal_entry_id, [externalId])
|
||||
}
|
||||
|
||||
let linked = 0
|
||||
for (const [journalEntryId, externalIds] of externalIdsByEntry) {
|
||||
const { data, error: linkError } = await supabase
|
||||
.from('transactions')
|
||||
.update({ journal_entry_id: journalEntryId })
|
||||
.eq('company_id', connection.company_id)
|
||||
.in('external_id', externalIds)
|
||||
.is('journal_entry_id', null)
|
||||
.select('id')
|
||||
if (linkError) {
|
||||
log.warn('settled-charge link failed', {
|
||||
connectionId: connection.id,
|
||||
journalEntryId,
|
||||
error: linkError.message,
|
||||
})
|
||||
continue
|
||||
}
|
||||
linked += data?.length ?? 0
|
||||
}
|
||||
return linked
|
||||
}
|
||||
|
||||
/**
|
||||
* Payout rows in this chunk whose payout the payout flow already booked:
|
||||
* link the payout row + the payout's fee rows to the payout entry. One extra
|
||||
* Stripe list call per booked payout (bounded by payouts in the window; the
|
||||
* balance transaction itself does not reference its payout).
|
||||
*/
|
||||
async function linkBookedPayouts(
|
||||
supabase: SupabaseClient,
|
||||
connection: StripeConnection,
|
||||
txns: BalanceTxnLike[],
|
||||
stripe: Stripe,
|
||||
requestOptions: Stripe.RequestOptions,
|
||||
log: Logger,
|
||||
): Promise<number> {
|
||||
const payoutIds: string[] = []
|
||||
for (const txn of txns) {
|
||||
if (txn.type !== 'payout') continue
|
||||
const id = sourceId(txn.source)
|
||||
if (id) payoutIds.push(id)
|
||||
}
|
||||
if (payoutIds.length === 0) return 0
|
||||
|
||||
const { data: payouts, error } = await supabase
|
||||
.from('stripe_payouts')
|
||||
.select('payout_id, journal_entry_id')
|
||||
.eq('connection_id', connection.id)
|
||||
.eq('status', 'booked')
|
||||
.not('journal_entry_id', 'is', null)
|
||||
.in('payout_id', payoutIds)
|
||||
if (error) {
|
||||
log.warn('booked-payout lookup failed; rows stay unlinked this run', {
|
||||
connectionId: connection.id,
|
||||
error: error.message,
|
||||
})
|
||||
return 0
|
||||
}
|
||||
|
||||
let linked = 0
|
||||
for (const payout of (payouts ?? []) as Array<{
|
||||
payout_id: string
|
||||
journal_entry_id: string
|
||||
}>) {
|
||||
const payoutTxns = await stripe.balanceTransactions
|
||||
.list({ payout: payout.payout_id, limit: 100 }, requestOptions)
|
||||
.autoPagingToArray({ limit: 1000 })
|
||||
linked += await linkPayoutFeedRows(
|
||||
supabase,
|
||||
connection.company_id,
|
||||
connection.stripe_account_id!,
|
||||
payout.journal_entry_id,
|
||||
payoutTxns,
|
||||
log,
|
||||
)
|
||||
}
|
||||
return linked
|
||||
}
|
||||
|
||||
function chunked<T>(items: T[], size: number): T[][] {
|
||||
const out: T[][] = []
|
||||
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size))
|
||||
return out
|
||||
}
|
||||
|
||||
export async function syncStripeBalanceTransactions(
|
||||
supabase: SupabaseClient,
|
||||
connection: StripeConnection,
|
||||
log: Logger = defaultLog,
|
||||
/**
|
||||
* Absolute deadline (epoch ms) from the caller's time budget. Enforced
|
||||
* between ingest chunks: the cursor advances only over processed chunks, so
|
||||
* the next run resumes exactly where this one stopped.
|
||||
*/
|
||||
deadlineMs?: number,
|
||||
): Promise<StripeTransactionSyncSummary> {
|
||||
const summary: StripeTransactionSyncSummary = {
|
||||
fetched: 0,
|
||||
imported: 0,
|
||||
duplicates: 0,
|
||||
linked: 0,
|
||||
errors: 0,
|
||||
}
|
||||
if (!connection.stripe_account_id || connection.status !== 'active') return summary
|
||||
|
||||
const stripe = getStripe()
|
||||
const requestOptions = connectedAccountOptions(connection.stripe_account_id)
|
||||
const firstRun = !connection.last_balance_txn_synced_at
|
||||
const gte = await resolveWindowStartSeconds(supabase, connection)
|
||||
|
||||
let txns: BalanceTxnLike[]
|
||||
try {
|
||||
txns = await stripe.balanceTransactions
|
||||
.list({ created: { gte }, limit: 100, expand: ['data.source'] }, requestOptions)
|
||||
.autoPagingToArray({ limit: MAX_TXNS_PER_RUN })
|
||||
} catch (err) {
|
||||
if (isRevokedConnectionError(err)) {
|
||||
// The event sync polls every 15 minutes and owns the revoked-status
|
||||
// transition + audit event; this nightly pass just reports and stops.
|
||||
summary.revoked = true
|
||||
return summary
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
summary.fetched = txns.length
|
||||
if (txns.length === 0) return summary
|
||||
|
||||
// Oldest first: cursor advancement stays chronological, and a payout's
|
||||
// charges are always ingested before (or with) the payout row their fee
|
||||
// linking depends on.
|
||||
txns.sort((a, b) => a.created - b.created)
|
||||
|
||||
await ensureStripeBalanceAccount(supabase, connection, firstRun, log)
|
||||
|
||||
for (const chunk of chunked(txns, INGEST_CHUNK_SIZE)) {
|
||||
if (deadlineMs !== undefined && Date.now() >= deadlineMs) {
|
||||
summary.deadlineReached = true
|
||||
log.info('time budget exhausted; stopping balance-transaction batch', {
|
||||
connectionId: connection.id,
|
||||
processed: summary.imported + summary.duplicates,
|
||||
remaining: summary.fetched - summary.imported - summary.duplicates,
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
const rows = chunk.flatMap((txn) =>
|
||||
mapBalanceTransaction(connection.stripe_account_id!, txn),
|
||||
)
|
||||
// Auto-categorization is skipped on purpose: for Stripe money the
|
||||
// deterministic settle/payout flows own booking; everything else is a
|
||||
// human decision in the inbox. Invoice matching still runs (suggestions
|
||||
// only), and FX enrichment covers non-SEK rows.
|
||||
const result = await ingestTransactions(
|
||||
supabase,
|
||||
connection.company_id,
|
||||
connection.user_id,
|
||||
rows,
|
||||
{ settlementAccount: STRIPE_LEDGER_ACCOUNT, skipAutoCategorization: true },
|
||||
)
|
||||
summary.imported += result.imported
|
||||
summary.duplicates += result.duplicates
|
||||
summary.errors += result.errors
|
||||
|
||||
summary.linked += await linkSettledCharges(supabase, connection, chunk, log)
|
||||
summary.linked += await linkBookedPayouts(
|
||||
supabase,
|
||||
connection,
|
||||
chunk,
|
||||
stripe,
|
||||
requestOptions,
|
||||
log,
|
||||
)
|
||||
|
||||
// Persist the cursor after each chunk so a crash or deadline stop resumes
|
||||
// from the last fully-processed chunk (the 24h overlap absorbs the rest).
|
||||
const maxCreated = chunk[chunk.length - 1].created
|
||||
await supabase
|
||||
.from('stripe_connections')
|
||||
.update({ last_balance_txn_synced_at: new Date(maxCreated * 1000).toISOString() })
|
||||
.eq('id', connection.id)
|
||||
connection.last_balance_txn_synced_at = new Date(maxCreated * 1000).toISOString()
|
||||
}
|
||||
|
||||
log.info('stripe balance-transaction sync done', {
|
||||
connectionId: connection.id,
|
||||
...summary,
|
||||
})
|
||||
return summary
|
||||
}
|
||||
@@ -10,6 +10,10 @@ export interface StripeConnection {
|
||||
display_name: string | null
|
||||
last_event_created_at: string | null
|
||||
last_event_id: string | null
|
||||
/** Opt-in: import the account's balance transactions as an inbox feed. */
|
||||
transaction_sync_enabled: boolean
|
||||
/** Balance-transaction polling cursor (max `created` processed). */
|
||||
last_balance_txn_synced_at: string | null
|
||||
error_message: string | null
|
||||
connected_at: string | null
|
||||
disconnected_at: string | null
|
||||
@@ -40,6 +44,8 @@ export interface StripeStatusResponse {
|
||||
| 'error_message'
|
||||
| 'connected_at'
|
||||
| 'last_event_created_at'
|
||||
| 'transaction_sync_enabled'
|
||||
| 'last_balance_txn_synced_at'
|
||||
> | null
|
||||
needs_review_count?: number
|
||||
needs_review?: StripeReviewEvent[]
|
||||
|
||||
@@ -1022,6 +1022,46 @@ export const CorrectJournalEntrySchema = z.object({
|
||||
lines: z.array(CreateJournalEntryLineSchema).min(2, 'At least two lines are required for double-entry'),
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// Inline rättelse of a posted verifikat (BFL 5 kap 5 § / 9 §)
|
||||
// ============================================================
|
||||
// The correct_entry_metadata / correct_entry_lines_inline RPCs enforce the
|
||||
// full envelope (posted status, open period, company lock date, balance,
|
||||
// who/when logging); these schemas only shape the payload.
|
||||
|
||||
/** POST /api/bookkeeping/journal-entries/[id]/correct-metadata */
|
||||
export const CorrectEntryMetadataSchema = z
|
||||
.object({
|
||||
description: z.string().trim().min(1, 'Beskrivningen kan inte vara tom').max(500).optional(),
|
||||
entry_date: isoDate.optional(),
|
||||
})
|
||||
.refine((body) => body.description !== undefined || body.entry_date !== undefined, {
|
||||
message: 'Minst ett fält måste anges',
|
||||
})
|
||||
|
||||
/**
|
||||
* Replacement line for an inline strike. Deliberately narrower than
|
||||
* CreateJournalEntryLineSchema: inline additions are SEK-only and carry no
|
||||
* tax_code or currency conversion (those corrections use the storno flow).
|
||||
*/
|
||||
export const InlineRattelseLineSchema = z.object({
|
||||
account_number: accountNumber,
|
||||
debit_amount: nonNegativeAmount.default(0),
|
||||
credit_amount: nonNegativeAmount.default(0),
|
||||
line_description: z.string().max(500).optional(),
|
||||
dimensions: DimensionsBagSchema.optional(),
|
||||
})
|
||||
|
||||
/** POST /api/bookkeeping/journal-entries/[id]/strike-lines */
|
||||
export const StrikeLinesSchema = z
|
||||
.object({
|
||||
strike_line_ids: z.array(uuid).max(200).default([]),
|
||||
lines: z.array(InlineRattelseLineSchema).max(100).default([]),
|
||||
})
|
||||
.refine((body) => body.strike_line_ids.length > 0 || body.lines.length > 0, {
|
||||
message: 'Rättelsen måste stryka eller lägga till minst en rad',
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// Dimension registry schemas (kostnadsställe/projekt)
|
||||
// ============================================================
|
||||
|
||||
@@ -6,6 +6,11 @@ describe('isBuiltInRedirectUri', () => {
|
||||
it.each([
|
||||
['https://claude.ai/api/oauth/callback', true],
|
||||
['https://claude.com/api/oauth/callback', true],
|
||||
['https://chatgpt.com/connector/oauth/abc123', true],
|
||||
['https://chatgpt.com/connector_platform_oauth_redirect', true],
|
||||
['https://chatgpt.com/connector_platform_oauth_redirect/extra', false],
|
||||
['https://chatgpt.com/other/path', false],
|
||||
['https://chatgpt.com.evil.com/connector/oauth/x', false],
|
||||
['http://localhost:3000/cb', true],
|
||||
['http://localhost/cb', true],
|
||||
['http://127.0.0.1:8080/cb', true],
|
||||
|
||||
@@ -3,12 +3,19 @@ import { createServiceClientNoCookies } from './api-keys'
|
||||
|
||||
/**
|
||||
* Built-in redirect URI patterns. These bypass the DB lookup entirely so
|
||||
* Claude's connector keeps working without seeded rows, and so local
|
||||
* development never depends on having a registration.
|
||||
* Claude's and ChatGPT's connectors keep working without seeded rows, and so
|
||||
* local development never depends on having a registration.
|
||||
*
|
||||
* ChatGPT uses a per-connector-instance callback path
|
||||
* (https://chatgpt.com/connector/oauth/{callback_id}) plus the legacy fixed
|
||||
* callback for already-published apps; both are documented at
|
||||
* developers.openai.com/apps-sdk/build/auth.
|
||||
*/
|
||||
export const BUILT_IN_REDIRECT_PATTERNS: readonly RegExp[] = [
|
||||
/^https:\/\/claude\.ai\/api\//,
|
||||
/^https:\/\/claude\.com\/api\//,
|
||||
/^https:\/\/chatgpt\.com\/connector\/oauth\//,
|
||||
/^https:\/\/chatgpt\.com\/connector_platform_oauth_redirect$/,
|
||||
/^http:\/\/localhost(:\d+)?(\/|$)/,
|
||||
/^http:\/\/127\.0\.0\.1(:\d+)?(\/|$)/,
|
||||
]
|
||||
|
||||
@@ -4,7 +4,7 @@ vi.mock('../trial-balance', () => ({
|
||||
generateTrialBalance: vi.fn(),
|
||||
}))
|
||||
|
||||
import { generateResultatrapport } from '../resultatrapport'
|
||||
import { generateResultatrapport, shiftDateOneYearBack } from '../resultatrapport'
|
||||
import { generateTrialBalance } from '../trial-balance'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
import type { TrialBalanceRow } from '@/types'
|
||||
@@ -16,7 +16,7 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
function makeRow(overrides: Partial<TrialBalanceRow>): TrialBalanceRow {
|
||||
return {
|
||||
const row: TrialBalanceRow = {
|
||||
account_number: '3001',
|
||||
account_name: 'Test',
|
||||
account_class: 3,
|
||||
@@ -28,6 +28,14 @@ function makeRow(overrides: Partial<TrialBalanceRow>): TrialBalanceRow {
|
||||
closing_credit: 0,
|
||||
...overrides,
|
||||
}
|
||||
// Full-period P&L reality: no opening balance, so window activity equals
|
||||
// closing. Tests specify closing_*; mirror into period_* unless the test
|
||||
// sets period activity explicitly.
|
||||
if (row.period_debit === 0 && row.period_credit === 0) {
|
||||
row.period_debit = row.closing_debit
|
||||
row.period_credit = row.closing_credit
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
function tb(rows: TrialBalanceRow[]) {
|
||||
@@ -279,4 +287,184 @@ describe('generateResultatrapport', () => {
|
||||
generateResultatrapport(q.supabase as any, 'company-1', 'missing')
|
||||
).rejects.toThrow('Fiscal period not found')
|
||||
})
|
||||
|
||||
it('compares a date range against the same window shifted one year back', async () => {
|
||||
const q = createQueuedMockSupabase()
|
||||
q.enqueue({
|
||||
data: { period_start: '2026-01-01', period_end: '2026-12-31', previous_period_id: 'period-0' },
|
||||
error: null,
|
||||
})
|
||||
// Fiscal periods covering the shifted window 2025-01-01..2025-03-31.
|
||||
q.enqueue({
|
||||
data: [{ id: 'period-0', period_start: '2025-01-01', period_end: '2025-12-31' }],
|
||||
error: null,
|
||||
})
|
||||
|
||||
mockTrialBalance
|
||||
.mockResolvedValueOnce(
|
||||
tb([makeRow({ account_number: '3001', account_class: 3, closing_credit: 90000 })])
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
tb([makeRow({ account_number: '3001', account_class: 3, closing_credit: 60000 })])
|
||||
)
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const report = await generateResultatrapport(q.supabase as any, 'company-1', 'period-1', {
|
||||
fromDate: '2026-01-01',
|
||||
toDate: '2026-03-31',
|
||||
})
|
||||
|
||||
expect(report.groups[0].rows[0].current_period).toBe(90000)
|
||||
expect(report.groups[0].rows[0].prior_period).toBe(60000)
|
||||
expect(report.prior_period).toEqual({ start: '2025-01-01', end: '2025-03-31' })
|
||||
expect(mockTrialBalance).toHaveBeenNthCalledWith(2, expect.anything(), 'company-1', 'period-0', {
|
||||
fromDate: '2025-01-01',
|
||||
toDate: '2025-03-31',
|
||||
})
|
||||
})
|
||||
|
||||
it('merges the shifted window across two fiscal periods (brutet räkenskapsår)', async () => {
|
||||
const q = createQueuedMockSupabase()
|
||||
// Current period: brutet räkenskapsår Jul 2025 - Jun 2026; window is the
|
||||
// calendar Q1 2026, so the shifted window Jan-Mar 2025 spans FY 24/25
|
||||
// only. Use a window that straddles instead: Jun-Jul 2026 shifted to
|
||||
// Jun-Jul 2025, split across FY 24/25 (ends Jun 30) and FY 25/26.
|
||||
q.enqueue({
|
||||
data: { period_start: '2025-07-01', period_end: '2026-06-30', previous_period_id: 'period-0' },
|
||||
error: null,
|
||||
})
|
||||
q.enqueue({
|
||||
data: [
|
||||
{ id: 'period-old', period_start: '2024-07-01', period_end: '2025-06-30' },
|
||||
{ id: 'period-1', period_start: '2025-07-01', period_end: '2026-06-30' },
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
|
||||
mockTrialBalance
|
||||
// Current window
|
||||
.mockResolvedValueOnce(
|
||||
tb([makeRow({ account_number: '3001', account_class: 3, closing_credit: 50000 })])
|
||||
)
|
||||
// Prior part 1: 2025-06-01..2025-06-30 in period-old
|
||||
.mockResolvedValueOnce(
|
||||
tb([makeRow({ account_number: '3001', account_class: 3, closing_credit: 10000 })])
|
||||
)
|
||||
// Prior part 2: 2025-07-01..2025-07-31 in period-1 (current period is a
|
||||
// legitimate source when the shifted window reaches into it)
|
||||
.mockResolvedValueOnce(
|
||||
tb([makeRow({ account_number: '3001', account_class: 3, closing_credit: 15000 })])
|
||||
)
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const report = await generateResultatrapport(q.supabase as any, 'company-1', 'period-1', {
|
||||
fromDate: '2026-06-01',
|
||||
toDate: '2026-07-31',
|
||||
})
|
||||
|
||||
expect(report.groups[0].rows[0].prior_period).toBe(25000)
|
||||
expect(report.prior_period).toEqual({ start: '2025-06-01', end: '2025-07-31' })
|
||||
expect(mockTrialBalance).toHaveBeenNthCalledWith(2, expect.anything(), 'company-1', 'period-old', {
|
||||
fromDate: '2025-06-01',
|
||||
toDate: '2025-06-30',
|
||||
})
|
||||
expect(mockTrialBalance).toHaveBeenNthCalledWith(3, expect.anything(), 'company-1', 'period-1', {
|
||||
fromDate: '2025-07-01',
|
||||
toDate: '2025-07-31',
|
||||
})
|
||||
})
|
||||
|
||||
it('drops the comparison when the shifted window would overlap the current one', async () => {
|
||||
const q = createQueuedMockSupabase()
|
||||
// 18-month fiscal period with a 14-month window: shifting back one year
|
||||
// overlaps the window itself, so no comparison is possible.
|
||||
q.enqueue({
|
||||
data: { period_start: '2025-01-01', period_end: '2026-06-30', previous_period_id: null },
|
||||
error: null,
|
||||
})
|
||||
|
||||
mockTrialBalance.mockResolvedValueOnce(
|
||||
tb([makeRow({ account_number: '3001', account_class: 3, closing_credit: 100000 })])
|
||||
)
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const report = await generateResultatrapport(q.supabase as any, 'company-1', 'period-1', {
|
||||
fromDate: '2025-01-01',
|
||||
toDate: '2026-02-28',
|
||||
})
|
||||
|
||||
expect(report.prior_period).toBeNull()
|
||||
expect(mockTrialBalance).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('still drops the comparison for dimension-filtered ranges', async () => {
|
||||
const q = createQueuedMockSupabase()
|
||||
q.enqueue({
|
||||
data: { period_start: '2026-01-01', period_end: '2026-12-31', previous_period_id: 'period-0' },
|
||||
error: null,
|
||||
})
|
||||
|
||||
mockTrialBalance.mockResolvedValueOnce(
|
||||
tb([makeRow({ account_number: '3001', account_class: 3, closing_credit: 100000 })])
|
||||
)
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const report = await generateResultatrapport(q.supabase as any, 'company-1', 'period-1', {
|
||||
fromDate: '2026-01-01',
|
||||
toDate: '2026-03-31',
|
||||
dimensions: { '6': 'P001' },
|
||||
})
|
||||
|
||||
expect(report.prior_period).toBeNull()
|
||||
expect(mockTrialBalance).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('reports window activity, not rolled-forward YTD closing', async () => {
|
||||
const q = createQueuedMockSupabase()
|
||||
q.enqueue({
|
||||
data: { period_start: '2026-01-01', period_end: '2026-12-31', previous_period_id: null },
|
||||
error: null,
|
||||
})
|
||||
q.enqueue({ data: [], error: null }) // no fiscal period covers the shifted window
|
||||
|
||||
// A June window: trial balance rolls Jan-May (60 000) into opening, so
|
||||
// closing shows 100 000 YTD while the window's own activity is 40 000.
|
||||
mockTrialBalance.mockResolvedValueOnce(
|
||||
tb([
|
||||
makeRow({
|
||||
account_number: '3001',
|
||||
account_class: 3,
|
||||
opening_credit: 60000,
|
||||
period_credit: 40000,
|
||||
closing_credit: 100000,
|
||||
}),
|
||||
])
|
||||
)
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const report = await generateResultatrapport(q.supabase as any, 'company-1', 'period-1', {
|
||||
fromDate: '2026-06-01',
|
||||
toDate: '2026-06-30',
|
||||
})
|
||||
|
||||
expect(report.groups[0].rows[0].current_period).toBe(40000)
|
||||
expect(report.net_result_current).toBe(40000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('shiftDateOneYearBack', () => {
|
||||
it('shifts a plain date one year back', () => {
|
||||
expect(shiftDateOneYearBack('2026-03-15')).toBe('2025-03-15')
|
||||
expect(shiftDateOneYearBack('2026-01-01')).toBe('2025-01-01')
|
||||
expect(shiftDateOneYearBack('2026-12-31')).toBe('2025-12-31')
|
||||
})
|
||||
|
||||
it('clamps leap day to the last day of February', () => {
|
||||
expect(shiftDateOneYearBack('2028-02-29')).toBe('2027-02-28')
|
||||
})
|
||||
|
||||
it('keeps Feb 29 when the target year is also a leap year divisible correctly', () => {
|
||||
// 2001 -> 2000 is a leap year (divisible by 400)
|
||||
expect(shiftDateOneYearBack('2001-02-28')).toBe('2000-02-28')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -845,6 +845,10 @@ export const MASTER_DATA_DUMP_TABLES: MasterDataTableSpec[] = [
|
||||
{ name: 'account_dimension_rules', file: 'account_dimension_rules.json' },
|
||||
// Compliance records
|
||||
{ name: 'voucher_gap_explanations', file: 'voucher_gap_explanations.json', orderBy: 'created_at' },
|
||||
// Inline rättelse trail (BFL 5 kap 5 § / 9 §): holds the struck original
|
||||
// lines and the old description/date, i.e. the preserved side of every
|
||||
// in-verifikat rättelse — räkenskapsinformation, not an operation log.
|
||||
{ name: 'journal_entry_rattelse_log', file: 'journal_entry_rattelse_log.json', orderBy: 'created_at' },
|
||||
{ name: 'journal_entry_no_doc_required', file: 'journal_entry_no_doc_required.json', pageKey: 'journal_entry_id' },
|
||||
{ name: 'rot_rut_payout_requests', file: 'rot_rut_payout_requests.json', orderBy: 'created_at' },
|
||||
{ name: 'rot_rut_payout_request_items', file: 'rot_rut_payout_request_items.json', via: { parent: 'rot_rut_payout_requests', fk: 'request_id' } },
|
||||
|
||||
@@ -60,17 +60,17 @@ export async function generateResultatrapport(
|
||||
})
|
||||
const currentRows = filterPnl(currentTb.rows)
|
||||
|
||||
// Prior-period comparison stays full-year. A narrower current window
|
||||
// compared against a full prior year would be misleading; until we ship a
|
||||
// proper "same window, prior year" comparison the cleanest move is to
|
||||
// drop the prior column entirely when the user narrows the range.
|
||||
// Same rule for a dimension filter: project codes are time-limited under
|
||||
// K2/K3 (registry start/end dates), so "this code last year" may be a
|
||||
// different project entirely: drop the column rather than compare
|
||||
// Prior-period comparison. Full period: the previous fiscal period.
|
||||
// Narrowed range: the same window shifted one year back (#862), so
|
||||
// "Hittills i år" compares against the same dates last year.
|
||||
// Dimension filter: no comparison at all; project codes are time-limited
|
||||
// under K2/K3 (registry start/end dates), so "this code last year" may be
|
||||
// a different project entirely: drop the column rather than compare
|
||||
// unrelated activity (#862 review).
|
||||
let priorRows: TrialBalanceRow[] = []
|
||||
let priorPeriodInfo: { start: string; end: string } | null = null
|
||||
const isFullPeriod = !options?.fromDate && !options?.toDate && !options?.dimensions
|
||||
const hasRange = Boolean(options?.fromDate || options?.toDate)
|
||||
const isFullPeriod = !hasRange && !options?.dimensions
|
||||
if (isFullPeriod) {
|
||||
// Prefer the explicit continuity chain; fall back to the period that ends
|
||||
// immediately before this one. The fallback keeps the comparison working
|
||||
@@ -102,6 +102,51 @@ export async function generateResultatrapport(
|
||||
priorPeriodInfo = { start: prior.period_start, end: prior.period_end }
|
||||
}
|
||||
}
|
||||
} else if (hasRange && !options?.dimensions) {
|
||||
// Same window, prior year. Shift the window back one year (leap-day
|
||||
// clamped) and read activity from whichever fiscal period(s) cover the
|
||||
// shifted dates: brutet räkenskapsår can split a calendar window across
|
||||
// two periods, so merge per account. The current period itself is a
|
||||
// valid source (a long first fiscal year can contain both windows).
|
||||
const shiftedFrom = shiftDateOneYearBack(effectiveFromDate)
|
||||
const shiftedTo = shiftDateOneYearBack(effectiveToDate)
|
||||
// A window longer than a year would overlap itself when shifted back;
|
||||
// that comparison is meaningless, so leave the column empty.
|
||||
if (shiftedTo < effectiveFromDate) {
|
||||
const { data: candidatePeriods } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('id, period_start, period_end')
|
||||
.eq('company_id', companyId)
|
||||
.lte('period_start', shiftedTo)
|
||||
.gte('period_end', shiftedFrom)
|
||||
.order('period_start', { ascending: true })
|
||||
|
||||
const merged = new Map<string, TrialBalanceRow>()
|
||||
let coveredAny = false
|
||||
for (const p of candidatePeriods ?? []) {
|
||||
const from = shiftedFrom > p.period_start ? shiftedFrom : p.period_start
|
||||
const to = shiftedTo < p.period_end ? shiftedTo : p.period_end
|
||||
if (from > to) continue
|
||||
const tbPart = await generateTrialBalance(supabase, companyId, p.id, {
|
||||
fromDate: from,
|
||||
toDate: to,
|
||||
})
|
||||
coveredAny = true
|
||||
for (const row of filterPnl(tbPart.rows)) {
|
||||
const existing = merged.get(row.account_number)
|
||||
if (!existing) {
|
||||
merged.set(row.account_number, { ...row })
|
||||
} else {
|
||||
existing.period_debit = round2(existing.period_debit + row.period_debit)
|
||||
existing.period_credit = round2(existing.period_credit + row.period_credit)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (coveredAny) {
|
||||
priorRows = [...merged.values()]
|
||||
priorPeriodInfo = { start: shiftedFrom, end: shiftedTo }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const priorByAccount = new Map<string, TrialBalanceRow>()
|
||||
@@ -135,9 +180,28 @@ function filterPnl(rows: TrialBalanceRow[]): TrialBalanceRow[] {
|
||||
* (class 4-7) have debit. We render every line as `credit - debit` so that
|
||||
* revenue is positive, expenses are negative, and a positive net result
|
||||
* means profit. This matches how Fortnox and Visma present a Resultatrapport.
|
||||
*
|
||||
* Window activity (`period_*`), not `closing_*`: when fromDate > period_start
|
||||
* the trial balance rolls pre-window activity into the opening columns, so
|
||||
* `closing_*` on a P&L account would silently report year-to-date amounts in
|
||||
* a month/quarter window. In the full-period case P&L accounts carry no
|
||||
* opening balance, so period_* equals closing_* and nothing changes there.
|
||||
*/
|
||||
function signedAmount(row: TrialBalanceRow): number {
|
||||
return row.closing_credit - row.closing_debit
|
||||
return row.period_credit - row.period_debit
|
||||
}
|
||||
|
||||
/**
|
||||
* `2026-03-15` -> `2025-03-15`; leap day clamps to the target month's last
|
||||
* day (`2028-02-29` -> `2027-02-28`). String math on the ISO parts: no Date
|
||||
* object, no timezone edge.
|
||||
*/
|
||||
export function shiftDateOneYearBack(isoDate: string): string {
|
||||
const [y, m, d] = isoDate.split('-').map(Number)
|
||||
const year = y - 1
|
||||
const daysInMonth = [31, year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
|
||||
const day = Math.min(d, daysInMonth[m - 1])
|
||||
return `${year}-${String(m).padStart(2, '0')}-${String(day).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function sumNet(rows: TrialBalanceRow[]): number {
|
||||
|
||||
@@ -517,6 +517,296 @@ describe('ingestTransactions', () => {
|
||||
expect(result.duplicates).toBe(0)
|
||||
})
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 2c-hand. Booked-hand-entered mirror: the user bookkeeps by chat/MCP FIRST
|
||||
// (free-form Swedish title, booked), then connects the bank; the feed
|
||||
// delivers the bank's copy of the same movement with a raw provider
|
||||
// string that shares no text. Same (date, öre), count-symmetric bucket
|
||||
// → deduped against the BOOKED hand-entered row.
|
||||
// -----------------------------------------------------------------------
|
||||
it('dedupes an incoming feed row against a booked hand-entered (mcp) twin whose title does not bridge', async () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
const raw = makeRaw({
|
||||
date: '2026-06-24',
|
||||
amount: -520,
|
||||
description: 'PLAN_ORDER_CHECKOUT-invoice-11111 Account fee', // raw provider text
|
||||
external_id: 'eb_SE00_2026-06-24_-52000_0',
|
||||
import_source: 'enable_banking',
|
||||
})
|
||||
|
||||
// Booked map: the hand-entered MCP row the user already booked.
|
||||
enqueue({
|
||||
data: [{
|
||||
date: '2026-06-24', amount: -520,
|
||||
original_description: 'Wise Business engångsavgift kontoöppning',
|
||||
description: 'Wise Business engångsavgift kontoöppning',
|
||||
import_source: 'mcp', bank_connection_id: null,
|
||||
cash_account_id: null, currency: 'SEK',
|
||||
}],
|
||||
error: null,
|
||||
})
|
||||
enqueue({ data: [], error: null }) // unbooked map: none
|
||||
enqueue({ data: [], error: null }) // supplier invoices
|
||||
enqueue({ data: [], error: null }) // external_id dedup: different namespace, no match
|
||||
// No insert: deduped by the booked-hand-entered mirror.
|
||||
|
||||
const result = await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, [raw])
|
||||
|
||||
expect(result.duplicates).toBe(1)
|
||||
expect(result.imported).toBe(0)
|
||||
})
|
||||
|
||||
it('never consumes an UNBOOKED hand-entered row (staged intent is not ledger evidence)', async () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
const raw = makeRaw({
|
||||
date: '2026-06-24',
|
||||
amount: -520,
|
||||
description: 'PLAN_ORDER_CHECKOUT-invoice-11111 Account fee',
|
||||
external_id: 'eb_SE00_2026-06-24_-52000_0',
|
||||
import_source: 'enable_banking',
|
||||
})
|
||||
const inserted = makeTransaction({ id: 'tx-kept', external_id: raw.external_id, amount: -520 })
|
||||
|
||||
enqueue({ data: [], error: null }) // booked map: none
|
||||
// Unbooked map: even if an mcp row leaked in here, it must not be a mirror
|
||||
// candidate (in production the query excludes manual/mcp at the DB level).
|
||||
enqueue({
|
||||
data: [{
|
||||
date: '2026-06-24', amount: -520,
|
||||
original_description: 'Wise Business engångsavgift kontoöppning',
|
||||
description: 'Wise Business engångsavgift kontoöppning',
|
||||
import_source: 'mcp', bank_connection_id: null,
|
||||
cash_account_id: null, currency: 'SEK',
|
||||
}],
|
||||
error: null,
|
||||
})
|
||||
enqueue({ data: [], error: null }) // supplier invoices
|
||||
enqueue({ data: [], error: null }) // external_id dedup
|
||||
enqueue({ data: inserted, error: null }) // insert: NOT deduped
|
||||
mockEvaluateMappingRules.mockResolvedValue(makeMappingResult({ confidence: 0.5 }))
|
||||
|
||||
const result = await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, [raw])
|
||||
|
||||
expect(result.imported).toBe(1)
|
||||
expect(result.duplicates).toBe(0)
|
||||
})
|
||||
|
||||
it('does not hand-entered-dedupe an asymmetric bucket (two incoming vs one booked mcp row)', async () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
const rows = [
|
||||
makeRaw({ date: '2026-06-24', amount: -520, description: 'TRANSFER-1 Payment', external_id: 'eb_a', import_source: 'enable_banking' }),
|
||||
makeRaw({ date: '2026-06-24', amount: -520, description: 'TRANSFER-2 Payment', external_id: 'eb_b', import_source: 'enable_banking' }),
|
||||
]
|
||||
|
||||
// Booked map: ONE hand-entered row → incoming 2 vs stored 1 = asymmetric.
|
||||
enqueue({
|
||||
data: [{
|
||||
date: '2026-06-24', amount: -520,
|
||||
original_description: 'Betalning till leverantör',
|
||||
description: 'Betalning till leverantör',
|
||||
import_source: 'mcp', bank_connection_id: null,
|
||||
cash_account_id: null, currency: 'SEK',
|
||||
}],
|
||||
error: null,
|
||||
})
|
||||
enqueue({ data: [], error: null }) // unbooked map
|
||||
enqueue({ data: [], error: null }) // supplier invoices
|
||||
enqueue({ data: [], error: null }) // external_id dedup
|
||||
enqueue({ data: makeTransaction({ id: 'tx-a', amount: -520 }), error: null })
|
||||
enqueue({ data: makeTransaction({ id: 'tx-b', amount: -520 }), error: null })
|
||||
mockEvaluateMappingRules.mockResolvedValue(makeMappingResult({ confidence: 0.5 }))
|
||||
|
||||
const result = await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, rows)
|
||||
|
||||
expect(result.imported).toBe(2)
|
||||
expect(result.duplicates).toBe(0)
|
||||
})
|
||||
|
||||
it('does not hand-entered-dedupe across currencies (SEK manual row vs USD feed row)', async () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
// Numerically equal amounts land in the same (date, öre) bucket, but the
|
||||
// currencies differ → the mirror must stay off.
|
||||
const raw = makeRaw({
|
||||
date: '2026-07-13',
|
||||
amount: 2500,
|
||||
currency: 'USD',
|
||||
description: 'TRANSFER-9 Facilitation',
|
||||
external_id: 'eb_US00_2026-07-13_250000_0',
|
||||
import_source: 'enable_banking',
|
||||
})
|
||||
const inserted = makeTransaction({ id: 'tx-usd', external_id: raw.external_id, amount: 2500 })
|
||||
|
||||
enqueue({
|
||||
data: [{
|
||||
date: '2026-07-13', amount: 2500,
|
||||
original_description: 'Kundinbetalning bankgiro',
|
||||
description: 'Kundinbetalning bankgiro',
|
||||
import_source: 'mcp', bank_connection_id: null,
|
||||
cash_account_id: null, currency: 'SEK',
|
||||
}],
|
||||
error: null,
|
||||
}) // booked map: SEK hand-entered row, same date+öre
|
||||
enqueue({ data: [], error: null }) // unbooked map
|
||||
enqueue({ data: [], error: null }) // supplier invoices
|
||||
enqueue({ data: [], error: null }) // external_id dedup
|
||||
enqueue({ data: inserted, error: null }) // insert: kept
|
||||
mockFetchExchangeRate.mockResolvedValue(null)
|
||||
mockEvaluateMappingRules.mockResolvedValue(makeMappingResult({ confidence: 0.5 }))
|
||||
|
||||
const result = await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, [raw])
|
||||
|
||||
expect(result.imported).toBe(1)
|
||||
expect(result.duplicates).toBe(0)
|
||||
expect(mockGetBestInvoiceMatch).toHaveBeenCalled() // income path still runs
|
||||
})
|
||||
|
||||
it('respects the cash-account guard on the booked-hand-entered mirror path', async () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
const raw = makeRaw({
|
||||
date: '2026-06-24',
|
||||
amount: -520,
|
||||
description: 'TRANSFER-5 Payment',
|
||||
external_id: 'eb_acctB_2026-06-24_-52000_0',
|
||||
import_source: 'enable_banking',
|
||||
})
|
||||
const inserted = makeTransaction({ id: 'tx-acctB', amount: -520 })
|
||||
|
||||
// Booked hand-entered row explicitly bound to a DIFFERENT cash account.
|
||||
enqueue({
|
||||
data: [{
|
||||
date: '2026-06-24', amount: -520,
|
||||
original_description: 'Egen insättning',
|
||||
description: 'Egen insättning',
|
||||
import_source: 'mcp', bank_connection_id: null,
|
||||
cash_account_id: 'acct-A', currency: 'SEK',
|
||||
}],
|
||||
error: null,
|
||||
})
|
||||
enqueue({ data: [], error: null }) // unbooked map
|
||||
enqueue({ data: [], error: null }) // supplier invoices
|
||||
enqueue({ data: [], error: null }) // external_id dedup
|
||||
enqueue({ data: { id: 'acct-B' }, error: null }) // cash_accounts → batch on account B
|
||||
enqueue({ data: inserted, error: null }) // insert: kept
|
||||
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(result.duplicates).toBe(0)
|
||||
})
|
||||
|
||||
it('stamps the batch cash account onto a consumed null-account hand row (one-consume-ever adoption)', async () => {
|
||||
const { supabase, enqueue, updates } = createQueueMockSupabase()
|
||||
const raw = makeRaw({
|
||||
date: '2026-06-24',
|
||||
amount: -520,
|
||||
description: 'TRANSFER-5 Payment', // does not bridge the hand row's title
|
||||
external_id: 'eb_acctA_2026-06-24_-52000_0',
|
||||
import_source: 'enable_banking',
|
||||
})
|
||||
|
||||
// Booked MANUAL row, account-unbound (cash_account_id null).
|
||||
enqueue({
|
||||
data: [{
|
||||
id: 'tx-hand-1',
|
||||
date: '2026-06-24', amount: -520,
|
||||
original_description: 'Egen insättning',
|
||||
description: 'Egen insättning',
|
||||
import_source: 'manual', bank_connection_id: null,
|
||||
cash_account_id: null, currency: 'SEK',
|
||||
}],
|
||||
error: null,
|
||||
})
|
||||
enqueue({ data: [], error: null }) // unbooked map
|
||||
enqueue({ data: [], error: null }) // supplier invoices
|
||||
enqueue({ data: [], error: null }) // external_id dedup
|
||||
enqueue({ data: { id: 'acct-A' }, error: null }) // cash_accounts → batch on account A
|
||||
enqueue({ data: null, error: null }) // adoption stamp update
|
||||
|
||||
const result = await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, [raw], {
|
||||
settlementAccount: '1930',
|
||||
})
|
||||
|
||||
expect(result.duplicates).toBe(1)
|
||||
expect(result.imported).toBe(0)
|
||||
// The hand row is now bound to account A, so it can never consume a feed
|
||||
// row on another account in a later sync.
|
||||
const txUpdates = (updates['transactions'] ?? []) as Record<string, unknown>[]
|
||||
expect(txUpdates).toContainEqual({ cash_account_id: 'acct-A' })
|
||||
})
|
||||
|
||||
it('does not let a Layer-1 duplicate inflate the hand-mirror symmetry count', async () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
// R0 is already stored (Layer-1 kills it); R1 is genuinely new. TWO booked
|
||||
// hand rows share the bucket. Coarse counting would say 2 incoming == 2
|
||||
// stored and consume a hand row for R1; the honest unmatched count is
|
||||
// 1 vs 2 → asymmetric → R1 must insert.
|
||||
const rows = [
|
||||
makeRaw({ date: '2026-06-24', amount: -520, description: 'TRANSFER-1 Pay', external_id: 'eb_stored_0', import_source: 'enable_banking' }),
|
||||
makeRaw({ date: '2026-06-24', amount: -520, description: 'TRANSFER-2 Pay', external_id: 'eb_new_1', import_source: 'enable_banking' }),
|
||||
]
|
||||
const inserted = makeTransaction({ id: 'tx-new', amount: -520 })
|
||||
|
||||
enqueue({
|
||||
data: [
|
||||
{ id: 'h1', date: '2026-06-24', amount: -520, original_description: 'Hyra lokal', description: 'Hyra lokal', import_source: 'mcp', bank_connection_id: null, cash_account_id: null, currency: 'SEK' },
|
||||
{ id: 'h2', date: '2026-06-24', amount: -520, original_description: 'Egen insättning', description: 'Egen insättning', import_source: 'mcp', bank_connection_id: null, cash_account_id: null, currency: 'SEK' },
|
||||
],
|
||||
error: null,
|
||||
}) // booked map: two hand rows
|
||||
enqueue({ data: [], error: null }) // unbooked map
|
||||
enqueue({ data: [], error: null }) // supplier invoices
|
||||
enqueue({ data: [{ external_id: 'eb_stored_0' }], error: null }) // external_id dedup: R0 already stored
|
||||
enqueue({ data: inserted, error: null }) // insert R1: kept
|
||||
mockEvaluateMappingRules.mockResolvedValue(makeMappingResult({ confidence: 0.5 }))
|
||||
|
||||
const result = await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, rows)
|
||||
|
||||
expect(result.duplicates).toBe(1) // R0 via Layer-1 only
|
||||
expect(result.imported).toBe(1) // R1 inserted, no hand row consumed
|
||||
})
|
||||
|
||||
it('excludes account-incompatible hand rows from the symmetry count (no mirror flip)', async () => {
|
||||
const { supabase, enqueue, updates } = createQueueMockSupabase()
|
||||
// Batch settles on account B. Stored: M1 (account-unbound) and M2 (bound to
|
||||
// account A). If M2 were counted, 2 incoming == 2 stored would switch the
|
||||
// mirror on and the non-bridging R1 would wrongly consume M1. With the
|
||||
// guard applied to the COUNT, symmetry is 2 vs 1 → mirror off → R1 inserts
|
||||
// and R2 dedups via its text bridge against M1.
|
||||
const rows = [
|
||||
makeRaw({ date: '2026-06-24', amount: -10000, description: 'TRANSFER-7 Pay', external_id: 'eb_r1', import_source: 'enable_banking' }),
|
||||
makeRaw({ date: '2026-06-24', amount: -10000, description: 'Hyra avtal 12 betalning juni', external_id: 'eb_r2', import_source: 'enable_banking' }),
|
||||
]
|
||||
const inserted = makeTransaction({ id: 'tx-r1', amount: -10000 })
|
||||
|
||||
enqueue({
|
||||
data: [
|
||||
{ id: 'm1', date: '2026-06-24', amount: -10000, original_description: 'Hyra avtal 12', description: 'Hyra avtal 12', import_source: 'mcp', bank_connection_id: null, cash_account_id: null, currency: 'SEK' },
|
||||
{ id: 'm2', date: '2026-06-24', amount: -10000, original_description: 'Hyra avtal 12', description: 'Hyra avtal 12', import_source: 'mcp', bank_connection_id: null, cash_account_id: 'acct-A', currency: 'SEK' },
|
||||
],
|
||||
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: 'acct-B' }, error: null }) // cash_accounts → account B
|
||||
enqueue({ data: inserted, error: null }) // insert R1
|
||||
enqueue({ data: null, error: null }) // adoption stamp for M1 (text-bridged by R2)
|
||||
mockEvaluateMappingRules.mockResolvedValue(makeMappingResult({ confidence: 0.5 }))
|
||||
|
||||
const result = await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, rows, {
|
||||
settlementAccount: '1931',
|
||||
})
|
||||
|
||||
expect(result.imported).toBe(1) // R1 kept (genuinely new)
|
||||
expect(result.duplicates).toBe(1) // R2 text-bridged M1
|
||||
// The text-bridge consumption also binds M1 to account B.
|
||||
const txUpdates = (updates['transactions'] ?? []) as Record<string, unknown>[]
|
||||
expect(txUpdates).toContainEqual({ cash_account_id: 'acct-B' })
|
||||
})
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 2c-shadow. Same-feed scope-drift (Hole A): SHADOW MODE. Enable Banking
|
||||
// returns the same account under a drifted IBAN, so the IBAN-embedded
|
||||
|
||||
+172
-19
@@ -15,6 +15,13 @@ import type { Transaction, RawTransaction, IngestResult, IngestOptions, Supplier
|
||||
// Re-export types for backward compatibility
|
||||
export type { RawTransaction, IngestResult } from '@/types'
|
||||
|
||||
/**
|
||||
* Sentinel for a (date, öre) bucket whose incoming rows carry more than one
|
||||
* currency: the booked-hand-entered mirror's per-bucket currency gate cannot be
|
||||
* evaluated there, so the mirror is disabled for that bucket.
|
||||
*/
|
||||
const MIXED_CURRENCIES = Symbol('mixed-currencies')
|
||||
|
||||
/**
|
||||
* One existing row in a content-dedup bucket: its normalized/lowercased
|
||||
* description, the cash account it settled on (null for legacy rows that
|
||||
@@ -24,10 +31,19 @@ export type { RawTransaction, IngestResult } from '@/types'
|
||||
* `consumeBridgingTwin`); `cashAccountId` is the cross-account guard.
|
||||
*/
|
||||
type BucketEntry = {
|
||||
/** Row id of the stored transaction; used to persist hand-mirror adoption. */
|
||||
id: string | null
|
||||
desc: string
|
||||
cashAccountId: string | null
|
||||
source: string | null
|
||||
isImportFeed: boolean
|
||||
/**
|
||||
* ISO currency of the stored row ('SEK', 'USD', ...), null for rows without
|
||||
* one. Guards the booked-hand-entered mirror: the content bucket keys on
|
||||
* (date, öre) only, so without this a manual 2 500 SEK row could consume an
|
||||
* incoming 2 500 USD feed row.
|
||||
*/
|
||||
currency: string | null
|
||||
/**
|
||||
* The stored row's `external_id`. Used ONLY by the shadow-mode same-feed
|
||||
* scope-drift instrumentation (see ingestTransactions): a stored row is a
|
||||
@@ -48,7 +64,15 @@ type BucketEntry = {
|
||||
type DescBucket = Map<string, BucketEntry[]>
|
||||
|
||||
interface ExistingTransactionMaps {
|
||||
/** Booked transactions (any source): consumed by any incoming raw transaction. */
|
||||
/**
|
||||
* Booked transactions (any source): consumed by any incoming raw transaction.
|
||||
* Hand-entered rows (import_source manual/mcp/null, no bank connection) in
|
||||
* THIS map are also cross-channel-mirror candidates: a booked hand-entered
|
||||
* row is the ledger asserting the movement already exists, so an incoming
|
||||
* feed row for the same (date, öre, currency, account) in a count-symmetric
|
||||
* bucket is the bank's copy of it, not new money (the MCP-then-bank-sync
|
||||
* case: user bookkeeps by chat first, connects the bank later).
|
||||
*/
|
||||
booked: DescBucket
|
||||
/**
|
||||
* Unbooked rows from ANY external import feed (Enable Banking PSD2 sync,
|
||||
@@ -57,8 +81,9 @@ interface ExistingTransactionMaps {
|
||||
* account pulled once via PSD2 and once via a CSV/CAMT file upload (in either
|
||||
* order), plus PSD2 reconnect duplicates whose external_id regenerated.
|
||||
* Hand-entered rows (import_source manual/mcp/null) are deliberately
|
||||
* excluded: only real feeds mirror one another, and a manual row must never
|
||||
* be silently consumed by an import.
|
||||
* excluded HERE: an UNBOOKED hand-entered row is just a staged intent (e.g.
|
||||
* an MCP draft awaiting approval), not ledger evidence, so it must never
|
||||
* consume an incoming import.
|
||||
*/
|
||||
unbookedImported: DescBucket
|
||||
}
|
||||
@@ -66,20 +91,24 @@ interface ExistingTransactionMaps {
|
||||
/** Push a row into its (date, öre) bucket, normalizing the description. */
|
||||
function addToBucket(
|
||||
bucket: DescBucket,
|
||||
id: string | null,
|
||||
date: string,
|
||||
amount: number | string,
|
||||
description: string,
|
||||
cashAccountId: string | null,
|
||||
source: string | null,
|
||||
isImportFeed: boolean,
|
||||
currency: string | null,
|
||||
externalId: string | null,
|
||||
): void {
|
||||
const key = contentBucketKey(date, amount)
|
||||
const entry: BucketEntry = {
|
||||
id,
|
||||
desc: description.toLowerCase().trim(),
|
||||
cashAccountId,
|
||||
source,
|
||||
isImportFeed,
|
||||
currency,
|
||||
externalId,
|
||||
}
|
||||
const entries = bucket.get(key)
|
||||
@@ -103,7 +132,7 @@ async function buildExistingTransactionMaps(
|
||||
try {
|
||||
const { data: bookedRows } = await supabase
|
||||
.from('transactions')
|
||||
.select('date, amount, original_description, description, cash_account_id, import_source, bank_connection_id, external_id')
|
||||
.select('id, date, amount, original_description, description, cash_account_id, import_source, bank_connection_id, currency, external_id')
|
||||
.eq('company_id', companyId)
|
||||
.not('journal_entry_id', 'is', null)
|
||||
.gte('date', dateFrom)
|
||||
@@ -117,12 +146,14 @@ async function buildExistingTransactionMaps(
|
||||
// original_description column.
|
||||
addToBucket(
|
||||
booked,
|
||||
tx.id ?? null,
|
||||
tx.date,
|
||||
tx.amount,
|
||||
normalizeImportedDescription(tx.original_description ?? tx.description),
|
||||
tx.cash_account_id ?? null,
|
||||
tx.import_source ?? null,
|
||||
isImportedTransaction({ import_source: tx.import_source, bank_connection_id: tx.bank_connection_id }),
|
||||
tx.currency ?? null,
|
||||
tx.external_id ?? null,
|
||||
)
|
||||
}
|
||||
@@ -139,7 +170,7 @@ async function buildExistingTransactionMaps(
|
||||
// manual / mcp are hand-entered and intentionally excluded.
|
||||
const { data: unbookedRows } = await supabase
|
||||
.from('transactions')
|
||||
.select('date, amount, original_description, description, cash_account_id, import_source, bank_connection_id, external_id')
|
||||
.select('id, date, amount, original_description, description, cash_account_id, import_source, bank_connection_id, currency, external_id')
|
||||
.eq('company_id', companyId)
|
||||
.is('journal_entry_id', null)
|
||||
.not('import_source', 'is', null)
|
||||
@@ -154,12 +185,14 @@ async function buildExistingTransactionMaps(
|
||||
// user title edit cannot reopen the duplicate-import window.
|
||||
addToBucket(
|
||||
unbookedImported,
|
||||
tx.id ?? null,
|
||||
tx.date,
|
||||
tx.amount,
|
||||
normalizeImportedDescription(tx.original_description ?? tx.description),
|
||||
tx.cash_account_id ?? null,
|
||||
tx.import_source ?? null,
|
||||
isImportedTransaction({ import_source: tx.import_source, bank_connection_id: tx.bank_connection_id }),
|
||||
tx.currency ?? null,
|
||||
tx.external_id ?? null,
|
||||
)
|
||||
}
|
||||
@@ -249,14 +282,30 @@ export async function ingestTransactions(
|
||||
// (date, öre, account) without a description match (see consumeBridgingTwin).
|
||||
// An asymmetric bucket keeps the description requirement, so a genuinely-new
|
||||
// row is never collapsed into a different one.
|
||||
//
|
||||
// The same count-symmetry signal also runs against BOOKED hand-entered rows
|
||||
// (import_source manual/mcp/null): a user who bookkeeps by chat/MCP first and
|
||||
// connects the bank afterwards has already put the movement in the ledger,
|
||||
// and the feed's copy of it must not re-appear as a duplicate. This mirror is
|
||||
// gated harder than feed-vs-feed: the stored row must be BOOKED (staged/
|
||||
// unbooked hand-entered rows never consume an import), its currency must not
|
||||
// contradict the incoming row's, its cash account must be compatible, and the
|
||||
// bucket counts must match exactly. Tracked in a SEPARATE count map (built
|
||||
// further down, once the batch settlement account and the stored external_id
|
||||
// set are known) so the feed-vs-feed mirror semantics are untouched: a
|
||||
// hand-entered twin never breaks feed count symmetry.
|
||||
const batchSource = rawTransactions[0]?.import_source ?? null
|
||||
const batchIsImportFeed = isImportedTransaction({ import_source: batchSource })
|
||||
const incomingByBucket = new Map<string, number>()
|
||||
const crossSourceStoredByBucket = new Map<string, number>()
|
||||
const incomingCurrencyByBucket = new Map<string, string | null | typeof MIXED_CURRENCIES>()
|
||||
if (batchIsImportFeed) {
|
||||
for (const raw of rawTransactions) {
|
||||
const k = contentBucketKey(raw.date, raw.amount)
|
||||
incomingByBucket.set(k, (incomingByBucket.get(k) ?? 0) + 1)
|
||||
const cur = raw.currency ?? null
|
||||
if (!incomingCurrencyByBucket.has(k)) incomingCurrencyByBucket.set(k, cur)
|
||||
else if (incomingCurrencyByBucket.get(k) !== cur) incomingCurrencyByBucket.set(k, MIXED_CURRENCIES)
|
||||
}
|
||||
for (const bucket of [existingMaps.booked, existingMaps.unbookedImported]) {
|
||||
for (const [k, entries] of bucket) {
|
||||
@@ -391,15 +440,50 @@ export async function ingestTransactions(
|
||||
// bucket is left alone. Counts are pre-loop snapshots; the gate is evaluated
|
||||
// per incoming row inside the loop.
|
||||
const incomingIdSet = new Set(externalIds)
|
||||
// Incoming rows Layer-1 will NOT reconcile (their external_id is not already
|
||||
// stored): the honest per-bucket count of rows that will actually reach the
|
||||
// content-dedup layer. Shared by the hand-entered mirror (enforcing) and the
|
||||
// scope-drift shadow (measure-only): the coarse incomingByBucket would let a
|
||||
// Layer-1 duplicate inflate the symmetry check.
|
||||
const unmatchedIncomingByBucket = new Map<string, number>()
|
||||
const driftCandidateStoredByBucket = new Map<string, number>()
|
||||
if (batchIsImportFeed && scopeDriftShadow) {
|
||||
if (batchIsImportFeed) {
|
||||
for (const raw of rawTransactions) {
|
||||
if (!existingExternalIds.has(raw.external_id)) {
|
||||
const k = contentBucketKey(raw.date, raw.amount)
|
||||
unmatchedIncomingByBucket.set(k, (unmatchedIncomingByBucket.get(k) ?? 0) + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Booked-hand-entered mirror candidates ────────────────────────────────
|
||||
// Built HERE, after the batch settlement account (cashAccountId) and the
|
||||
// stored external_id set are known, so the counts apply the SAME guards the
|
||||
// consume step applies (account compatibility + currency). Counting entries
|
||||
// the consume step would reject makes "symmetry" lie: an unconsumable twin
|
||||
// could switch the mirror on and collapse a genuinely-new row. Hand-entered
|
||||
// candidates exist ONLY in the booked map: the unbooked map's query excludes
|
||||
// manual/mcp/null sources at the DB level.
|
||||
const bookedHandEnteredByBucket = new Map<string, number>()
|
||||
if (batchIsImportFeed) {
|
||||
for (const [k, entries] of existingMaps.booked) {
|
||||
const bucketCurrency = incomingCurrencyByBucket.get(k)
|
||||
// No incoming rows in this bucket, or the incoming rows disagree on
|
||||
// currency: the currency gate cannot be evaluated per-bucket, so the
|
||||
// hand-entered mirror stays off there (conservative: row inserts).
|
||||
if (bucketCurrency === undefined || bucketCurrency === MIXED_CURRENCIES) continue
|
||||
for (const entry of entries) {
|
||||
if (entry.isImportFeed) continue
|
||||
const accountCompatible =
|
||||
cashAccountId === null || entry.cashAccountId === null || entry.cashAccountId === cashAccountId
|
||||
if (!accountCompatible) continue
|
||||
if (entry.currency !== null && bucketCurrency !== null && entry.currency !== bucketCurrency) continue
|
||||
bookedHandEnteredByBucket.set(k, (bookedHandEnteredByBucket.get(k) ?? 0) + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const driftCandidateStoredByBucket = new Map<string, number>()
|
||||
if (batchIsImportFeed && scopeDriftShadow) {
|
||||
for (const bucket of [existingMaps.booked, existingMaps.unbookedImported]) {
|
||||
for (const [k, entries] of bucket) {
|
||||
for (const entry of entries) {
|
||||
@@ -460,13 +544,17 @@ export async function ingestTransactions(
|
||||
// 1b/1c. Content-dedup bridge: skip if an existing booked row (any source)
|
||||
// OR an unbooked import-feed row shares this (date, öre) bucket and EITHER
|
||||
// (a) a *bridging* description (prefix-containment, see descriptionsBridge),
|
||||
// OR (b) the bucket is a cross-channel mirror (crossSourceMirror below).
|
||||
// OR (b) the bucket is a cross-channel mirror (crossSourceMirror below),
|
||||
// OR (c) the bucket is a booked-hand-entered mirror (handEnteredMirror).
|
||||
// (a) catches re-imports the external_id check misses: old-format ids
|
||||
// re-synced after the id scheme changed, and PSD2 description enrichment
|
||||
// between syncs ("TIC" → "TIC BG … via internet"). (b) catches the same
|
||||
// bank account imported via two channels whose descriptions don't bridge at
|
||||
// all (Nordea CSV payee "TELENOR"/"Nordea" vs PSD2 OCR/message), which (a)
|
||||
// alone cannot. Booked first, then unbooked.
|
||||
// alone cannot. (c) catches the feed delivering a movement the user already
|
||||
// booked by hand (MCP/manual) under a free-form title that shares no text
|
||||
// with the bank's ("Egen insättning …" vs "TRANSFER-123 Topped up").
|
||||
// Booked first, then unbooked.
|
||||
//
|
||||
// Consumed with COUNTING semantics: each match splices one stored entry out
|
||||
// of its bucket, so N stored twins dedup exactly N incoming and two
|
||||
@@ -489,17 +577,34 @@ export async function ingestTransactions(
|
||||
// requirement dropped; an asymmetric bucket keeps it, so when the channels
|
||||
// disagree on how many transactions a bucket holds we keep a visible
|
||||
// (deletable) duplicate rather than risk collapsing a genuinely-new row.
|
||||
//
|
||||
// handEnteredMirror: the analogous signal against BOOKED hand-entered rows
|
||||
// (manual/mcp/null source). A booked hand-entered row means the user
|
||||
// already put this movement in the ledger before the feed delivered the
|
||||
// bank's copy (bookkeep-by-chat first, connect the bank later). Symmetry
|
||||
// compares the bucket's Layer-1-UNMATCHED incoming count (a row Layer-1
|
||||
// reconciles never reaches this layer, so it must not inflate the count)
|
||||
// against the guarded hand-entered candidate count, plus a per-entry
|
||||
// currency gate in consumeBridgingTwin (the bucket key is only date+öre,
|
||||
// and hand-entered rows often lack a cash_account_id for the account guard
|
||||
// to bite on).
|
||||
const bucketKey = contentBucketKey(raw.date, raw.amount)
|
||||
const rawCurrency = raw.currency ?? null
|
||||
const crossSourceMirror =
|
||||
batchIsImportFeed &&
|
||||
(crossSourceStoredByBucket.get(bucketKey) ?? 0) > 0 &&
|
||||
incomingByBucket.get(bucketKey) === crossSourceStoredByBucket.get(bucketKey)
|
||||
const consumeBridgingTwin = (bucket: DescBucket): boolean => {
|
||||
const handEnteredMirror =
|
||||
batchIsImportFeed &&
|
||||
(bookedHandEnteredByBucket.get(bucketKey) ?? 0) > 0 &&
|
||||
unmatchedIncomingByBucket.get(bucketKey) === bookedHandEnteredByBucket.get(bucketKey)
|
||||
const consumeBridgingTwin = (bucket: DescBucket): BucketEntry | null => {
|
||||
const entries = bucket.get(bucketKey)
|
||||
if (!entries || entries.length === 0) return false
|
||||
if (!entries || entries.length === 0) return null
|
||||
let bestIdx = -1
|
||||
let bestLen = -1
|
||||
let crossIdx = -1
|
||||
let handIdx = -1
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const entry = entries[i]
|
||||
const sameAccount =
|
||||
@@ -515,17 +620,65 @@ export async function ingestTransactions(
|
||||
if (crossIdx === -1 && crossSourceMirror && entry.isImportFeed && entry.source !== batchSource) {
|
||||
crossIdx = i
|
||||
}
|
||||
// Booked-hand-entered fallback: hand-entered entries only exist in the
|
||||
// booked map (the unbooked query excludes manual/mcp at the DB level),
|
||||
// so !isImportFeed here already implies booked. Currency must not
|
||||
// contradict: null on either side is compatible (legacy rows).
|
||||
if (
|
||||
handIdx === -1 &&
|
||||
handEnteredMirror &&
|
||||
!entry.isImportFeed &&
|
||||
(entry.currency === null || rawCurrency === null || entry.currency === rawCurrency)
|
||||
) {
|
||||
handIdx = i
|
||||
}
|
||||
}
|
||||
const idx = bestIdx !== -1 ? bestIdx : crossIdx
|
||||
if (idx === -1) return false
|
||||
entries.splice(idx, 1)
|
||||
return true
|
||||
const idx = bestIdx !== -1 ? bestIdx : crossIdx !== -1 ? crossIdx : handIdx
|
||||
if (idx === -1) return null
|
||||
const [consumed] = entries.splice(idx, 1)
|
||||
return consumed
|
||||
}
|
||||
if (
|
||||
consumeBridgingTwin(existingMaps.booked) ||
|
||||
consumeBridgingTwin(existingMaps.unbookedImported)
|
||||
) {
|
||||
const consumedTwin =
|
||||
consumeBridgingTwin(existingMaps.booked) ?? consumeBridgingTwin(existingMaps.unbookedImported)
|
||||
if (consumedTwin) {
|
||||
result.duplicates++
|
||||
// Persist the hand-mirror adoption: bind the account-unbound hand row to
|
||||
// the account this feed batch settled on. Consumption is otherwise
|
||||
// in-memory only, so without this ONE null-account hand row could
|
||||
// consume one genuine feed row per sync call on EVERY account whose
|
||||
// bucket happens to be date+öre+currency symmetric: permanent silent
|
||||
// suppression across accounts. After the stamp, the account guard
|
||||
// excludes this row from any other account's mirror. The `.is()` filter
|
||||
// makes the write race-safe (never overwrites a concurrent binding),
|
||||
// and a failure is non-critical: dedup already happened, the stamp only
|
||||
// narrows future consumption.
|
||||
if (
|
||||
!consumedTwin.isImportFeed &&
|
||||
consumedTwin.id !== null &&
|
||||
consumedTwin.cashAccountId === null &&
|
||||
cashAccountId !== null
|
||||
) {
|
||||
try {
|
||||
const { error: stampError } = await supabase
|
||||
.from('transactions')
|
||||
.update({ cash_account_id: cashAccountId })
|
||||
.eq('id', consumedTwin.id)
|
||||
.is('cash_account_id', null)
|
||||
if (stampError) {
|
||||
log.warn('hand-mirror adoption stamp failed; row stays unbound', {
|
||||
transactionId: consumedTwin.id,
|
||||
cashAccountId,
|
||||
error: stampError.message,
|
||||
})
|
||||
}
|
||||
} catch (stampError) {
|
||||
log.warn('hand-mirror adoption stamp failed; row stays unbound', {
|
||||
transactionId: consumedTwin.id,
|
||||
cashAccountId,
|
||||
error: stampError instanceof Error ? stampError.message : String(stampError),
|
||||
})
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
+26
-4
@@ -364,7 +364,16 @@
|
||||
"reason_amount_mismatch": "The amount does not match the invoice remaining balance",
|
||||
"reason_currency_mismatch": "The currency does not match the invoice",
|
||||
"reason_non_sek_invoice": "Foreign-currency invoice (book manually)",
|
||||
"reason_unknown": "Unknown reason"
|
||||
"reason_unknown": "Unknown reason",
|
||||
"transaction_sync_title": "Transactions from Stripe",
|
||||
"transaction_sync_description": "Import all Stripe transactions (payments, fees, refunds and payouts) into the transactions inbox every night, like a bank feed for your Stripe balance. Already-booked payments are linked to their vouchers; you book the rest as usual.",
|
||||
"transaction_sync_backfill_note": "The first sync fetches up to 90 days of history, but never before the bookkeeping lock date.",
|
||||
"transaction_sync_last_synced": "Last synced {date}",
|
||||
"transaction_sync_never_synced": "Not synced yet",
|
||||
"transaction_sync_enabled_toast": "Transaction sync enabled. History is fetched on the next sync.",
|
||||
"transaction_sync_disabled_toast": "Transaction sync disabled.",
|
||||
"transaction_sync_toggle_failed": "Could not save the setting. Please try again.",
|
||||
"sync_done_transactions": "{imported} transaction(s) imported, {linked} linked to vouchers."
|
||||
},
|
||||
"settings_modal": {
|
||||
"title": "Settings",
|
||||
@@ -1609,6 +1618,10 @@
|
||||
"connected_description": "You can now file declarations and fetch the tax account balance.",
|
||||
"connect_failed_title": "Connection failed"
|
||||
},
|
||||
"settings_assistant": {
|
||||
"fab_title": "Floating assistant button",
|
||||
"fab_description": "Show the assistant button in the bottom-right corner on every page. The assistant is always available in the sidebar."
|
||||
},
|
||||
"settings_account_danger": {
|
||||
"heading": "Delete account",
|
||||
"blockers_title": "Companies you own",
|
||||
@@ -2674,6 +2687,7 @@
|
||||
"create_correction": "Create correction entry",
|
||||
"not_posted": "Not posted",
|
||||
"possible_match_invoice": "Possible match: Invoice {number}",
|
||||
"match_voucher": "Match to existing voucher",
|
||||
"attach_document": "Match to document",
|
||||
"book": "Post",
|
||||
"delete": "Delete",
|
||||
@@ -3774,6 +3788,8 @@
|
||||
"scope_label": "Showing:",
|
||||
"scope_all_years": "All fiscal years",
|
||||
"out_of_period_label": "Subsequent",
|
||||
"rattelse_badge": "Corrected",
|
||||
"rattelse_badge_tooltip": "The voucher was corrected after posting: see the correction history on the voucher page",
|
||||
"out_of_period_tooltip": "Posted in a later fiscal year, but relates to the selected year (e.g. payment of an invoice issued in the selected year).",
|
||||
"out_of_period_tooltip_mobile": "Posted in a later fiscal year, but relates to the selected year.",
|
||||
"attachment_count_tooltip": "{count} documents",
|
||||
@@ -3965,8 +3981,14 @@
|
||||
"copy_entry": "Copy journal entry",
|
||||
"edit_entry": "Edit",
|
||||
"correct_menu": "Correct",
|
||||
"correct_lines": "Correct lines",
|
||||
"correct_lines": "Correct lines (correction entry)",
|
||||
"correct_date": "Correct date",
|
||||
"strike_lines": "Strike lines in the voucher",
|
||||
"correct_metadata": "Change text or date",
|
||||
"struck_marker": "Struck {date}",
|
||||
"rattelse_history_title": "Correction history",
|
||||
"rattelse_kind_metadata": "Text/date corrected",
|
||||
"rattelse_kind_lines": "Lines struck and replaced",
|
||||
"correct_opening_balances": "Correct opening balances",
|
||||
"reverse_action": "Reverse (storno)",
|
||||
"reverse_confirm_title": "Reverse journal entry",
|
||||
@@ -4053,8 +4075,8 @@
|
||||
"col_description": "Description",
|
||||
"col_debit": "Debit",
|
||||
"col_credit": "Credit",
|
||||
"col_saldo": "Balance (before)",
|
||||
"saldo_label": "Balance (before)",
|
||||
"col_saldo": "Balance",
|
||||
"saldo_label": "Balance",
|
||||
"line_description_placeholder": "Line text...",
|
||||
"sum": "Total",
|
||||
"sum_d": "D: {amount}",
|
||||
|
||||
+26
-4
@@ -364,7 +364,16 @@
|
||||
"reason_amount_mismatch": "Beloppet stämmer inte med fakturans restbelopp",
|
||||
"reason_currency_mismatch": "Valutan stämmer inte med fakturan",
|
||||
"reason_non_sek_invoice": "Faktura i utländsk valuta (bokförs manuellt)",
|
||||
"reason_unknown": "Okänd orsak"
|
||||
"reason_unknown": "Okänd orsak",
|
||||
"transaction_sync_title": "Transaktioner från Stripe",
|
||||
"transaction_sync_description": "Hämta alla Stripe-transaktioner (betalningar, avgifter, återbetalningar och utbetalningar) till transaktionsinkorgen varje natt, som ett bankflöde för ditt Stripe-saldo. Redan bokförda betalningar länkas till sina verifikat; övriga bokför du som vanligt.",
|
||||
"transaction_sync_backfill_note": "Vid första synkningen hämtas upp till 90 dagars historik, dock inte före bokföringslåset.",
|
||||
"transaction_sync_last_synced": "Senast synkad {date}",
|
||||
"transaction_sync_never_synced": "Inte synkad ännu",
|
||||
"transaction_sync_enabled_toast": "Transaktionssynk aktiverad. Historiken hämtas vid nästa synkning.",
|
||||
"transaction_sync_disabled_toast": "Transaktionssynk avaktiverad.",
|
||||
"transaction_sync_toggle_failed": "Kunde inte spara inställningen. Försök igen.",
|
||||
"sync_done_transactions": "{imported} transaktion(er) importerade, {linked} länkade till verifikat."
|
||||
},
|
||||
"settings_modal": {
|
||||
"title": "Inställningar",
|
||||
@@ -1609,6 +1618,10 @@
|
||||
"connected_description": "Du kan nu skicka deklarationer och hämta skattekonto-saldot.",
|
||||
"connect_failed_title": "Anslutning misslyckades"
|
||||
},
|
||||
"settings_assistant": {
|
||||
"fab_title": "Flytande assistentknapp",
|
||||
"fab_description": "Visa assistentknappen nere till höger på alla sidor. Assistenten finns alltid kvar i sidomenyn."
|
||||
},
|
||||
"settings_account_danger": {
|
||||
"heading": "Radera konto",
|
||||
"blockers_title": "Företag du äger",
|
||||
@@ -2674,6 +2687,7 @@
|
||||
"create_correction": "Skapa ändringsverifikation",
|
||||
"not_posted": "Ej bokförd",
|
||||
"possible_match_invoice": "Möjlig match: Faktura {number}",
|
||||
"match_voucher": "Matcha mot befintlig verifikation",
|
||||
"attach_document": "Matcha mot underlag",
|
||||
"book": "Bokför",
|
||||
"delete": "Ta bort",
|
||||
@@ -3774,6 +3788,8 @@
|
||||
"scope_label": "Visar:",
|
||||
"scope_all_years": "Alla räkenskapsår",
|
||||
"out_of_period_label": "Efterföljande",
|
||||
"rattelse_badge": "Rättad",
|
||||
"rattelse_badge_tooltip": "Verifikatet har rättats i efterhand: se rättelsehistoriken på verifikatsidan",
|
||||
"out_of_period_tooltip": "Bokförd i ett senare räkenskapsår, men avser det valda året (t.ex. betalning av en faktura utställd i det valda året).",
|
||||
"out_of_period_tooltip_mobile": "Bokförd i ett senare räkenskapsår, men avser det valda året.",
|
||||
"attachment_count_tooltip": "{count} underlag",
|
||||
@@ -3965,8 +3981,14 @@
|
||||
"copy_entry": "Kopiera verifikat",
|
||||
"edit_entry": "Redigera",
|
||||
"correct_menu": "Rätta",
|
||||
"correct_lines": "Rätta rader",
|
||||
"correct_lines": "Rätta rader (ändringsverifikat)",
|
||||
"correct_date": "Rätta datum",
|
||||
"strike_lines": "Stryk rader i verifikatet",
|
||||
"correct_metadata": "Ändra text eller datum",
|
||||
"struck_marker": "Struken {date}",
|
||||
"rattelse_history_title": "Rättelsehistorik",
|
||||
"rattelse_kind_metadata": "Text/datum rättat",
|
||||
"rattelse_kind_lines": "Rader strukna och ersatta",
|
||||
"correct_opening_balances": "Korrigera ingående balanser",
|
||||
"reverse_action": "Återför (storno)",
|
||||
"reverse_confirm_title": "Återför verifikat",
|
||||
@@ -4053,8 +4075,8 @@
|
||||
"col_description": "Beskrivning",
|
||||
"col_debit": "Debet",
|
||||
"col_credit": "Kredit",
|
||||
"col_saldo": "Saldo (före)",
|
||||
"saldo_label": "Saldo (före)",
|
||||
"col_saldo": "Saldo",
|
||||
"saldo_label": "Saldo",
|
||||
"line_description_placeholder": "Radtext...",
|
||||
"sum": "Summa",
|
||||
"sum_d": "D: {amount}",
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
{
|
||||
"horizontal/swedish-accounting-compliance": {
|
||||
"hash": "8f0ea563286a0fcbf0c7a30307111f06453dc0d5f201869d794772bf0a1538c8",
|
||||
"version": 3
|
||||
"hash": "2f04bdd94e9523ef25cff4530cf8b24c6f153dee437bb54d1befacbfdabe5305",
|
||||
"version": 4
|
||||
},
|
||||
"horizontal/swedish-accounting-compliance/bas-kontoplan": {
|
||||
"hash": "0d1b05466568344be0afd4b360d582db23331ae4f1b4b05681cc92c50d099bdb",
|
||||
"version": 2
|
||||
},
|
||||
"horizontal/swedish-accounting-compliance/bfl-bfnar": {
|
||||
"hash": "2240a0e67af22450468f5bcd85ddf2037fd3ed37e8db662afff6cbaf93d4b398",
|
||||
"version": 1
|
||||
"hash": "676a6dac3ed2b5104c42d59f1e7d7a591498a5ad175192a8e24f83b3e1700c83",
|
||||
"version": 2
|
||||
},
|
||||
"horizontal/swedish-accounting-compliance/changes-2025-2026": {
|
||||
"hash": "ffc4c5d618bb8840f43fd48c8d4a3e464768e84abdaeb49da016e0051b260964",
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
-- Stripe balance-transaction sync (the "Stripe balance as a bank feed" feature).
|
||||
--
|
||||
-- Balance transactions from the connected account land in the transactions
|
||||
-- inbox as feed rows on a 1686 cash account (see
|
||||
-- extensions/general/stripe/lib/transaction-sync.ts). Two new columns on
|
||||
-- stripe_connections drive it:
|
||||
--
|
||||
-- - transaction_sync_enabled: per-connection opt-in. Off by default: a
|
||||
-- company using only invoice payment links should not suddenly get a
|
||||
-- second feed in its inbox.
|
||||
-- - last_balance_txn_synced_at: polling cursor (max balance-transaction
|
||||
-- `created` processed). The sync re-polls with a 24h overlap and relies on
|
||||
-- the (company_id, external_id) unique index on transactions for
|
||||
-- idempotency, mirroring last_event_created_at for the event sync.
|
||||
|
||||
alter table public.stripe_connections
|
||||
add column transaction_sync_enabled boolean not null default false,
|
||||
add column last_balance_txn_synced_at timestamptz;
|
||||
|
||||
comment on column public.stripe_connections.transaction_sync_enabled is
|
||||
'Opt-in: import the connected account''s balance transactions into the transactions inbox (Stripe balance as a bank feed).';
|
||||
comment on column public.stripe_connections.last_balance_txn_synced_at is
|
||||
'Balance-transaction polling cursor (max created processed); the sync overlaps it by 24h and dedups on external_id.';
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,752 @@
|
||||
-- Verifikat inline rättelse (founder-approved 2026-07-23, both cards):
|
||||
--
|
||||
-- Card 1: correct description/entry_date of a POSTED entry without a
|
||||
-- rättelseverifikation (BFL 5 kap 9 §: a verifikation may be
|
||||
-- corrected if who/when is recorded).
|
||||
-- Card 2: strike lines inside a POSTED verifikat and add replacement
|
||||
-- lines in the SAME verifikat (BFL 5 kap 5 §: a bokföringspost
|
||||
-- may be corrected other than via a särskild rättelsepost, as
|
||||
-- long as the original remains visible and who/when is recorded).
|
||||
--
|
||||
-- Legal position: Fortnox and Visma both offer exactly this inside the same
|
||||
-- envelope. The envelope here is strictly bounded: posted entries only, open
|
||||
-- periods only (not closed, not locked), company lock date honored, one
|
||||
-- audited SECURITY DEFINER write path per operation, immutable before/after
|
||||
-- log (journal_entry_rattelse_log), and the struck original preserved both in
|
||||
-- the log (full row snapshots) and in audit_log (write_audit_log fires on all
|
||||
-- line DML). Past a lock/close, the storno flow remains the only path.
|
||||
--
|
||||
-- Carve-out pattern follows the sanctioned precedents:
|
||||
-- 20260608120000 (notes-only entry update), 20260613120000 (source_type
|
||||
-- retag GUC), 20260702170000 (dimension retag GUC + immutable log).
|
||||
--
|
||||
-- pg-test: tests/pg/inline-rattelse.pg.test.ts
|
||||
|
||||
-- =============================================================================
|
||||
-- 1. journal_entry_rattelse_log — immutable rättelse audit trail
|
||||
-- =============================================================================
|
||||
-- Like dimension_retag_log: no FK to journal_entries so the log survives
|
||||
-- hard-deletes (undo_sie_import); behandlingshistorik must not vanish with
|
||||
-- its subject. Company FK keeps tenant lifecycle.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.journal_entry_rattelse_log (
|
||||
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
|
||||
journal_entry_id uuid NOT NULL,
|
||||
rattelse_type text NOT NULL CHECK (rattelse_type IN ('metadata', 'lines')),
|
||||
old_description text,
|
||||
new_description text,
|
||||
old_entry_date date,
|
||||
new_entry_date date,
|
||||
struck_lines jsonb,
|
||||
added_lines jsonb,
|
||||
actor uuid,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
ALTER TABLE public.journal_entry_rattelse_log ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Read-only for members; INSERT happens exclusively inside the SECURITY
|
||||
-- DEFINER RPCs (no INSERT/UPDATE/DELETE policies on purpose).
|
||||
DROP POLICY IF EXISTS "view own-company journal_entry_rattelse_log"
|
||||
ON public.journal_entry_rattelse_log;
|
||||
CREATE POLICY "view own-company journal_entry_rattelse_log"
|
||||
ON public.journal_entry_rattelse_log FOR SELECT
|
||||
USING (company_id IN (SELECT user_company_ids()));
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_journal_entry_rattelse_log_entry
|
||||
ON public.journal_entry_rattelse_log (company_id, journal_entry_id);
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.journal_entry_rattelse_log_immutable()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION 'journal_entry_rattelse_log är oföränderlig — rader kan inte ändras eller tas bort.';
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS journal_entry_rattelse_log_immutable
|
||||
ON public.journal_entry_rattelse_log;
|
||||
CREATE TRIGGER journal_entry_rattelse_log_immutable
|
||||
BEFORE UPDATE OR DELETE ON public.journal_entry_rattelse_log
|
||||
FOR EACH ROW EXECUTE FUNCTION public.journal_entry_rattelse_log_immutable();
|
||||
|
||||
-- =============================================================================
|
||||
-- 2. Entry-immutability carve-out: metadata rättelse (append-only replacement)
|
||||
-- =============================================================================
|
||||
-- Adds ONE branch to the current function body (verbatim from staging/prod,
|
||||
-- last replaced by 20260613120000): while the transaction-local GUC set by
|
||||
-- correct_entry_metadata() is active, permit an UPDATE of a POSTED entry iff
|
||||
-- ONLY description/entry_date change (whole-row to_jsonb diff; updated_at
|
||||
-- exempt because journal_entries_updated_at bumps it). Voucher number, period,
|
||||
-- amounts, linkage and every future column stay immutable by construction.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.enforce_journal_entry_immutability()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
BEGIN
|
||||
IF TG_OP = 'DELETE' THEN
|
||||
IF current_setting('gnubok.allow_delete', true) = 'true' THEN
|
||||
RETURN OLD;
|
||||
END IF;
|
||||
RAISE EXCEPTION 'Cannot delete journal entries (id: %, status: %). Use cancelled status instead.',
|
||||
OLD.id, OLD.status;
|
||||
END IF;
|
||||
|
||||
IF OLD.status = 'draft' AND NEW.status IN ('draft', 'posted', 'cancelled') THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
IF OLD.status = 'posted' AND NEW.status IN ('reversed', 'cancelled') THEN
|
||||
IF NEW.status = 'reversed' THEN
|
||||
IF NEW.description != OLD.description OR NEW.entry_date != OLD.entry_date
|
||||
OR NEW.fiscal_period_id != OLD.fiscal_period_id
|
||||
OR NEW.voucher_number != OLD.voucher_number
|
||||
OR NEW.commit_method IS DISTINCT FROM OLD.commit_method
|
||||
OR NEW.rubric_version IS DISTINCT FROM OLD.rubric_version
|
||||
OR NEW.source_voucher_series IS DISTINCT FROM OLD.source_voucher_series
|
||||
OR NEW.source_voucher_number IS DISTINCT FROM OLD.source_voucher_number THEN
|
||||
RAISE EXCEPTION 'Cannot modify fields of a posted entry during reversal (id: %)', OLD.id;
|
||||
END IF;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- Narrow un-reversal path: when delete_last_voucher removes a storno entry,
|
||||
-- it flips the original from 'reversed' back to 'posted'. No other fields
|
||||
-- may change, and the bypass flag must be set.
|
||||
IF OLD.status = 'reversed' AND NEW.status = 'posted'
|
||||
AND current_setting('gnubok.allow_delete', true) = 'true' THEN
|
||||
IF NEW.description != OLD.description OR NEW.entry_date != OLD.entry_date
|
||||
OR NEW.fiscal_period_id != OLD.fiscal_period_id
|
||||
OR NEW.voucher_number != OLD.voucher_number THEN
|
||||
RAISE EXCEPTION 'Cannot modify fields during un-reversal (id: %)', OLD.id;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- Notes-only annotation on a committed entry (posted/reversed/cancelled).
|
||||
-- `notes` is internal metadata, not verifikation content, so editing it does
|
||||
-- not violate immutability. Allowed ONLY when the status is unchanged and the
|
||||
-- sole difference between OLD and NEW is `notes` (updated_at is exempt because
|
||||
-- the journal_entries_updated_at trigger bumps it). The to_jsonb() diff covers
|
||||
-- every other column automatically, so any real bookkeeping change still raises.
|
||||
IF OLD.status = NEW.status
|
||||
AND OLD.status IN ('posted', 'reversed', 'cancelled')
|
||||
AND (to_jsonb(NEW) - 'notes' - 'updated_at')
|
||||
= (to_jsonb(OLD) - 'notes' - 'updated_at') THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- Source-type re-tag of a mis-typed opening balance. source_type is internal
|
||||
-- classification metadata, not verifikation content (see header), so moving a
|
||||
-- bank-account IB from manual/import to opening_balance does not alter the
|
||||
-- bokföringspost. Allowed ONLY when: the transaction-local bypass flag set by
|
||||
-- mark_entry_as_opening_balance() is present; status is unchanged 'posted'; the
|
||||
-- value moves manual/import -> opening_balance; and source_type is the SOLE
|
||||
-- changed column (whole-row to_jsonb diff, updated_at exempt as above). Any other
|
||||
-- field delta, status change, or missing flag still raises below.
|
||||
IF OLD.status = NEW.status
|
||||
AND OLD.status = 'posted'
|
||||
AND current_setting('gnubok.allow_source_type_retag', true) = 'true'
|
||||
AND OLD.source_type IN ('manual', 'import')
|
||||
AND NEW.source_type = 'opening_balance'
|
||||
AND (to_jsonb(NEW) - 'source_type' - 'updated_at')
|
||||
= (to_jsonb(OLD) - 'source_type' - 'updated_at') THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- Metadata rättelse of a posted verifikation (BFL 5 kap 9 §): while the
|
||||
-- transaction-local GUC set by correct_entry_metadata() is active, permit an
|
||||
-- UPDATE iff ONLY description and/or entry_date change. The RPC has already
|
||||
-- recorded who/when in journal_entry_rattelse_log, verified the period is
|
||||
-- open/unlocked and (for date moves) that the new date stays inside the same
|
||||
-- fiscal period. Any other field delta still raises below.
|
||||
IF OLD.status = NEW.status
|
||||
AND OLD.status = 'posted'
|
||||
AND current_setting('gnubok.allow_metadata_rattelse', true) = 'true'
|
||||
AND (to_jsonb(NEW) - 'description' - 'entry_date' - 'updated_at')
|
||||
= (to_jsonb(OLD) - 'description' - 'entry_date' - 'updated_at') THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
RAISE EXCEPTION 'Cannot modify a % journal entry (id: %). Committed entries are immutable per Bokforingslagen.',
|
||||
OLD.status, OLD.id;
|
||||
END;
|
||||
$function$;
|
||||
|
||||
ALTER FUNCTION public.enforce_journal_entry_immutability() SET search_path = public;
|
||||
|
||||
-- =============================================================================
|
||||
-- 3. Line-immutability carve-out: inline line rättelse
|
||||
-- =============================================================================
|
||||
-- Current body verbatim (last replaced by 20260702170000) plus ONE branch:
|
||||
-- while the transaction-local GUC set by correct_entry_lines_inline() is
|
||||
-- active, permit DELETE of lines on a POSTED entry (the struck originals; the
|
||||
-- RPC snapshots them to journal_entry_rattelse_log first and re-verifies the
|
||||
-- entry balances afterwards). UPDATE of posted lines stays blocked: a strike
|
||||
-- is remove-and-replace, never edit-in-place. (Line INSERT has no immutability
|
||||
-- trigger; the balance invariant is enforced by the RPC.)
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.enforce_journal_entry_line_immutability()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $function$
|
||||
DECLARE v_status text;
|
||||
BEGIN
|
||||
IF current_setting('gnubok.allow_delete', true) = 'true' THEN
|
||||
IF TG_OP = 'DELETE' THEN RETURN OLD; END IF;
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
SELECT status INTO v_status FROM public.journal_entries
|
||||
WHERE id = COALESCE(OLD.journal_entry_id, NEW.journal_entry_id);
|
||||
|
||||
-- Dimension retag carve-out (dimensions plan PR6, founder-approved):
|
||||
-- while the transaction-local GUC set by retag_line_dimensions is active,
|
||||
-- permit UPDATE of a POSTED line iff ONLY the dimension columns change —
|
||||
-- dimensions (source of truth) and its derived mirrors cost_center/project.
|
||||
-- Account, amounts, description, currency fields, sort order and entry
|
||||
-- linkage remain absolutely immutable.
|
||||
IF TG_OP = 'UPDATE'
|
||||
AND v_status = 'posted'
|
||||
AND current_setting('gnubok.allow_dimension_retag', true) = 'true'
|
||||
AND (to_jsonb(NEW) - 'dimensions' - 'cost_center' - 'project')
|
||||
= (to_jsonb(OLD) - 'dimensions' - 'cost_center' - 'project') THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- Inline rättelse carve-out (BFL 5 kap 5 §, founder-approved 2026-07-23):
|
||||
-- while the transaction-local GUC set by correct_entry_lines_inline() is
|
||||
-- active, permit DELETE of a POSTED line (a struck line). The RPC has
|
||||
-- already snapshotted the row to journal_entry_rattelse_log and verifies
|
||||
-- post-state balance before committing.
|
||||
IF TG_OP = 'DELETE'
|
||||
AND v_status = 'posted'
|
||||
AND current_setting('gnubok.allow_line_rattelse', true) = 'true' THEN
|
||||
RETURN OLD;
|
||||
END IF;
|
||||
|
||||
IF v_status = 'draft' THEN
|
||||
IF TG_OP = 'DELETE' THEN RETURN OLD; END IF;
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
IF v_status = 'cancelled' THEN
|
||||
IF TG_OP = 'DELETE' THEN RETURN OLD; END IF;
|
||||
RAISE EXCEPTION 'Cannot % lines of a cancelled journal entry.', TG_OP;
|
||||
END IF;
|
||||
|
||||
RAISE EXCEPTION 'Cannot % lines of a % journal entry.', TG_OP, v_status;
|
||||
END; $function$;
|
||||
|
||||
ALTER FUNCTION public.enforce_journal_entry_line_immutability() SET search_path = public;
|
||||
|
||||
-- =============================================================================
|
||||
-- 4. correct_entry_metadata — the ONE write path for Card 1
|
||||
-- =============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.correct_entry_metadata(
|
||||
p_company_id uuid,
|
||||
p_entry_id uuid,
|
||||
p_description text DEFAULT NULL,
|
||||
p_entry_date date DEFAULT NULL,
|
||||
p_user_id uuid DEFAULT NULL
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_jwt_role text := coalesce(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'role', '');
|
||||
v_actor uuid := COALESCE(p_user_id, auth.uid());
|
||||
v_caller_role text;
|
||||
v_entry record;
|
||||
v_is_closed boolean;
|
||||
v_locked_at timestamptz;
|
||||
v_p_start date;
|
||||
v_p_end date;
|
||||
v_lock_date date;
|
||||
v_new_desc text;
|
||||
v_new_date date;
|
||||
v_log_id uuid;
|
||||
BEGIN
|
||||
IF v_jwt_role IN ('anon', 'authenticated') THEN
|
||||
IF NOT public.caller_is_company_member(p_company_id) THEN
|
||||
RAISE EXCEPTION 'unauthorized: caller is not a member of company %', p_company_id
|
||||
USING ERRCODE = '42501';
|
||||
END IF;
|
||||
-- A JWT caller can never act as someone else: p_user_id is only for
|
||||
-- service-role paths, which authenticate the user application-side.
|
||||
v_actor := auth.uid();
|
||||
END IF;
|
||||
|
||||
SELECT cm.role INTO v_caller_role
|
||||
FROM company_members cm
|
||||
WHERE cm.company_id = p_company_id AND cm.user_id = v_actor;
|
||||
|
||||
IF v_caller_role IS NULL OR v_caller_role NOT IN ('owner', 'admin', 'member') THEN
|
||||
RAISE EXCEPTION 'Endast användare med skrivbehörighet kan rätta verifikat.';
|
||||
END IF;
|
||||
|
||||
SELECT je.id, je.status, je.description, je.entry_date, je.source_type,
|
||||
je.fiscal_period_id, je.company_id AS entry_company_id
|
||||
INTO v_entry
|
||||
FROM public.journal_entries je
|
||||
WHERE je.id = p_entry_id
|
||||
FOR UPDATE OF je;
|
||||
|
||||
IF NOT FOUND OR v_entry.entry_company_id <> p_company_id THEN
|
||||
RAISE EXCEPTION 'Verifikationen hittades inte.';
|
||||
END IF;
|
||||
|
||||
IF v_entry.status <> 'posted' THEN
|
||||
RAISE EXCEPTION 'Endast bokförda verifikat kan rättas (utkast redigeras direkt).';
|
||||
END IF;
|
||||
|
||||
v_new_desc := COALESCE(NULLIF(btrim(p_description), ''), v_entry.description);
|
||||
v_new_date := COALESCE(p_entry_date, v_entry.entry_date);
|
||||
|
||||
IF length(v_new_desc) > 500 THEN
|
||||
RAISE EXCEPTION 'Beskrivningen får vara högst 500 tecken.';
|
||||
END IF;
|
||||
|
||||
-- A storno mirrors its original: its generated text and date are part of
|
||||
-- the correction chain and are never edited directly.
|
||||
IF v_entry.source_type = 'storno' THEN
|
||||
RAISE EXCEPTION 'Stornoverifikat kan inte rättas — rätta eller återför originalverifikatet i stället.';
|
||||
END IF;
|
||||
|
||||
-- Date moves are forbidden for entry types whose date carries structural
|
||||
-- meaning (IB and year-end are period-bound; a vat_settlement is looked up
|
||||
-- by entry_date when the "already booked" gate runs, so moving it enables
|
||||
-- double-booking a settlement).
|
||||
IF v_new_date <> v_entry.entry_date
|
||||
AND v_entry.source_type IN ('opening_balance', 'year_end', 'vat_settlement') THEN
|
||||
RAISE EXCEPTION 'Datumet på den här verifikationstypen kan inte ändras.';
|
||||
END IF;
|
||||
|
||||
SELECT fp.is_closed, fp.locked_at, fp.period_start, fp.period_end
|
||||
INTO v_is_closed, v_locked_at, v_p_start, v_p_end
|
||||
FROM public.fiscal_periods fp
|
||||
WHERE fp.id = v_entry.fiscal_period_id;
|
||||
|
||||
IF v_is_closed OR v_locked_at IS NOT NULL THEN
|
||||
RAISE EXCEPTION 'Perioden är stängd eller låst — använd rättelseverifikat (storno).';
|
||||
END IF;
|
||||
|
||||
-- The date may only move WITHIN the entry's fiscal period; cross-period
|
||||
-- moves change period sums and must go through the recordate (storno) flow.
|
||||
IF v_new_date <> v_entry.entry_date
|
||||
AND (v_new_date < v_p_start OR v_new_date > v_p_end) THEN
|
||||
RAISE EXCEPTION 'Nytt datum måste ligga inom samma bokföringsperiod (% – %). Använd "Flytta till annat datum" för att byta period.', v_p_start, v_p_end;
|
||||
END IF;
|
||||
|
||||
SELECT cs.bookkeeping_locked_through INTO v_lock_date
|
||||
FROM public.company_settings cs
|
||||
WHERE cs.company_id = p_company_id;
|
||||
|
||||
IF v_lock_date IS NOT NULL
|
||||
AND (v_entry.entry_date <= v_lock_date OR v_new_date <= v_lock_date) THEN
|
||||
RAISE EXCEPTION 'Bokföringen är låst t.o.m. % — använd rättelseverifikat (storno).', v_lock_date;
|
||||
END IF;
|
||||
|
||||
IF v_new_desc = v_entry.description AND v_new_date = v_entry.entry_date THEN
|
||||
RETURN jsonb_build_object('changed', false, 'log_id', NULL);
|
||||
END IF;
|
||||
|
||||
-- Immutable who/when log FIRST — the carve-out is only ever exercised in a
|
||||
-- transaction that has already recorded the rättelse (BFL 5 kap 9 §).
|
||||
INSERT INTO public.journal_entry_rattelse_log
|
||||
(company_id, journal_entry_id, rattelse_type,
|
||||
old_description, new_description, old_entry_date, new_entry_date, actor)
|
||||
VALUES
|
||||
(p_company_id, p_entry_id, 'metadata',
|
||||
v_entry.description, v_new_desc, v_entry.entry_date, v_new_date, v_actor)
|
||||
RETURNING id INTO v_log_id;
|
||||
|
||||
PERFORM set_config('gnubok.allow_metadata_rattelse', 'true', true);
|
||||
|
||||
UPDATE public.journal_entries
|
||||
SET description = v_new_desc,
|
||||
entry_date = v_new_date
|
||||
WHERE id = p_entry_id;
|
||||
|
||||
PERFORM set_config('gnubok.allow_metadata_rattelse', 'false', true);
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'changed', true,
|
||||
'log_id', v_log_id,
|
||||
'old_description', v_entry.description,
|
||||
'new_description', v_new_desc,
|
||||
'old_entry_date', v_entry.entry_date,
|
||||
'new_entry_date', v_new_date
|
||||
);
|
||||
END;
|
||||
$function$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.correct_entry_metadata(uuid, uuid, text, date, uuid) FROM PUBLIC, anon;
|
||||
GRANT EXECUTE ON FUNCTION public.correct_entry_metadata(uuid, uuid, text, date, uuid) TO authenticated, service_role;
|
||||
|
||||
-- =============================================================================
|
||||
-- 5. correct_entry_lines_inline — the ONE write path for Card 2
|
||||
-- =============================================================================
|
||||
-- Strikes lines (delete + snapshot) and/or adds replacement lines inside the
|
||||
-- same posted verifikat. The effective line set must balance to the öre and
|
||||
-- stay non-empty; the original rows survive in the log and in audit_log.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.correct_entry_lines_inline(
|
||||
p_company_id uuid,
|
||||
p_entry_id uuid,
|
||||
p_strike_line_ids uuid[],
|
||||
p_new_lines jsonb DEFAULT '[]'::jsonb,
|
||||
p_user_id uuid DEFAULT NULL
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_jwt_role text := coalesce(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'role', '');
|
||||
v_actor uuid := COALESCE(p_user_id, auth.uid());
|
||||
v_caller_role text;
|
||||
v_entry record;
|
||||
v_is_closed boolean;
|
||||
v_locked_at timestamptz;
|
||||
v_lock_date date;
|
||||
v_strike_ids uuid[] := ARRAY(SELECT DISTINCT unnest(COALESCE(p_strike_line_ids, '{}'::uuid[])));
|
||||
v_strike_count int := COALESCE(array_length(v_strike_ids, 1), 0);
|
||||
v_owned_count int;
|
||||
v_line jsonb;
|
||||
v_acc text;
|
||||
v_debit numeric;
|
||||
v_credit numeric;
|
||||
v_new_count int := 0;
|
||||
v_new_debit numeric := 0;
|
||||
v_new_credit numeric := 0;
|
||||
v_rem_debit numeric;
|
||||
v_rem_credit numeric;
|
||||
v_rem_count int;
|
||||
v_struck_json jsonb;
|
||||
v_struck_keys text[];
|
||||
v_added_keys text[];
|
||||
v_sort int;
|
||||
v_added_ids uuid[] := '{}';
|
||||
v_added_json jsonb;
|
||||
v_new_id uuid;
|
||||
v_log_id uuid;
|
||||
v_fin_debit numeric;
|
||||
v_fin_credit numeric;
|
||||
v_fin_count int;
|
||||
v_bank_linked boolean;
|
||||
v_invoice_linked boolean;
|
||||
v_supplier_linked boolean;
|
||||
v_delta numeric;
|
||||
BEGIN
|
||||
IF v_jwt_role IN ('anon', 'authenticated') THEN
|
||||
IF NOT public.caller_is_company_member(p_company_id) THEN
|
||||
RAISE EXCEPTION 'unauthorized: caller is not a member of company %', p_company_id
|
||||
USING ERRCODE = '42501';
|
||||
END IF;
|
||||
-- A JWT caller can never act as someone else: p_user_id is only for
|
||||
-- service-role paths, which authenticate the user application-side.
|
||||
v_actor := auth.uid();
|
||||
END IF;
|
||||
|
||||
SELECT cm.role INTO v_caller_role
|
||||
FROM company_members cm
|
||||
WHERE cm.company_id = p_company_id AND cm.user_id = v_actor;
|
||||
|
||||
IF v_caller_role IS NULL OR v_caller_role NOT IN ('owner', 'admin', 'member') THEN
|
||||
RAISE EXCEPTION 'Endast användare med skrivbehörighet kan rätta verifikat.';
|
||||
END IF;
|
||||
|
||||
IF p_new_lines IS NULL OR jsonb_typeof(p_new_lines) <> 'array' THEN
|
||||
RAISE EXCEPTION 'Nya rader måste vara en lista.';
|
||||
END IF;
|
||||
|
||||
IF v_strike_count = 0 AND jsonb_array_length(p_new_lines) = 0 THEN
|
||||
RAISE EXCEPTION 'Rättelsen måste stryka eller lägga till minst en rad.';
|
||||
END IF;
|
||||
|
||||
IF jsonb_array_length(p_new_lines) > 100 THEN
|
||||
RAISE EXCEPTION 'Högst 100 nya rader per rättelse.';
|
||||
END IF;
|
||||
|
||||
SELECT je.id, je.status, je.entry_date, je.source_type,
|
||||
je.fiscal_period_id, je.company_id AS entry_company_id
|
||||
INTO v_entry
|
||||
FROM public.journal_entries je
|
||||
WHERE je.id = p_entry_id
|
||||
FOR UPDATE OF je;
|
||||
|
||||
IF NOT FOUND OR v_entry.entry_company_id <> p_company_id THEN
|
||||
RAISE EXCEPTION 'Verifikationen hittades inte.';
|
||||
END IF;
|
||||
|
||||
IF v_entry.status <> 'posted' THEN
|
||||
RAISE EXCEPTION 'Endast bokförda verifikat kan rättas (utkast redigeras direkt).';
|
||||
END IF;
|
||||
|
||||
-- Structural entry types keep their dedicated flows: a storno mirrors its
|
||||
-- original, an IB feeds opening_balance_entry_id, year-end vouchers feed
|
||||
-- dispositions/idempotency checks.
|
||||
IF v_entry.source_type IN ('storno', 'opening_balance', 'year_end', 'vat_settlement') THEN
|
||||
RAISE EXCEPTION 'Den här verifikationstypen kan inte rättas radvis — använd dess egen rättelsefunktion.';
|
||||
END IF;
|
||||
|
||||
SELECT fp.is_closed, fp.locked_at INTO v_is_closed, v_locked_at
|
||||
FROM public.fiscal_periods fp
|
||||
WHERE fp.id = v_entry.fiscal_period_id;
|
||||
|
||||
IF v_is_closed OR v_locked_at IS NOT NULL THEN
|
||||
RAISE EXCEPTION 'Perioden är stängd eller låst — använd rättelseverifikat (storno).';
|
||||
END IF;
|
||||
|
||||
SELECT cs.bookkeeping_locked_through INTO v_lock_date
|
||||
FROM public.company_settings cs
|
||||
WHERE cs.company_id = p_company_id;
|
||||
|
||||
IF v_lock_date IS NOT NULL AND v_entry.entry_date <= v_lock_date THEN
|
||||
RAISE EXCEPTION 'Bokföringen är låst t.o.m. % — använd rättelseverifikat (storno).', v_lock_date;
|
||||
END IF;
|
||||
|
||||
-- Every struck id must be a line of THIS entry.
|
||||
SELECT count(*) INTO v_owned_count
|
||||
FROM public.journal_entry_lines jel
|
||||
WHERE jel.journal_entry_id = p_entry_id
|
||||
AND jel.id = ANY (v_strike_ids);
|
||||
|
||||
IF v_owned_count <> v_strike_count THEN
|
||||
RAISE EXCEPTION 'En eller flera rader som ska strykas hör inte till verifikationen.';
|
||||
END IF;
|
||||
|
||||
-- Foreign-currency lines carry conversion data (amount_in_currency /
|
||||
-- exchange_rate) that replacement lines cannot reproduce: those
|
||||
-- corrections stay on the storno flow.
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM public.journal_entry_lines jel
|
||||
WHERE jel.journal_entry_id = p_entry_id
|
||||
AND jel.id = ANY (v_strike_ids)
|
||||
AND jel.currency IS NOT NULL AND jel.currency <> 'SEK'
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Rader i utländsk valuta kan inte strykas — använd rättelseverifikat (storno).';
|
||||
END IF;
|
||||
|
||||
-- A struck line with a line-level underlag link would sever the document
|
||||
-- coupling (document_attachments.journal_entry_line_id is ON DELETE
|
||||
-- RESTRICT, so the DELETE would fail anyway — this gives a clear message).
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM public.document_attachments da
|
||||
WHERE da.journal_entry_line_id = ANY (v_strike_ids)
|
||||
) THEN
|
||||
RAISE EXCEPTION 'En rad som ska strykas har ett kopplat underlag — använd rättelseverifikat (storno).';
|
||||
END IF;
|
||||
|
||||
-- Validate the replacement lines. SEK only: inline additions never carry
|
||||
-- foreign-currency conversion data (that correction stays on the storno flow).
|
||||
FOR v_line IN SELECT * FROM jsonb_array_elements(p_new_lines)
|
||||
LOOP
|
||||
v_acc := btrim(COALESCE(v_line ->> 'account_number', ''));
|
||||
v_debit := round(COALESCE((v_line ->> 'debit_amount')::numeric, 0), 2);
|
||||
v_credit := round(COALESCE((v_line ->> 'credit_amount')::numeric, 0), 2);
|
||||
|
||||
IF v_acc !~ '^[0-9]{4}$' THEN
|
||||
RAISE EXCEPTION 'Ogiltigt kontonummer: "%".', v_acc;
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM public.chart_of_accounts coa
|
||||
WHERE coa.company_id = p_company_id AND coa.account_number = v_acc
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Kontot % finns inte i kontoplanen.', v_acc;
|
||||
END IF;
|
||||
IF v_debit < 0 OR v_credit < 0 THEN
|
||||
RAISE EXCEPTION 'Belopp kan inte vara negativa (konto %).', v_acc;
|
||||
END IF;
|
||||
IF v_debit > 0 AND v_credit > 0 THEN
|
||||
RAISE EXCEPTION 'En rad kan inte ha både debet och kredit (konto %).', v_acc;
|
||||
END IF;
|
||||
IF v_debit = 0 AND v_credit = 0 THEN
|
||||
RAISE EXCEPTION 'En rad måste ha ett belopp (konto %).', v_acc;
|
||||
END IF;
|
||||
|
||||
v_new_count := v_new_count + 1;
|
||||
v_new_debit := v_new_debit + v_debit;
|
||||
v_new_credit := v_new_credit + v_credit;
|
||||
END LOOP;
|
||||
|
||||
-- Effective post-state must balance and stay a real bokföringspost.
|
||||
SELECT COALESCE(sum(jel.debit_amount), 0), COALESCE(sum(jel.credit_amount), 0), count(*)
|
||||
INTO v_rem_debit, v_rem_credit, v_rem_count
|
||||
FROM public.journal_entry_lines jel
|
||||
WHERE jel.journal_entry_id = p_entry_id
|
||||
AND NOT (jel.id = ANY (v_strike_ids));
|
||||
|
||||
IF (v_rem_count + v_new_count) < 2 THEN
|
||||
RAISE EXCEPTION 'Verifikationen måste ha minst två rader efter rättelsen. Använd "Återför (storno)" för att makulera hela verifikationen.';
|
||||
END IF;
|
||||
|
||||
IF abs((v_rem_debit + v_new_debit) - (v_rem_credit + v_new_credit)) >= 0.005 THEN
|
||||
RAISE EXCEPTION 'Verifikationen balanserar inte efter rättelsen (debet %, kredit %).',
|
||||
round(v_rem_debit + v_new_debit, 2), round(v_rem_credit + v_new_credit, 2);
|
||||
END IF;
|
||||
|
||||
IF (v_rem_debit + v_new_debit) < 0.005 THEN
|
||||
RAISE EXCEPTION 'Rättelsen skulle nollställa verifikationen. Använd "Återför (storno)" i stället.';
|
||||
END IF;
|
||||
|
||||
-- A rättelse must change something: striking rows and re-adding an
|
||||
-- identical set is a no-op in disguise.
|
||||
SELECT COALESCE(array_agg(k ORDER BY k), '{}'), COALESCE(jsonb_agg(to_jsonb(jel) ORDER BY jel.sort_order), '[]'::jsonb)
|
||||
INTO v_struck_keys, v_struck_json
|
||||
FROM public.journal_entry_lines jel,
|
||||
LATERAL (SELECT jel.account_number || '|' || round(jel.debit_amount, 2)::text || '|'
|
||||
|| round(jel.credit_amount, 2)::text || '|' || COALESCE(jel.line_description, '')) AS key(k)
|
||||
WHERE jel.journal_entry_id = p_entry_id
|
||||
AND jel.id = ANY (v_strike_ids);
|
||||
|
||||
SELECT COALESCE(array_agg(k ORDER BY k), '{}')
|
||||
INTO v_added_keys
|
||||
FROM (
|
||||
SELECT btrim(l ->> 'account_number') || '|'
|
||||
|| round(COALESCE((l ->> 'debit_amount')::numeric, 0), 2)::text || '|'
|
||||
|| round(COALESCE((l ->> 'credit_amount')::numeric, 0), 2)::text || '|'
|
||||
|| COALESCE(NULLIF(btrim(COALESCE(l ->> 'line_description', '')), ''), '') AS k
|
||||
FROM jsonb_array_elements(p_new_lines) AS l
|
||||
) keys;
|
||||
|
||||
IF v_struck_keys = v_added_keys THEN
|
||||
RAISE EXCEPTION 'Rättelsen ändrar ingenting.';
|
||||
END IF;
|
||||
|
||||
-- Reconciliation guard: when the entry is anchored to external records
|
||||
-- (bank transactions, payment links), the anchored side must keep its
|
||||
-- per-account net. The bank feed / payment amount is immutable, so letting
|
||||
-- a strike change the 19xx/cash-account (or reskontra) net would create a
|
||||
-- permanent unexplained reconciliation difference. Net-preserving strikes
|
||||
-- (e.g. fixing a line description) stay allowed.
|
||||
v_bank_linked := EXISTS (SELECT 1 FROM public.transactions t WHERE t.journal_entry_id = p_entry_id)
|
||||
OR EXISTS (SELECT 1 FROM public.transaction_voucher_links tvl WHERE tvl.journal_entry_id = p_entry_id);
|
||||
v_invoice_linked := EXISTS (SELECT 1 FROM public.invoice_payments ip WHERE ip.journal_entry_id = p_entry_id);
|
||||
v_supplier_linked := EXISTS (SELECT 1 FROM public.supplier_invoice_payments sp WHERE sp.journal_entry_id = p_entry_id);
|
||||
|
||||
IF v_bank_linked OR v_invoice_linked OR v_supplier_linked THEN
|
||||
FOR v_acc, v_delta IN
|
||||
SELECT x.acc, sum(x.delta)
|
||||
FROM (
|
||||
SELECT jel.account_number AS acc,
|
||||
-(jel.debit_amount - jel.credit_amount) AS delta
|
||||
FROM public.journal_entry_lines jel
|
||||
WHERE jel.journal_entry_id = p_entry_id
|
||||
AND jel.id = ANY (v_strike_ids)
|
||||
UNION ALL
|
||||
SELECT btrim(l ->> 'account_number'),
|
||||
round(COALESCE((l ->> 'debit_amount')::numeric, 0), 2)
|
||||
- round(COALESCE((l ->> 'credit_amount')::numeric, 0), 2)
|
||||
FROM jsonb_array_elements(p_new_lines) AS l
|
||||
) x
|
||||
GROUP BY x.acc
|
||||
LOOP
|
||||
IF abs(v_delta) >= 0.005 AND (
|
||||
(v_bank_linked AND (v_acc LIKE '19%' OR v_acc IN (
|
||||
SELECT ca.ledger_account FROM public.cash_accounts ca WHERE ca.company_id = p_company_id)))
|
||||
OR (v_invoice_linked AND v_acc LIKE '15%')
|
||||
OR (v_supplier_linked AND v_acc LIKE '24%')
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Raden mot konto % kan inte ändras: verifikationen är kopplad till en banktransaktion eller betalning. Använd rättelseverifikat (storno).', v_acc;
|
||||
END IF;
|
||||
END LOOP;
|
||||
END IF;
|
||||
|
||||
PERFORM set_config('gnubok.allow_line_rattelse', 'true', true);
|
||||
|
||||
DELETE FROM public.journal_entry_lines
|
||||
WHERE journal_entry_id = p_entry_id
|
||||
AND id = ANY (v_strike_ids);
|
||||
|
||||
SELECT COALESCE(max(jel.sort_order), 0) INTO v_sort
|
||||
FROM public.journal_entry_lines jel
|
||||
WHERE jel.journal_entry_id = p_entry_id;
|
||||
|
||||
FOR v_line IN SELECT * FROM jsonb_array_elements(p_new_lines)
|
||||
LOOP
|
||||
v_sort := v_sort + 1;
|
||||
-- cost_center/project are GENERATED columns derived from dimensions:
|
||||
-- never inserted explicitly, they recompute from the bag.
|
||||
INSERT INTO public.journal_entry_lines
|
||||
(journal_entry_id, account_number, account_id, debit_amount, credit_amount,
|
||||
line_description, sort_order, dimensions, currency)
|
||||
VALUES
|
||||
(p_entry_id,
|
||||
btrim(v_line ->> 'account_number'),
|
||||
(SELECT coa.id FROM public.chart_of_accounts coa
|
||||
WHERE coa.company_id = p_company_id
|
||||
AND coa.account_number = btrim(v_line ->> 'account_number')
|
||||
ORDER BY (coa.is_active IS TRUE) DESC, coa.created_at
|
||||
LIMIT 1),
|
||||
round(COALESCE((v_line ->> 'debit_amount')::numeric, 0), 2),
|
||||
round(COALESCE((v_line ->> 'credit_amount')::numeric, 0), 2),
|
||||
NULLIF(btrim(COALESCE(v_line ->> 'line_description', '')), ''),
|
||||
v_sort,
|
||||
COALESCE(v_line -> 'dimensions', '{}'::jsonb),
|
||||
'SEK')
|
||||
RETURNING id INTO v_new_id;
|
||||
v_added_ids := v_added_ids || v_new_id;
|
||||
END LOOP;
|
||||
|
||||
PERFORM set_config('gnubok.allow_line_rattelse', 'false', true);
|
||||
|
||||
-- Authoritative post-state verification straight from the table: the entry
|
||||
-- must still balance to the öre and hold at least two lines, or everything
|
||||
-- rolls back.
|
||||
SELECT COALESCE(sum(jel.debit_amount), 0), COALESCE(sum(jel.credit_amount), 0), count(*)
|
||||
INTO v_fin_debit, v_fin_credit, v_fin_count
|
||||
FROM public.journal_entry_lines jel
|
||||
WHERE jel.journal_entry_id = p_entry_id;
|
||||
|
||||
IF abs(v_fin_debit - v_fin_credit) >= 0.005 OR v_fin_count < 2 OR v_fin_debit < 0.005 THEN
|
||||
RAISE EXCEPTION 'Internt fel: verifikationen balanserar inte efter rättelsen — ändringen har återställts.';
|
||||
END IF;
|
||||
|
||||
-- Close the check-then-write window on period locks: if a lock or close
|
||||
-- committed while this rättelse was running, abort and roll back rather
|
||||
-- than write into a period that is now locked.
|
||||
SELECT fp.is_closed, fp.locked_at INTO v_is_closed, v_locked_at
|
||||
FROM public.fiscal_periods fp
|
||||
WHERE fp.id = v_entry.fiscal_period_id;
|
||||
IF v_is_closed OR v_locked_at IS NOT NULL THEN
|
||||
RAISE EXCEPTION 'Perioden är stängd eller låst — använd rättelseverifikat (storno).';
|
||||
END IF;
|
||||
|
||||
SELECT COALESCE(jsonb_agg(to_jsonb(jel) ORDER BY jel.sort_order), '[]'::jsonb)
|
||||
INTO v_added_json
|
||||
FROM public.journal_entry_lines jel
|
||||
WHERE jel.id = ANY (v_added_ids);
|
||||
|
||||
INSERT INTO public.journal_entry_rattelse_log
|
||||
(company_id, journal_entry_id, rattelse_type, struck_lines, added_lines, actor)
|
||||
VALUES
|
||||
(p_company_id, p_entry_id, 'lines', v_struck_json, v_added_json, v_actor)
|
||||
RETURNING id INTO v_log_id;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'log_id', v_log_id,
|
||||
'struck_count', v_strike_count,
|
||||
'added_count', v_new_count,
|
||||
'total_debit', round(v_fin_debit, 2),
|
||||
'total_credit', round(v_fin_credit, 2)
|
||||
);
|
||||
END;
|
||||
$function$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.correct_entry_lines_inline(uuid, uuid, uuid[], jsonb, uuid) FROM PUBLIC, anon;
|
||||
GRANT EXECUTE ON FUNCTION public.correct_entry_lines_inline(uuid, uuid, uuid[], jsonb, uuid) TO authenticated, service_role;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
||||
-- Per-user preference to hide the floating assistant button (FAB) shown
|
||||
-- bottom-right on every dashboard page. UI-only: the sidebar assistant entry
|
||||
-- is unaffected. Default false keeps current behavior for everyone.
|
||||
alter table public.user_preferences
|
||||
add column if not exists hide_assistant_fab boolean not null default false;
|
||||
@@ -0,0 +1,590 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getPool, withUserContext } from '@/tests/pg/setup'
|
||||
import {
|
||||
seedCompany,
|
||||
insertAuthUser,
|
||||
insertCompanyMember,
|
||||
insertDraftJournalEntry,
|
||||
} from '@/tests/pg/fixtures'
|
||||
|
||||
// Migration 20260723210000_verifikat_inline_rattelse.sql: the founder-approved
|
||||
// inline rättelse of posted verifikat (BFL 5 kap 5 § / 9 §).
|
||||
//
|
||||
// The mandatory suite:
|
||||
// 1. GUC-less UPDATE of description/entry_date on a posted entry stays blocked
|
||||
// 2. GUC-less DELETE of a posted line stays blocked
|
||||
// 3. under the metadata GUC, any non-description/date column change still raises
|
||||
// 4. both RPCs are blocked in closed/locked periods and behind the lock date
|
||||
// 5. the effective line set must balance to the öre and keep >= 2 lines
|
||||
// 6. every rättelse writes an immutable journal_entry_rattelse_log row
|
||||
// 7. the log itself is WORM
|
||||
// 8. role gates (viewer/stranger), cross-tenant reach, structural source types
|
||||
|
||||
async function insertPostedEntry(params: {
|
||||
companyId: string
|
||||
userId: string
|
||||
fiscalPeriodId: string
|
||||
entryDate?: string
|
||||
voucherNumber?: number
|
||||
sourceType?: string
|
||||
description?: string
|
||||
}): Promise<{ entryId: string; debitLineId: string; creditLineId: string }> {
|
||||
const entryId = await insertDraftJournalEntry({
|
||||
userId: params.userId,
|
||||
companyId: params.companyId,
|
||||
fiscalPeriodId: params.fiscalPeriodId,
|
||||
sourceType: params.sourceType ?? 'manual',
|
||||
status: 'draft',
|
||||
voucherNumber: params.voucherNumber ?? 1,
|
||||
entryDate: params.entryDate,
|
||||
})
|
||||
if (params.description) {
|
||||
await getPool().query(`UPDATE public.journal_entries SET description = $2 WHERE id = $1`, [
|
||||
entryId,
|
||||
params.description,
|
||||
])
|
||||
}
|
||||
const { rows: debitRows } = await getPool().query<{ id: string }>(
|
||||
`INSERT INTO public.journal_entry_lines
|
||||
(journal_entry_id, account_number, debit_amount, credit_amount, sort_order)
|
||||
VALUES ($1, '5010', 1000, 0, 1)
|
||||
RETURNING id`,
|
||||
[entryId],
|
||||
)
|
||||
const { rows: creditRows } = await getPool().query<{ id: string }>(
|
||||
`INSERT INTO public.journal_entry_lines
|
||||
(journal_entry_id, account_number, debit_amount, credit_amount, sort_order)
|
||||
VALUES ($1, '1930', 0, 1000, 2)
|
||||
RETURNING id`,
|
||||
[entryId],
|
||||
)
|
||||
await getPool().query(`UPDATE public.journal_entries SET status = 'posted' WHERE id = $1`, [entryId])
|
||||
return { entryId, debitLineId: debitRows[0].id, creditLineId: creditRows[0].id }
|
||||
}
|
||||
|
||||
async function insertChartAccount(companyId: string, userId: string, accountNumber: string): Promise<void> {
|
||||
await getPool().query(
|
||||
`INSERT INTO public.chart_of_accounts
|
||||
(user_id, company_id, account_number, account_name, account_class, account_type, normal_balance)
|
||||
VALUES ($1, $2, $3, 'Testkonto ' || $3, left($3, 1)::int, 'expense', 'debit')
|
||||
ON CONFLICT DO NOTHING`,
|
||||
[userId, companyId, accountNumber],
|
||||
)
|
||||
}
|
||||
|
||||
async function callMetadata(
|
||||
companyId: string,
|
||||
entryId: string,
|
||||
description: string | null,
|
||||
entryDate: string | null,
|
||||
actor: string,
|
||||
) {
|
||||
return getPool().query<{ result: { changed: boolean; log_id: string | null } }>(
|
||||
`SELECT public.correct_entry_metadata($1::uuid, $2::uuid, $3, $4::date, $5::uuid) AS result`,
|
||||
[companyId, entryId, description, entryDate, actor],
|
||||
)
|
||||
}
|
||||
|
||||
async function callStrike(
|
||||
companyId: string,
|
||||
entryId: string,
|
||||
strikeIds: string[],
|
||||
newLines: unknown[],
|
||||
actor: string,
|
||||
) {
|
||||
return getPool().query<{ result: { struck_count: number; added_count: number; log_id: string } }>(
|
||||
`SELECT public.correct_entry_lines_inline($1::uuid, $2::uuid, $3::uuid[], $4::jsonb, $5::uuid) AS result`,
|
||||
[companyId, entryId, strikeIds, JSON.stringify(newLines), actor],
|
||||
)
|
||||
}
|
||||
|
||||
async function periodBounds(fiscalPeriodId: string): Promise<{ start: string; end: string }> {
|
||||
const { rows } = await getPool().query<{ period_start: string; period_end: string }>(
|
||||
`SELECT period_start::text, period_end::text FROM public.fiscal_periods WHERE id = $1`,
|
||||
[fiscalPeriodId],
|
||||
)
|
||||
return { start: rows[0].period_start, end: rows[0].period_end }
|
||||
}
|
||||
|
||||
describe('inline rättelse: metadata (correct_entry_metadata)', () => {
|
||||
it('still blocks a GUC-less description/date UPDATE on a posted entry', async () => {
|
||||
const { companyId, userId, fiscalPeriodId } = await seedCompany()
|
||||
const { entryId } = await insertPostedEntry({ companyId, userId, fiscalPeriodId })
|
||||
|
||||
await expect(
|
||||
getPool().query(`UPDATE public.journal_entries SET description = 'hacked' WHERE id = $1`, [entryId]),
|
||||
).rejects.toThrow(/immutable/)
|
||||
})
|
||||
|
||||
it('corrects description + same-period date, and logs old/new with the actor', async () => {
|
||||
const { companyId, userId, fiscalPeriodId } = await seedCompany()
|
||||
const bounds = await periodBounds(fiscalPeriodId)
|
||||
const { entryId } = await insertPostedEntry({
|
||||
companyId, userId, fiscalPeriodId,
|
||||
entryDate: bounds.start, description: 'Felstavat teext',
|
||||
})
|
||||
|
||||
const res = await callMetadata(companyId, entryId, 'Rättad text', bounds.end, userId)
|
||||
expect(res.rows[0].result.changed).toBe(true)
|
||||
expect(res.rows[0].result.log_id).toBeTruthy()
|
||||
|
||||
const { rows: entry } = await getPool().query(
|
||||
`SELECT description, entry_date::text, status FROM public.journal_entries WHERE id = $1`,
|
||||
[entryId],
|
||||
)
|
||||
expect(entry[0].description).toBe('Rättad text')
|
||||
expect(entry[0].entry_date).toBe(bounds.end)
|
||||
expect(entry[0].status).toBe('posted')
|
||||
|
||||
const { rows: log } = await getPool().query(
|
||||
`SELECT rattelse_type, old_description, new_description, old_entry_date::text, new_entry_date::text, actor
|
||||
FROM public.journal_entry_rattelse_log WHERE journal_entry_id = $1`,
|
||||
[entryId],
|
||||
)
|
||||
expect(log).toHaveLength(1)
|
||||
expect(log[0].rattelse_type).toBe('metadata')
|
||||
expect(log[0].old_description).toBe('Felstavat teext')
|
||||
expect(log[0].new_description).toBe('Rättad text')
|
||||
expect(log[0].old_entry_date).toBe(bounds.start)
|
||||
expect(log[0].new_entry_date).toBe(bounds.end)
|
||||
expect(log[0].actor).toBe(userId)
|
||||
})
|
||||
|
||||
it('is an idempotent no-op (no log row) when nothing changes', async () => {
|
||||
const { companyId, userId, fiscalPeriodId } = await seedCompany()
|
||||
const { entryId } = await insertPostedEntry({
|
||||
companyId, userId, fiscalPeriodId, description: 'Samma text',
|
||||
})
|
||||
|
||||
const res = await callMetadata(companyId, entryId, 'Samma text', null, userId)
|
||||
expect(res.rows[0].result.changed).toBe(false)
|
||||
|
||||
const { rows } = await getPool().query(
|
||||
`SELECT 1 FROM public.journal_entry_rattelse_log WHERE journal_entry_id = $1`,
|
||||
[entryId],
|
||||
)
|
||||
expect(rows).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('rejects a date outside the fiscal period', async () => {
|
||||
const { companyId, userId, fiscalPeriodId } = await seedCompany()
|
||||
const bounds = await periodBounds(fiscalPeriodId)
|
||||
const { entryId } = await insertPostedEntry({
|
||||
companyId, userId, fiscalPeriodId, entryDate: bounds.start,
|
||||
})
|
||||
const outside = new Date(new Date(bounds.end).getTime() + 24 * 3600 * 1000)
|
||||
.toISOString()
|
||||
.slice(0, 10)
|
||||
|
||||
await expect(callMetadata(companyId, entryId, null, outside, userId)).rejects.toThrow(
|
||||
/inom samma bokföringsperiod/,
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects all metadata edits on storno entries', async () => {
|
||||
const { companyId, userId, fiscalPeriodId } = await seedCompany()
|
||||
const { entryId } = await insertPostedEntry({
|
||||
companyId, userId, fiscalPeriodId, sourceType: 'storno', voucherNumber: 8,
|
||||
})
|
||||
|
||||
await expect(callMetadata(companyId, entryId, 'Omdöpt storno', null, userId)).rejects.toThrow(
|
||||
/Stornoverifikat kan inte rättas/,
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects date changes on opening_balance/year_end/vat_settlement entries', async () => {
|
||||
const { companyId, userId, fiscalPeriodId } = await seedCompany()
|
||||
const bounds = await periodBounds(fiscalPeriodId)
|
||||
const { entryId } = await insertPostedEntry({
|
||||
companyId, userId, fiscalPeriodId,
|
||||
entryDate: bounds.start, sourceType: 'year_end', voucherNumber: 7,
|
||||
})
|
||||
|
||||
await expect(callMetadata(companyId, entryId, null, bounds.end, userId)).rejects.toThrow(
|
||||
/kan inte ändras/,
|
||||
)
|
||||
// ...but the description alone is still correctable.
|
||||
const res = await callMetadata(companyId, entryId, 'Bokslut, rättad text', null, userId)
|
||||
expect(res.rows[0].result.changed).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects metadata rättelse in closed and locked periods, and behind the lock date', async () => {
|
||||
const closed = await seedCompany()
|
||||
const closedEntry = await insertPostedEntry({
|
||||
companyId: closed.companyId, userId: closed.userId, fiscalPeriodId: closed.fiscalPeriodId,
|
||||
})
|
||||
await getPool().query(
|
||||
`UPDATE public.fiscal_periods SET is_closed = true, closed_at = now() WHERE id = $1`,
|
||||
[closed.fiscalPeriodId],
|
||||
)
|
||||
await expect(
|
||||
callMetadata(closed.companyId, closedEntry.entryId, 'Ny text', null, closed.userId),
|
||||
).rejects.toThrow(/stängd eller låst/)
|
||||
|
||||
const locked = await seedCompany()
|
||||
const lockedEntry = await insertPostedEntry({
|
||||
companyId: locked.companyId, userId: locked.userId, fiscalPeriodId: locked.fiscalPeriodId,
|
||||
})
|
||||
await getPool().query(`UPDATE public.fiscal_periods SET locked_at = now() WHERE id = $1`, [
|
||||
locked.fiscalPeriodId,
|
||||
])
|
||||
await expect(
|
||||
callMetadata(locked.companyId, lockedEntry.entryId, 'Ny text', null, locked.userId),
|
||||
).rejects.toThrow(/stängd eller låst/)
|
||||
|
||||
const lockDated = await seedCompany()
|
||||
const lockDatedBounds = await periodBounds(lockDated.fiscalPeriodId)
|
||||
const lockDatedEntry = await insertPostedEntry({
|
||||
companyId: lockDated.companyId, userId: lockDated.userId,
|
||||
fiscalPeriodId: lockDated.fiscalPeriodId, entryDate: lockDatedBounds.start,
|
||||
})
|
||||
await getPool().query(
|
||||
`INSERT INTO public.company_settings (user_id, company_id, bookkeeping_locked_through)
|
||||
VALUES ($1, $2, $3::date)
|
||||
ON CONFLICT (company_id) DO UPDATE SET bookkeeping_locked_through = $3::date`,
|
||||
[lockDated.userId, lockDated.companyId, lockDatedBounds.end],
|
||||
)
|
||||
await expect(
|
||||
callMetadata(lockDated.companyId, lockDatedEntry.entryId, 'Ny text', null, lockDated.userId),
|
||||
).rejects.toThrow(/låst t\.o\.m/)
|
||||
})
|
||||
|
||||
it('rejects viewers, strangers and drafts', async () => {
|
||||
const { companyId, userId, fiscalPeriodId } = await seedCompany()
|
||||
const { entryId } = await insertPostedEntry({ companyId, userId, fiscalPeriodId })
|
||||
|
||||
const viewerId = await insertAuthUser()
|
||||
await insertCompanyMember({ companyId, userId: viewerId, role: 'viewer' })
|
||||
await expect(callMetadata(companyId, entryId, 'Som viewer', null, viewerId)).rejects.toThrow(
|
||||
/skrivbehörighet/,
|
||||
)
|
||||
await expect(callMetadata(companyId, entryId, 'Som främling', null, randomUUID())).rejects.toThrow(
|
||||
/skrivbehörighet/,
|
||||
)
|
||||
|
||||
const draftId = await insertDraftJournalEntry({
|
||||
userId, companyId, fiscalPeriodId, status: 'draft', voucherNumber: 99,
|
||||
})
|
||||
await expect(callMetadata(companyId, draftId, 'Utkast', null, userId)).rejects.toThrow(
|
||||
/bokförda verifikat/,
|
||||
)
|
||||
})
|
||||
|
||||
it('ignores a spoofed p_user_id for JWT callers (viewer cannot act as the owner)', async () => {
|
||||
const { companyId, userId, fiscalPeriodId } = await seedCompany()
|
||||
const { entryId } = await insertPostedEntry({ companyId, userId, fiscalPeriodId })
|
||||
const viewerId = await insertAuthUser()
|
||||
await insertCompanyMember({ companyId, userId: viewerId, role: 'viewer' })
|
||||
|
||||
// Authenticated JWT context as the viewer, passing the OWNER's id as
|
||||
// p_user_id: the RPC must pin the actor to auth.uid() and refuse.
|
||||
await withUserContext(viewerId, async (client) => {
|
||||
await expect(
|
||||
client.query(
|
||||
`SELECT public.correct_entry_metadata($1::uuid, $2::uuid, 'Spoofad text', NULL, $3::uuid)`,
|
||||
[companyId, entryId, userId],
|
||||
),
|
||||
).rejects.toThrow(/skrivbehörighet/)
|
||||
})
|
||||
})
|
||||
|
||||
it('never admits a smuggled non-metadata change under the GUC', async () => {
|
||||
const { companyId, userId, fiscalPeriodId } = await seedCompany()
|
||||
const { entryId } = await insertPostedEntry({ companyId, userId, fiscalPeriodId })
|
||||
|
||||
const client = await getPool().connect()
|
||||
try {
|
||||
await client.query('BEGIN')
|
||||
await client.query(`SELECT set_config('gnubok.allow_metadata_rattelse', 'true', true)`)
|
||||
await expect(
|
||||
client.query(
|
||||
`UPDATE public.journal_entries SET description = 'ny text', voucher_number = 4711 WHERE id = $1`,
|
||||
[entryId],
|
||||
),
|
||||
).rejects.toThrow(/immutable/)
|
||||
await client.query('ROLLBACK')
|
||||
} finally {
|
||||
client.release()
|
||||
}
|
||||
})
|
||||
|
||||
it("cannot reach another company's entries", async () => {
|
||||
const a = await seedCompany()
|
||||
const b = await seedCompany()
|
||||
const { entryId } = await insertPostedEntry({
|
||||
companyId: a.companyId, userId: a.userId, fiscalPeriodId: a.fiscalPeriodId,
|
||||
})
|
||||
|
||||
await expect(callMetadata(b.companyId, entryId, 'Cross-tenant', null, b.userId)).rejects.toThrow(
|
||||
/hittades inte/,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('inline rättelse: lines (correct_entry_lines_inline)', () => {
|
||||
it('still blocks a GUC-less DELETE of a posted line', async () => {
|
||||
const { companyId, userId, fiscalPeriodId } = await seedCompany()
|
||||
const { debitLineId } = await insertPostedEntry({ companyId, userId, fiscalPeriodId })
|
||||
|
||||
await expect(
|
||||
getPool().query(`DELETE FROM public.journal_entry_lines WHERE id = $1`, [debitLineId]),
|
||||
).rejects.toThrow(/Cannot DELETE lines of a posted journal entry/)
|
||||
})
|
||||
|
||||
it('strikes a line and adds a balanced replacement in the same verifikat (happy path)', async () => {
|
||||
const { companyId, userId, fiscalPeriodId } = await seedCompany()
|
||||
await insertChartAccount(companyId, userId, '5420')
|
||||
const { entryId, debitLineId } = await insertPostedEntry({ companyId, userId, fiscalPeriodId })
|
||||
|
||||
const res = await callStrike(
|
||||
companyId, entryId, [debitLineId],
|
||||
[{ account_number: '5420', debit_amount: 1000, credit_amount: 0, line_description: 'Programvara' }],
|
||||
userId,
|
||||
)
|
||||
expect(res.rows[0].result.struck_count).toBe(1)
|
||||
expect(res.rows[0].result.added_count).toBe(1)
|
||||
|
||||
// The struck line is gone from the effective verifikat; the replacement
|
||||
// exists with a resolved account_id and a sort_order after the survivors.
|
||||
const { rows: lines } = await getPool().query(
|
||||
`SELECT account_number, debit_amount::numeric, credit_amount::numeric, account_id, sort_order
|
||||
FROM public.journal_entry_lines WHERE journal_entry_id = $1 ORDER BY sort_order`,
|
||||
[entryId],
|
||||
)
|
||||
expect(lines).toHaveLength(2)
|
||||
expect(lines.map((l) => l.account_number)).toEqual(['1930', '5420'])
|
||||
expect(lines[1].account_id).toBeTruthy()
|
||||
expect(Number(lines[1].debit_amount)).toBe(1000)
|
||||
|
||||
// Entry still balances and is still posted.
|
||||
const { rows: sums } = await getPool().query(
|
||||
`SELECT sum(debit_amount)::numeric AS d, sum(credit_amount)::numeric AS c
|
||||
FROM public.journal_entry_lines WHERE journal_entry_id = $1`,
|
||||
[entryId],
|
||||
)
|
||||
expect(Number(sums[0].d)).toBe(1000)
|
||||
expect(Number(sums[0].c)).toBe(1000)
|
||||
|
||||
// Immutable log row carries the full struck snapshot + the added lines.
|
||||
const { rows: log } = await getPool().query(
|
||||
`SELECT rattelse_type, struck_lines, added_lines, actor
|
||||
FROM public.journal_entry_rattelse_log WHERE journal_entry_id = $1`,
|
||||
[entryId],
|
||||
)
|
||||
expect(log).toHaveLength(1)
|
||||
expect(log[0].rattelse_type).toBe('lines')
|
||||
expect(log[0].struck_lines).toHaveLength(1)
|
||||
expect(log[0].struck_lines[0].account_number).toBe('5010')
|
||||
expect(Number(log[0].struck_lines[0].debit_amount)).toBe(1000)
|
||||
expect(log[0].added_lines).toHaveLength(1)
|
||||
expect(log[0].added_lines[0].account_number).toBe('5420')
|
||||
expect(log[0].actor).toBe(userId)
|
||||
})
|
||||
|
||||
it('rejects an unbalanced rättelse and rolls back atomically', async () => {
|
||||
const { companyId, userId, fiscalPeriodId } = await seedCompany()
|
||||
await insertChartAccount(companyId, userId, '5420')
|
||||
const { entryId, debitLineId } = await insertPostedEntry({ companyId, userId, fiscalPeriodId })
|
||||
|
||||
await expect(
|
||||
callStrike(
|
||||
companyId, entryId, [debitLineId],
|
||||
[{ account_number: '5420', debit_amount: 900, credit_amount: 0 }],
|
||||
userId,
|
||||
),
|
||||
).rejects.toThrow(/balanserar inte/)
|
||||
|
||||
// Nothing changed, nothing logged.
|
||||
const { rows: lines } = await getPool().query(
|
||||
`SELECT count(*)::int AS n FROM public.journal_entry_lines WHERE journal_entry_id = $1`,
|
||||
[entryId],
|
||||
)
|
||||
expect(lines[0].n).toBe(2)
|
||||
const { rows: log } = await getPool().query(
|
||||
`SELECT 1 FROM public.journal_entry_rattelse_log WHERE journal_entry_id = $1`,
|
||||
[entryId],
|
||||
)
|
||||
expect(log).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('rejects a rättelse that leaves fewer than two lines or zeroes the verifikat', async () => {
|
||||
const { companyId, userId, fiscalPeriodId } = await seedCompany()
|
||||
const { entryId, debitLineId, creditLineId } = await insertPostedEntry({
|
||||
companyId, userId, fiscalPeriodId,
|
||||
})
|
||||
|
||||
await expect(callStrike(companyId, entryId, [debitLineId], [], userId)).rejects.toThrow(
|
||||
/minst två rader/,
|
||||
)
|
||||
await expect(
|
||||
callStrike(companyId, entryId, [debitLineId, creditLineId], [], userId),
|
||||
).rejects.toThrow(/minst två rader/)
|
||||
})
|
||||
|
||||
it('rejects an empty rättelse and a strike + identical re-add', async () => {
|
||||
const { companyId, userId, fiscalPeriodId } = await seedCompany()
|
||||
await insertChartAccount(companyId, userId, '5010')
|
||||
const { entryId, debitLineId } = await insertPostedEntry({ companyId, userId, fiscalPeriodId })
|
||||
|
||||
await expect(callStrike(companyId, entryId, [], [], userId)).rejects.toThrow(/minst en rad/)
|
||||
await expect(
|
||||
callStrike(
|
||||
companyId, entryId, [debitLineId],
|
||||
[{ account_number: '5010', debit_amount: 1000, credit_amount: 0 }],
|
||||
userId,
|
||||
),
|
||||
).rejects.toThrow(/ändrar ingenting/)
|
||||
})
|
||||
|
||||
it("rejects strike ids from another entry and accounts missing from the chart", async () => {
|
||||
const { companyId, userId, fiscalPeriodId } = await seedCompany()
|
||||
const first = await insertPostedEntry({ companyId, userId, fiscalPeriodId })
|
||||
const second = await insertPostedEntry({ companyId, userId, fiscalPeriodId, voucherNumber: 2 })
|
||||
|
||||
await expect(
|
||||
callStrike(companyId, first.entryId, [second.debitLineId], [], userId),
|
||||
).rejects.toThrow(/hör inte till verifikationen/)
|
||||
|
||||
await expect(
|
||||
callStrike(
|
||||
companyId, first.entryId, [first.debitLineId],
|
||||
[{ account_number: '9999', debit_amount: 1000, credit_amount: 0 }],
|
||||
userId,
|
||||
),
|
||||
).rejects.toThrow(/finns inte i kontoplanen/)
|
||||
})
|
||||
|
||||
it('rejects line rättelse on structural source types and outside open periods', async () => {
|
||||
const { companyId, userId, fiscalPeriodId } = await seedCompany()
|
||||
const yearEnd = await insertPostedEntry({
|
||||
companyId, userId, fiscalPeriodId, sourceType: 'year_end', voucherNumber: 3,
|
||||
})
|
||||
await expect(
|
||||
callStrike(companyId, yearEnd.entryId, [yearEnd.debitLineId], [], userId),
|
||||
).rejects.toThrow(/kan inte rättas radvis/)
|
||||
|
||||
const locked = await seedCompany()
|
||||
const lockedEntry = await insertPostedEntry({
|
||||
companyId: locked.companyId, userId: locked.userId, fiscalPeriodId: locked.fiscalPeriodId,
|
||||
})
|
||||
await getPool().query(`UPDATE public.fiscal_periods SET locked_at = now() WHERE id = $1`, [
|
||||
locked.fiscalPeriodId,
|
||||
])
|
||||
await expect(
|
||||
callStrike(locked.companyId, lockedEntry.entryId, [lockedEntry.debitLineId], [], locked.userId),
|
||||
).rejects.toThrow(/stängd eller låst/)
|
||||
})
|
||||
|
||||
it('blocks striking foreign-currency lines and doc-attached lines', async () => {
|
||||
const { companyId, userId, fiscalPeriodId } = await seedCompany()
|
||||
await insertChartAccount(companyId, userId, '5420')
|
||||
const entryId = await insertDraftJournalEntry({
|
||||
userId, companyId, fiscalPeriodId, sourceType: 'manual', status: 'draft', voucherNumber: 11,
|
||||
})
|
||||
const { rows: fxRows } = await getPool().query<{ id: string }>(
|
||||
`INSERT INTO public.journal_entry_lines
|
||||
(journal_entry_id, account_number, debit_amount, credit_amount, sort_order, currency, amount_in_currency, exchange_rate)
|
||||
VALUES ($1, '5010', 1000, 0, 1, 'EUR', 90, 11.11) RETURNING id`,
|
||||
[entryId],
|
||||
)
|
||||
await getPool().query(
|
||||
`INSERT INTO public.journal_entry_lines (journal_entry_id, account_number, debit_amount, credit_amount, sort_order)
|
||||
VALUES ($1, '1930', 0, 1000, 2)`,
|
||||
[entryId],
|
||||
)
|
||||
await getPool().query(`UPDATE public.journal_entries SET status = 'posted' WHERE id = $1`, [entryId])
|
||||
|
||||
await expect(
|
||||
callStrike(companyId, entryId, [fxRows[0].id],
|
||||
[{ account_number: '5420', debit_amount: 1000, credit_amount: 0 }], userId),
|
||||
).rejects.toThrow(/utländsk valuta/)
|
||||
|
||||
await getPool().query(
|
||||
`INSERT INTO public.document_attachments
|
||||
(user_id, company_id, journal_entry_id, journal_entry_line_id, storage_path, file_name, sha256_hash)
|
||||
VALUES ($1, $2, $3, $4, 'test/underlag.pdf', 'kvitto.pdf', repeat('a', 64))`,
|
||||
[userId, companyId, entryId, fxRows[0].id],
|
||||
)
|
||||
await expect(
|
||||
callStrike(companyId, entryId, [fxRows[0].id],
|
||||
[{ account_number: '5420', debit_amount: 1000, credit_amount: 0 }], userId),
|
||||
).rejects.toThrow(/utländsk valuta|kopplat underlag/)
|
||||
})
|
||||
|
||||
it('protects the bank side of transaction-linked entries but allows contra-side fixes', async () => {
|
||||
const { companyId, userId, fiscalPeriodId } = await seedCompany()
|
||||
await insertChartAccount(companyId, userId, '5420')
|
||||
await insertChartAccount(companyId, userId, '1930')
|
||||
const { entryId, debitLineId, creditLineId } = await insertPostedEntry({
|
||||
companyId, userId, fiscalPeriodId, sourceType: 'bank_transaction',
|
||||
})
|
||||
await getPool().query(
|
||||
`INSERT INTO public.transactions (user_id, company_id, date, description, amount, journal_entry_id, is_business)
|
||||
VALUES ($1, $2, '2026-02-10', 'Bank tx', -1000, $3, true)`,
|
||||
[userId, companyId, entryId],
|
||||
)
|
||||
|
||||
// Changing the 1930 net is refused: the bank feed amount is immutable.
|
||||
await expect(
|
||||
callStrike(companyId, entryId, [creditLineId],
|
||||
[
|
||||
{ account_number: '1930', debit_amount: 0, credit_amount: 900 },
|
||||
{ account_number: '5420', debit_amount: 0, credit_amount: 100 },
|
||||
], userId),
|
||||
).rejects.toThrow(/kopplad till en banktransaktion/)
|
||||
|
||||
// The contra side (wrong expense account) is exactly the reconciliation
|
||||
// use case and stays correctable.
|
||||
const res = await callStrike(companyId, entryId, [debitLineId],
|
||||
[{ account_number: '5420', debit_amount: 1000, credit_amount: 0 }], userId)
|
||||
expect(res.rows[0].result.struck_count).toBe(1)
|
||||
|
||||
// A net-preserving strike+re-add on the bank line (description fix) is
|
||||
// allowed, and counts as a real change thanks to the description-aware
|
||||
// no-op comparison.
|
||||
const res2 = await callStrike(companyId, entryId, [creditLineId],
|
||||
[{ account_number: '1930', debit_amount: 0, credit_amount: 1000, line_description: 'Rättad text' }], userId)
|
||||
expect(res2.rows[0].result.struck_count).toBe(1)
|
||||
})
|
||||
|
||||
it('keeps the journal_entry_rattelse_log immutable', async () => {
|
||||
const { companyId, userId, fiscalPeriodId } = await seedCompany()
|
||||
await insertChartAccount(companyId, userId, '5420')
|
||||
const { entryId, debitLineId } = await insertPostedEntry({ companyId, userId, fiscalPeriodId })
|
||||
await callStrike(
|
||||
companyId, entryId, [debitLineId],
|
||||
[{ account_number: '5420', debit_amount: 1000, credit_amount: 0 }],
|
||||
userId,
|
||||
)
|
||||
|
||||
const { rows } = await getPool().query<{ id: string }>(
|
||||
`SELECT id FROM public.journal_entry_rattelse_log WHERE journal_entry_id = $1`,
|
||||
[entryId],
|
||||
)
|
||||
await expect(
|
||||
getPool().query(`UPDATE public.journal_entry_rattelse_log SET actor = NULL WHERE id = $1`, [
|
||||
rows[0].id,
|
||||
]),
|
||||
).rejects.toThrow(/oföränderlig/)
|
||||
await expect(
|
||||
getPool().query(`DELETE FROM public.journal_entry_rattelse_log WHERE id = $1`, [rows[0].id]),
|
||||
).rejects.toThrow(/oföränderlig/)
|
||||
})
|
||||
|
||||
it('leaves the gnubok.allow_delete bulk-delete path unaffected', async () => {
|
||||
const { companyId, userId, fiscalPeriodId } = await seedCompany()
|
||||
const { entryId, debitLineId } = await insertPostedEntry({ companyId, userId, fiscalPeriodId })
|
||||
|
||||
const client = await getPool().connect()
|
||||
try {
|
||||
await client.query('BEGIN')
|
||||
await client.query(`SELECT set_config('gnubok.allow_delete', 'true', true)`)
|
||||
await client.query(`DELETE FROM public.journal_entry_lines WHERE id = $1`, [debitLineId])
|
||||
await client.query(`DELETE FROM public.journal_entries WHERE id = $1`, [entryId])
|
||||
await client.query('ROLLBACK')
|
||||
} finally {
|
||||
client.release()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -21,6 +21,10 @@
|
||||
"path": "/api/extensions/stripe/sync/cron",
|
||||
"schedule": "*/15 * * * *"
|
||||
},
|
||||
{
|
||||
"path": "/api/extensions/stripe/transactions/cron",
|
||||
"schedule": "30 3 * * *"
|
||||
},
|
||||
{
|
||||
"path": "/api/documents/verify/cron",
|
||||
"schedule": "0 3 * * *"
|
||||
|
||||
Reference in New Issue
Block a user