fix: similar-sweep currency remediation, security hardening and v1 API fixes (#1215)

* fix(security): gate replace_sie_import behind owner/admin membership

The RPC was SECURITY DEFINER with EXECUTE granted to PUBLIC and anon, no
company_members lookup, no auth.uid() reference and no unauthorized raise,
while setting gnubok.allow_delete to disarm the BFL immutability and
retention triggers. Any caller holding a company_id and an import id could
hard delete another tenant's verifikationer. Confirmed live in production.

Applies the same fail closed owner/admin guard that undo_sie_import already
carries (migration 20260624120000), resolving the actor from
COALESCE(p_user_id, auth.uid()) so it denies when the role is NULL, then
revokes EXECUTE from PUBLIC and anon. search_path and the raised
statement_timeout are restated, since CREATE OR REPLACE drops settings that
are not repeated.

userId is a required parameter on replaceSIEImport: the service client has a
NULL auth.uid(), so a caller without an explicit actor now fails to compile
rather than hitting the closed gate at runtime.

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

* fix(security): validate arcim OAuth callback state server side

The callback route is skipAuth and decoded the state parameter as plain
base64url JSON, trusting consentId and provider from it. A one time code was
minted at flow start and never read. An unauthenticated attacker who learned
a consent id could run an OAuth flow on their own provider account and post
the callback with a forged state, landing their tokens on another tenant's
consent, so the victim's next migration imported the attacker's ledger.

State is now an opaque randomBytes(32) pointer to a provider_otc row,
consumed by a single atomic UPDATE guarded on used_at IS NULL and
expires_at, so a replay loses the row lock race and updates nothing.
provider is read from provider_consents rather than trusted from the client.
provider_otc already existed for exactly this purpose and was never wired up.

Also scopes getConsent to an owning company, closing a cross tenant status
oracle where the preview and migrate paths echoed a consent's status before
the scoped check ran.

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

* fix(security): scope documents storage to company_id (phase A)

The documents bucket policies matched on auth.uid(), and upload keys were
documents/{userId}/..., so company membership was never consulted. Removing a
member revoked nothing: their session still authenticated and they kept
direct Storage read access to every receipt, supplier invoice and bank
statement they had uploaded. The same bug was fixed for sie-files in
20260416120000; this bucket was left behind.

Phase A is additive. Company scoped policies are added alongside the
uploader scoped ones, uploads move to documents/{companyId}/{userId}/..., and
reads accept either layout so nothing breaks mid migration. Phase C, which
drops the old policies, is gated on the backfill reporting zero remaining
legacy prefix objects.

The policy compares the company segment as text rather than casting to uuid
the way sie-files does: this bucket holds keys whose second segment is not a
uuid (MCP audit packages), and Postgres does not guarantee the bucket prefix
qual runs before the cast, so a planner reordering would raise 22P02 and fail
the whole query instead of filtering the row out.

deleteDocument now removes both candidate keys. Removing only the stored
pointer would leave a readable orphan copy of a document the user asked to
erase.

The backfill script is included but has never been run. It defaults to dry
run, refuses .env.local by name, and verifies each copy is readable and
SHA-256 identical before repointing the row.

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

* fix(security): enforce events:read scope and membership on /api/events

This was the only one of the three validateApiKey call sites with no
downstream guard: v1 and the MCP server both check scope and re-verify
company membership, this route did neither. An events:read scope existed and
was documented as gating the endpoint but was never called, so a legacy key
falling back to DEFAULT_SCOPES read the full log. The bound company id went
straight from the api_keys row into a service role query, so a key whose user
had been removed from the company kept reading.

Adds the scope check before any database access, re-verifies company_members
with archived_at IS NULL, honours test mode by stamping X-Gnubok-Mode instead
of ignoring it, applies minimisePayload so the pull surface can never return
a wider payload than the push surface, and replaces the three flat error
strings with the canonical envelope.

Test key reads are served rather than blocked: TEST_KEY_WRITE_BLOCKED is
gated on mutations in with-api-v1, so a read gets the same treatment as every
other v1 read endpoint.

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

* perf(bookkeeping): sweep remaining journal_entries!inner embeds

A previous refactor removed this pattern from lib/reports and introduced
fetchEntryLines, but the class was never swept. Seventeen sites remained and
had become the top application consumer of production database time:
measured across the resulting query shapes, 32,694 calls and 25,848 seconds
of execution, mean 790ms, with shapes averaging 2.6s and 3.0s and maxing at
7,962ms against the 8s statement_timeout, which surfaced to users as 500s on
the booking path.

PostgREST compiles an embed with filters on the embedded side into a
correlated INNER JOIN LATERAL with a parameterized LIMIT, which stops
Postgres reordering the join, so each query walked the whole
journal_entry_lines table across all tenants. Driving from the entries side
instead turns that into two indexed round trips.

Converted sites keep their existing shape: the helper reattaches the parent
entry under the same key the embed produced. Several conversions also remove
a latent silent truncation where an unpaginated query was capped at
PostgREST's 1000 row ceiling.

Two deliberate exceptions. The free text ilike legs of the MCP display query
stay on the embed, because each is capped at legLimit and that cap drives the
truncation contract the tool reports, while the helper is unbounded. The
accounts route moves to the existing get_account_usage_counts RPC instead,
since its embed was a head count and the helper returns rows.

commitEntry's write path is untouched: the change there is confined to the
read query of the pre-commit dimension rule check.

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

* fix(api): anchor v1 list cursors on created_at

Page two returned page one, forever, while still advertising a fresh
next_cursor. The three routes sorted by and encoded a Postgres date column,
which serializes as YYYY-MM-DD, but decodeDefaultCursor validates the cursor
timestamp as full ISO-8601 and returned null, so the keyset filter was never
applied and has_more never went false. An integrator syncing verifikat looped
on the newest rows indefinitely.

The transactions route already solved this and its comment names the trap;
the fix was never ported. All three now order and encode on created_at with
an id tie break, matching the transactions keyset predicate exactly.
ISO_TIMESTAMP is deliberately left alone: relaxing it would silently change
sort semantics on the route that currently works.

Default ordering therefore moves from business date to insert order. Every
business date is still on the row, and the invoices list gains date_from and
date_to filters so a date range is still reachable; the other two already had
them.

The tests use an in-memory PostgREST that actually evaluates the filters,
because the repo's pass-through mock cannot catch this class of bug: the bug
is that the filter is never sent. They walk to exhaustion with a hard
iteration cap, so an unterminated walk fails instead of hanging.

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

* fix(api): separate dry run from commit in the idempotency hash

The request hash was built from url.pathname, which excludes the query
string, so a dry run and its commit hashed identically. Following the flow
documented in dry-run.ts, re-issuing the request with the same
Idempotency-Key returned the cached preview with Idempotent-Replayed set and
wrote nothing, while reporting 200. An agent or integrator saw success for a
write that never happened.

dry_run is folded into the hash only when true, not as an unconditional
boolean. Including it as false would change the hash of every ordinary write,
and with a 24h idempotency TTL any key in flight across the deploy would fail
the request_hash comparison and 409 on a legitimate retry. Both hash call
sites now go through one shared helper so they cannot drift into a permanent
cache miss, and dry run responses are no longer stored at all.

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

* ci: install the Bedrock SDK out of tree in the compliance review

The Swedish accounting compliance gate had failed ten consecutive runs and so
was posting nothing. With --no-package-lock npm discarded the lockfile and
re-resolved the whole tree from package.json, floating @hookform/resolvers to
5.4.3, whose valibot ^1 peer conflicts with the pinned valibot 0.39.0.

Installing into the parent of the checkout resolves only that one package, so
an unrelated peer conflict can never take the gate down again. Node still
finds it because ESM bare specifiers walk up parent node_modules; NODE_PATH
would not have worked, as it is CommonJS only. --legacy-peer-deps was
rejected because it masks future genuine peer conflicts and still reifies the
full tree.

The same step's SDK version is aligned from 0.31.0 back to the 0.29.1 that
package.json and check:guards enforce after the streaming outage. That drift
went unnoticed because the pin guard only inspects package.json and the
lockfile, never workflow files.

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

* build(docker): generate crontabs from vercel.json

vercel.json defines 16 cron jobs; both Docker crontabs carried 9, and were
byte identical to each other. Self hosted deployments therefore never sent
recurring invoices, never dispatched webhooks and never cleaned up
idempotency keys. tax-deadlines also ran once a year on 2 January instead of
daily, and documents/verify weekly instead of daily.

Extension crons are included rather than excluded. The Dockerfile copies the
whole tree before building, so every extension cron route is compiled into
the image regardless of the enabled preset, and each returns 200 when its
extension is unconfigured, so curl -sf logs no failure. Two such entries were
already present in the crontab for extensions absent from the preset, which
settles the intent.

documents/verify is treated as drift rather than a self hosted concession:
the weekly cadence was present in the hosted crontab too, and the run is
capped at 200 documents walking a nulls-first queue, so weekly drains the
integrity queue seven times slower on a check that exists for BFL retention.

webhooks/dispatch keeps its per minute cadence, adding 1,440 requests a day
on self hosted. A gentler tick would silently stretch the first retry, since
the retry ladder opens at 60 seconds. SCHEDULE_OVERRIDES is the one line
place to change that.

A parity test asserts the path sets match minus a documented exclusion list,
and ratchets three cron routes that are currently scheduled nowhere so they
are named rather than silently rotting.

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

* chore(observability): add a provider agnostic error sink

There is no error tracking in this codebase: logs go to console and Vercel
retention and nowhere else, nothing alerts on the 16 cron jobs, and seven
code comments across lib, app, components and extensions asserted that Sentry
captures errors when Sentry is not a dependency. The two most recent bug
fixes on this repo were both discovered by customer email.

This adds the sink, not a vendor. No dependency is taken: the interface has a
no-op default and a registration point, so behaviour is unchanged until an
adapter is registered. Releases are tagged from the build id already inlined
by next.config.ts.

Redaction moved out of lib/logger.ts into a leaf module that both the logger
and the sink import, so there is one denylist and no path from application
data to a third party can skip the personnummer regex, including direct sink
calls that bypass the logger. That matters here because these logs carry
personnummer and financial data.

verifyCronSecret now reports its own 401s, which covers all 16 jobs without
touching a route file and catches the case where CRON_SECRET is rotated
without updating the scheduler and every job silently 401s forever. The
threshold is one failure rather than the backup alert's three: suppressing
the first occurrence is precisely how an outage stays invisible.

The seven misleading comments are corrected to describe what the code
actually does, including the two cases that still are not covered: the client
side one, since the sink is server side, and a warn level call that is not
forwarded.

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

* fix: remediate the 2026-07-26 similar-sweep findings across all surfaces

Resolves the ~150-finding sweep (dev_docs/similar-sweep-2026-07-26.md) with
one agent per finding; every behavioural fix carries a regression test proven
to fail at HEAD. Full status, corrections to the sweep, refusals and open
decisions in dev_docs/similar-sweep-2026-07-26-remediation-status.md.

Structural roots closed:
- resolveSekAmountOrNull(): honest SEK resolution refuses instead of booking
  1:1; four duplicated toSek closures now refuse via INVOICE_FX_RATE_MISSING
- ledger-line-amount.ts: journal_entry_lines.currency labels the document,
  not the amount; SQL pre-filter decoy proven and fixed
- sparse-patch.ts: .partial() does not strip .default() in Zod 4.4.3; the
  exploitable salary payslip-line PATCH and KPI preferences sinks fixed
- tests/schema: migration-replay phantom-column guard (13k+ refs, closed
  CHECK sets, onConflict targets); found 28 real defects, all fixed, all
  four baselines now empty
- three new ratchet guards: sek-labelled-amount, cross-extension-import,
  ungated-extension-route

Highlights: lawful VAT-rate set on all seven invoice surfaces (ML 6 kap),
RC input VAT mismatch wired on web + both MCP callers, missing-underlag
resource delegates to the shared RPC predicate, push-notifications consent
polarity fail-closed, deadlines undo honours requested state, silent-failure
and read-side-fabrication classes fixed across settings/KPI/inbox/Stripe/
Arcim/kassaflodesanalys, error-envelope stringification fixed at 10+ sites
with isSwedishUserMessage extended.

Also includes the parallel session's MCP invoice tools (update_invoice,
recurring schedules, invoice deliveries) which share files with the sweep
work and are verified green together.

13 new migrations are NOT applied anywhere; they apply via branch merge.
20260726120000 backfills 1247 supplier-invoice rows. pg tests for new
DDL are written but unrun (no local Postgres).

Verified: 11088 tests / 881 files green, tsc 0 non-test errors, lint 0
errors, check:guards passing, MCP payload 57475/57500.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(migrations): rename replace_sie_import migration off main's 20260726090000 version

origin/main shipped 20260726090000_agent_quota_rpc_caller_guard.sql; keeping
our replace_sie_import migration on the same version would abort the Supabase
apply with a schema_migrations_pkey duplicate at merge time.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(review): remediate pre-publish deep-review findings across all slices

A 13-agent review of the full branch diff surfaced 1 critical, 5 high and
~45 further findings; this commit resolves them in one pass:

- replace_sie_import / undo_sie_import: p_user_id honored only for
  service_role callers; any other caller is pinned to auth.uid()
  (impersonation gate bypass), authz raise errcode 42501 mapped to a
  Swedish 403 in the route, new caller-guard migration for undo
- bulk_book_transactions refuses homogeneous non-SEK batches instead of
  writing foreign magnitudes into SEK ledger columns
- credit-note cap trigger: company-match on credited_invoice_id, no
  cross-tenant figures in exception text
- link_voucher RPCs resolve NULL invoice currency as SEK end to end
- personal-number ciphertext CHECK split into NOT VALID + VALIDATE
- same-currency foreign settlements clear 1510 at booking rate and book
  realized diff to 3960/7960; rate-less foreign write paths refuse
- receivables revaluation covers partially_paid and outstanding amounts
- period lock guard paginates candidates past the PostgREST 1000 cap
- documents: service-client storage removals after authz, dual-layout
  reads in integrity cron and archive export, backfill delete-source
  sweep actually deletes with hash verification and shared-key grouping
- invoice matching normalizes NULL/lowercase currencies (regression),
  duplicate candidates stop claiming amount matches they never ran
- match-invoice aborts on any booking failure (no paid-without-verifikat)
- refresh-exchange-rate reverts on concurrent booking (TOCTOU window)
- KPI preferences upsert arbiter aligned to the company-scoped constraint
- personnummer_last4 stripped from all salary responses incl. MCP tools
- worked-hours batch restores destroyed rows on conflict and error paths
- MCP: shared duplicate-claim builder (no more 'null kr'), short-circuit
  on tag_journal_lines overflow, auto_send schedules stage as high risk
- observability sink redacts emails/IBANs/API keys and keeps redacted
  stacks in prod; assorted small guards (safe-return-to /@, dry_run=True,
  cursor helper off-by-one, OAuth state TTL 10 min, arcim saveMappings
  call removed)

Full dispositions, deferred items and hand-verified accounting numbers
are documented in the PR body and DECISIONS.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(personnummer): implement masking and encryption for personal numbers with tests

* fix(review): address CI and compliance-bot findings for PR #1215

pg-real: the CI image's auth shim reads the legacy request.jwt.claim.role
GUC, so both service-role simulations (runAsServiceRole and the
invoice-delivery test's local helper) never satisfied auth.role() =
'service_role' and every legitimate p_user_id path failed closed; the
shared helper now sets both GUC shapes plus SET LOCAL ROLE with a
fail-loud sanity check, and the delivery test reuses it. The link-voucher
migration had recreated both RPCs from pre-rewrite file text,
reintroducing the NULL-unsafe membership pattern the
null-safe-tenant-guards ratchet bans; both guards now use
public.caller_is_company_member() with all currency changes preserved.

Compliance bots: the customers export now emits the standard masked form
instead of raw AES-256-GCM ciphertext in the Org-/personnummer column,
and maskCustomerRow returns a non-round-trippable placeholder on decrypt
failure instead of 500ing the list. MCP parity: gnubok_lock_period's
staging pre-check now runs the exact countUnbookedInPeriod the commit
path enforces (exported from period-service; local mirror deleted), and
gnubok_agi_status resolves AGI state run-scoped so a correction run no
longer renders as already filed.

Declined with evidence: PR-Agent's opening-balances null-zeroing concern
(all mergeable columns are NOT NULL with defaults per 20260713101000).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(review): address codex review findings on PR #1215

- restore 20260726140000 to its preview-recorded content and restate the
  NULL-safe tenant guard under 20260727130000: a recorded migration version
  never re-runs, so the in-place edit could not reach the preview branch
- replace toFixed() with sv-SE two-decimal formatting in the ROT/RUT cap
  warning texts and update the pinned test expectations
- drop the em dash in the fiscal-periods route comment
- strip trailing whitespace in import-existing.test.ts

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(reports): raise timeout on real PDF render tests

renderToBuffer does real @react-pdf layout work and exceeds the 5s
default when the full suite saturates the CPU; tests pass in isolation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-07-27 03:34:56 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent a43a8b03cf
commit f24b26a139
536 changed files with 68921 additions and 4851 deletions
@@ -0,0 +1,236 @@
import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
import type { PartyDto, SalesInvoiceDto, SupplierInvoiceDto } from '@/lib/providers/dto'
/**
* Guards the SEK conversion of migrated invoices.
*
* The provider DTOs carry NO exchange rate and NO SEK amount, only a currency
* code and amounts already expressed in it. The mapper used to write
* `exchange_rate: dto.currencyCode === 'SEK' ? null : null`: a ternary that
* returned null on BOTH branches, so every foreign-currency invoice the
* migration imported arrived unconverted, together with null subtotal_sek /
* vat_amount_sek / total_sek.
*
* An imported invoice is räkenskapsinformation (BFL 7 kap): its SEK value is
* part of the record. So the rate must come from the invoice's OWN issue date
* (not today), and when no rate can be established the invoice must be
* REPORTED, never silently nulled and never posted at a fabricated 1:1.
*/
vi.mock('@/lib/currency/riksbanken', () => ({
fetchExchangeRate: vi.fn(),
}))
import { fetchExchangeRate } from '@/lib/currency/riksbanken'
import { buildFxRateIndex, mapSalesInvoice, mapSupplierInvoice } from '../lib/entity-mapper'
const party: PartyDto = { name: 'Motpart AB', identifications: [] }
function salesDto(over: { currencyCode?: string; issueDate?: string } = {}): SalesInvoiceDto {
const currencyCode = over.currencyCode ?? 'SEK'
return {
id: 'inv-1',
invoiceNumber: 'F-100',
issueDate: over.issueDate ?? '2024-03-15',
dueDate: '2024-04-15',
currencyCode,
status: 'sent',
supplier: party,
customer: party,
lines: [
{
id: '1',
description: 'Konsultarvode',
lineExtensionAmount: { value: 1000, currencyCode },
taxPercent: 25,
taxAmount: { value: 250, currencyCode },
},
],
taxTotal: { taxAmount: { value: 250, currencyCode } },
legalMonetaryTotal: {
lineExtensionAmount: { value: 1000, currencyCode },
payableAmount: { value: 1250, currencyCode },
},
paymentStatus: { paid: false, balance: { value: 1250, currencyCode } },
}
}
function supplierDto(over: { currencyCode?: string; issueDate?: string } = {}): SupplierInvoiceDto {
const currencyCode = over.currencyCode ?? 'SEK'
return {
id: 'sinv-1',
invoiceNumber: 'L-200',
issueDate: over.issueDate ?? '2024-03-15',
dueDate: '2024-04-15',
currencyCode,
status: 'booked',
supplier: party,
buyer: party,
lines: [
{
id: '1',
description: 'Licens',
lineExtensionAmount: { value: 1000, currencyCode },
taxPercent: 25,
taxAmount: { value: 250, currencyCode },
},
],
taxTotal: { taxAmount: { value: 250, currencyCode } },
legalMonetaryTotal: {
lineExtensionAmount: { value: 1000, currencyCode },
payableAmount: { value: 1250, currencyCode },
},
paymentStatus: { paid: false, balance: { value: 1250, currencyCode } },
}
}
const supabase = {} as SupabaseClient
beforeEach(() => {
vi.clearAllMocks()
})
describe('buildFxRateIndex', () => {
it('fetches the rate for each document DATE, not today, and caches per pair', async () => {
;(fetchExchangeRate as Mock).mockImplementation(async (currency: string, date: Date) => ({
currency,
rate: 11.5,
date: date.toISOString().split('T')[0],
}))
const index = await buildFxRateIndex(supabase, [
{ currencyCode: 'EUR', issueDate: '2024-03-15' },
// Same (currency, date) pair: must NOT trigger a second fetch.
{ currencyCode: 'EUR', issueDate: '2024-03-15' },
{ currencyCode: 'EUR', issueDate: '2022-11-02' },
// SEK needs no rate at all.
{ currencyCode: 'SEK', issueDate: '2024-03-15' },
])
expect(fetchExchangeRate).toHaveBeenCalledTimes(2)
// The document's own date is what is asked for.
const askedDates = (fetchExchangeRate as Mock).mock.calls
.map((c) => (c[1] as Date).toISOString().split('T')[0])
.sort()
expect(askedDates).toEqual(['2022-11-02', '2024-03-15'])
// The supabase client is passed so the exchange_rates cache is consulted.
expect((fetchExchangeRate as Mock).mock.calls[0][2]).toBe(supabase)
expect(index.get('EUR|2024-03-15')?.rate).toBe(11.5)
expect(index.get('EUR|2022-11-02')?.rate).toBe(11.5)
})
it('leaves a pair unset when the rate cannot be fetched: never a made-up rate', async () => {
;(fetchExchangeRate as Mock).mockResolvedValue(null)
const index = await buildFxRateIndex(supabase, [{ currencyCode: 'EUR', issueDate: '2024-03-15' }])
expect(index.size).toBe(0)
})
it('skips currencies with no Riksbanken series instead of fetching them', async () => {
const index = await buildFxRateIndex(supabase, [{ currencyCode: 'JPY', issueDate: '2024-03-15' }])
expect(fetchExchangeRate).not.toHaveBeenCalled()
expect(index.size).toBe(0)
})
})
describe.each([
['mapSalesInvoice', (dto: SalesInvoiceDto, fx?: Map<string, never>) =>
mapSalesInvoice(dto, 'user-1', 'company-1', 'customer-1', fx as never), salesDto],
['mapSupplierInvoice', (dto: SupplierInvoiceDto, fx?: Map<string, never>) =>
mapSupplierInvoice(dto, 'user-1', 'company-1', 'supplier-1', fx as never), supplierDto],
] as const)('%s: SEK conversion', (_name, map, makeDto) => {
it('SEK invoice: no exchange rate, SEK amounts equal the invoice amounts', () => {
// A domestic invoice has no rate to record; that null is honest, and the
// *_sek columns still carry the value.
const { invoice, fxUnresolved } = map(makeDto({ currencyCode: 'SEK' }) as never)
expect(invoice.exchange_rate).toBeNull()
expect(invoice.exchange_rate_date).toBeNull()
expect(invoice.subtotal_sek).toBe(1000)
expect(invoice.vat_amount_sek).toBe(250)
expect(invoice.total_sek).toBe(1250)
expect(fxUnresolved).toBeNull()
})
it('EUR invoice with a resolved rate: rate and SEK amounts are populated', () => {
const fx = new Map([
['EUR|2024-03-15', { currency: 'EUR', rate: 11.32, date: '2024-03-15' }],
])
const { invoice, fxUnresolved } = map(
makeDto({ currencyCode: 'EUR', issueDate: '2024-03-15' }) as never,
fx as never
)
expect(invoice.currency).toBe('EUR')
expect(invoice.exchange_rate).toBe(11.32)
expect(invoice.exchange_rate_date).toBe('2024-03-15')
// Rounded to öre through the mapper's round2 helper, never toFixed.
expect(invoice.subtotal_sek).toBe(11320)
expect(invoice.vat_amount_sek).toBe(2830)
expect(invoice.total_sek).toBe(14150)
// Foreign-currency amounts themselves stay in the invoice currency.
expect(invoice.subtotal).toBe(1000)
expect(invoice.total).toBe(1250)
expect(fxUnresolved).toBeNull()
})
it('uses the rate for the invoice DATE, not another date in the index', () => {
const fx = new Map([
['EUR|2024-03-15', { currency: 'EUR', rate: 11.32, date: '2024-03-15' }],
['EUR|2022-11-02', { currency: 'EUR', rate: 10.75, date: '2022-11-02' }],
])
const { invoice } = map(
makeDto({ currencyCode: 'EUR', issueDate: '2022-11-02' }) as never,
fx as never
)
expect(invoice.exchange_rate).toBe(10.75)
expect(invoice.total_sek).toBe(13437.5)
})
it('EUR invoice with no obtainable rate: reported, not silently nulled', () => {
const { invoice, fxUnresolved } = map(
makeDto({ currencyCode: 'EUR', issueDate: '2024-03-15' }) as never,
new Map() as never
)
// Still imported (the record is räkenskapsinformation) but explicitly
// unconverted, so the booking paths refuse it instead of posting at 1:1.
expect(invoice.exchange_rate).toBeNull()
expect(invoice.subtotal_sek).toBeNull()
expect(invoice.vat_amount_sek).toBeNull()
expect(invoice.total_sek).toBeNull()
expect(fxUnresolved).toEqual({ currency: 'EUR', date: '2024-03-15', reason: 'rate_unavailable' })
})
it('currency with no rate source at all is reported as unsupported', () => {
const { invoice, fxUnresolved } = map(
makeDto({ currencyCode: 'JPY', issueDate: '2024-03-15' }) as never,
new Map() as never
)
expect(invoice.exchange_rate).toBeNull()
expect(invoice.total_sek).toBeNull()
expect(fxUnresolved).toEqual({
currency: 'JPY',
date: '2024-03-15',
reason: 'unsupported_currency',
})
})
it('never converts a foreign invoice when no rate index is supplied', () => {
// The regression this locks: a caller that forgets to resolve rates must
// get a REPORT, never a 1:1 conversion.
const { invoice, fxUnresolved } = map(makeDto({ currencyCode: 'EUR' }) as never)
expect(invoice.exchange_rate).toBeNull()
expect(invoice.total_sek).toBeNull()
expect(fxUnresolved?.reason).toBe('rate_unavailable')
})
})
@@ -0,0 +1,32 @@
/**
* Guard against re-adding a direct saveMappings() call to the migration
* wizard's /import-sie handler.
*
* The handler used to run `await saveMappings(supabase, user.id, mappings)`
* before executeSIEImport: user.id in the companyId slot. Once saveMappings
* started surfacing upsert failures (and filling user_id), that call threw on
* RLS/FK on every request, 500ing every provider-migration import before a
* single voucher was written. The call was also redundant: executeSIEImport
* saves the mappings itself (lib/import/sie-import.ts), with the correct
* companyId + userId and non-fatal warning handling.
*
* A source-level assertion is deliberate: the handler builds its Supabase
* client inline and the failure mode is "a future refactor re-imports the
* helper", which this catches regardless of how the handler is wired.
*/
import { describe, it, expect } from 'vitest'
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
const indexSource = readFileSync(fileURLToPath(new URL('../index.ts', import.meta.url)), 'utf8')
describe('arcim-migration /import-sie handler', () => {
it('does not call saveMappings directly (executeSIEImport persists mappings itself)', () => {
expect(indexSource).not.toMatch(/\bsaveMappings\s*\(/)
expect(indexSource).not.toMatch(/import\s*\{[^}]*\bsaveMappings\b[^}]*\}/)
})
it('still routes the import through executeSIEImport, which owns mapping persistence', () => {
expect(indexSource).toContain('executeSIEImport(supabase, companyId, user.id, parsed, mappings')
})
})
@@ -29,6 +29,7 @@ vi.mock('../lib/provider-client', () => ({
getConsent: vi.fn(),
listConsents: vi.fn(),
generateOtc: vi.fn(),
consumeOAuthState: vi.fn(),
getAuthUrl: vi.fn(),
exchangeAuthToken: vi.fn(),
submitProviderToken: vi.fn(),
@@ -36,6 +37,9 @@ vi.mock('../lib/provider-client', () => ({
deleteConsent: vi.fn(),
resolveConsent: vi.fn(),
fetchCompanyInfoDirect: vi.fn(),
// Real classes: index.ts branches on `instanceof` in the catch blocks.
ProviderTokenInvalidError: class ProviderTokenInvalidError extends Error {},
ConsentNotFoundError: class ConsentNotFoundError extends Error {},
}))
import { arcimMigrationExtension } from '../index'
@@ -0,0 +1,292 @@
import { describe, it, expect, beforeEach, afterEach, vi, type Mock } from 'vitest'
import { createMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
import type { ExtensionContext } from '@/lib/extensions/types'
/**
* Locks the tenant boundary on the unauthenticated OAuth callback
* (GET /callback, skipAuth: true).
*
* The callback used to decode `state` as plain base64url JSON and trust
* `consentId` / `provider` straight out of it. Nothing was signed and no
* server-side session row was checked, so anyone who learned a victim's consent
* id (it is handed to the browser in the success redirect and postMessage)
* could run OAuth against their OWN provider account and call the callback with
* `state=base64url({consentId: victim})`. The attacker's provider tokens landed
* on the victim's consent, and the victim's next migration imported the
* attacker's ledger.
*
* The callback now resolves everything from a server-written provider_otc row
* that it consumes atomically. These tests pin that: nothing from the query
* string reaches exchangeAuthToken, and every state failure looks identical
* from outside.
*/
vi.mock('../lib/migration-orchestrator', () => ({
executeMigration: vi.fn().mockResolvedValue({}),
}))
// index.ts imports many helpers from provider-client at module load; stub the
// whole module. The two error classes are real classes because index.ts
// branches on `instanceof`.
vi.mock('../lib/provider-client', () => ({
createConsent: vi.fn(),
getConsent: vi.fn(),
listConsents: vi.fn(),
generateOtc: vi.fn(),
consumeOAuthState: vi.fn(),
getAuthUrl: vi.fn(),
exchangeAuthToken: vi.fn(),
submitProviderToken: vi.fn(),
acceptConsent: vi.fn(),
deleteConsent: vi.fn(),
resolveConsent: vi.fn(),
fetchCompanyInfoDirect: vi.fn(),
ProviderTokenInvalidError: class ProviderTokenInvalidError extends Error {},
ConsentNotFoundError: class ConsentNotFoundError extends Error {},
}))
import { arcimMigrationExtension } from '../index'
import {
consumeOAuthState,
exchangeAuthToken,
getConsent,
ConsentNotFoundError,
} from '../lib/provider-client'
type RouteHandler = (request: Request, ctx?: ExtensionContext) => Promise<Response>
const findRoute = (method: string, path: string) =>
(arcimMigrationExtension.apiRoutes ?? []).find(
(r) => r.method === method && r.path === path,
)!
const callbackHandler = findRoute('GET', '/callback').handler as RouteHandler
const previewHandler = findRoute('GET', '/preview').handler as RouteHandler
const APP_URL = 'https://app.example.test'
/** The exact string the callback shows for every state failure. */
const GENERIC_REJECTION = 'Ingen giltig migrationssession hittades'
function callbackRequest(params: Record<string, string>) {
return createMockRequest(
'http://localhost/api/extensions/ext/arcim-migration/callback',
{ searchParams: params },
)
}
/** The forged payload the old implementation would have trusted. */
function forgedLegacyState(consentId: string, provider: string) {
return Buffer.from(JSON.stringify({ consentId, provider })).toString('base64url')
}
describe('GET /callback: OAuth state binding', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.stubEnv('NEXT_PUBLIC_APP_URL', APP_URL)
// The callback route is dispatched without an ExtensionContext (skipAuth
// routes get no ctx), so console is the logger. Keep the output quiet.
vi.spyOn(console, 'error').mockImplementation(() => {})
})
afterEach(() => {
vi.unstubAllEnvs()
vi.restoreAllMocks()
})
it('rejects a forged state: an unknown token never reaches token exchange', async () => {
;(consumeOAuthState as Mock).mockResolvedValue(null)
const res = await callbackHandler(
callbackRequest({
code: 'provider-auth-code',
state: forgedLegacyState('victim-consent-id', 'fortnox'),
}),
)
const html = await res.text()
expect(exchangeAuthToken).not.toHaveBeenCalled()
expect(html).toContain(GENERIC_REJECTION)
// The response must not echo anything the attacker put in the state.
expect(html).not.toContain('victim-consent-id')
})
it('rejects an expired state with the same generic message as a forged one', async () => {
// consumeOAuthState collapses expired into "no row": the expiry predicate
// lives in the UPDATE's WHERE clause (see provider-client tests).
;(consumeOAuthState as Mock).mockResolvedValue(null)
const res = await callbackHandler(
callbackRequest({ code: 'provider-auth-code', state: 'expired-token' }),
)
const html = await res.text()
expect(exchangeAuthToken).not.toHaveBeenCalled()
expect(html).toContain(GENERIC_REJECTION)
})
it('rejects a replayed state: the second callback with the same token fails', async () => {
// First delivery consumes the row, second finds nothing left to consume.
;(consumeOAuthState as Mock)
.mockResolvedValueOnce({ consentId: 'consent-1', provider: 'fortnox' })
.mockResolvedValueOnce(null)
const first = await callbackHandler(
callbackRequest({ code: 'provider-auth-code', state: 'one-time-token' }),
)
const second = await callbackHandler(
callbackRequest({ code: 'provider-auth-code', state: 'one-time-token' }),
)
expect(await first.text()).toContain('Anslutningen lyckades')
expect(await second.text()).toContain(GENERIC_REJECTION)
// Exactly one exchange: the replay bought the attacker nothing.
expect(exchangeAuthToken).toHaveBeenCalledTimes(1)
expect(consumeOAuthState).toHaveBeenNthCalledWith(1, 'one-time-token')
expect(consumeOAuthState).toHaveBeenNthCalledWith(2, 'one-time-token')
})
it('takes consent and provider from the state ROW, never from the query string', async () => {
;(consumeOAuthState as Mock).mockResolvedValue({
consentId: 'consent-owned-by-caller',
provider: 'visma',
})
// The token names a different consent and provider. It must be ignored:
// the row wins.
const res = await callbackHandler(
callbackRequest({
code: 'provider-auth-code',
state: forgedLegacyState('victim-consent-id', 'fortnox'),
}),
)
expect(exchangeAuthToken).toHaveBeenCalledTimes(1)
expect(exchangeAuthToken).toHaveBeenCalledWith(
'consent-owned-by-caller',
'visma',
'provider-auth-code',
`${APP_URL}/api/extensions/ext/arcim-migration/callback`,
)
expect(await res.text()).toContain('consent-owned-by-caller')
})
})
/**
* The callback's no-opener arm used to be near-dead: the wizard only ever
* reached this route through a popup, which always has a window.opener.
* ArcimMigrationWorkspace now falls back to a full-page OAuth flow when the
* popup is blocked (a discarded window.open return value made a blocked popup
* look exactly like a successful one), so that arm is a live user path and the
* only way a popup-blocked user finishes the migration.
*
* These pin the URL it navigates to, because the wizard reads it on the other
* end: `/import?migration=...` sets mode='migration'
* (app/(dashboard)/import/page.tsx:1990) and handleOAuthReturn consumes
* `consentId` / `reason` from there. Dropping the arm, or renaming a param,
* would strand every popup-blocked user on this HTML page.
*/
describe('GET /callback: full-page fallback when there is no opener', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.stubEnv('NEXT_PUBLIC_APP_URL', APP_URL)
vi.spyOn(console, 'error').mockImplementation(() => {})
})
afterEach(() => {
vi.unstubAllEnvs()
vi.restoreAllMocks()
})
/** The URL the page navigates to when window.opener is absent. */
function fallbackNavigation(html: string): URL {
// Both arms are emitted; the opener arm postMessages instead of navigating.
expect(html).toContain('window.opener')
const match = html.match(/window\.location\.href = "([^"]+)"/)
expect(match, 'callback HTML has no no-opener navigation').not.toBeNull()
return new URL(match![1])
}
it('sends a successful connect back to the wizard with the consent id', async () => {
;(consumeOAuthState as Mock).mockResolvedValue({
consentId: 'consent-1',
provider: 'fortnox',
})
const res = await callbackHandler(
callbackRequest({ code: 'provider-auth-code', state: 'one-time-token' }),
)
const target = fallbackNavigation(await res.text())
expect(target.origin).toBe(APP_URL)
expect(target.pathname).toBe('/import')
expect(target.searchParams.get('migration')).toBe('connected')
expect(target.searchParams.get('consentId')).toBe('consent-1')
})
it('sends a failure back to the wizard with the reason attached', async () => {
;(consumeOAuthState as Mock).mockResolvedValue(null)
const res = await callbackHandler(
callbackRequest({ code: 'provider-auth-code', state: 'forged-token' }),
)
const target = fallbackNavigation(await res.text())
expect(target.pathname).toBe('/import')
expect(target.searchParams.get('migration')).toBe('error')
expect(target.searchParams.get('reason')).toContain(GENERIC_REJECTION)
})
})
describe('GET /preview: cross-tenant consent status oracle', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.spyOn(console, 'error').mockImplementation(() => {})
})
afterEach(() => {
vi.restoreAllMocks()
})
function buildCtx(): ExtensionContext {
const { supabase } = createMockSupabase()
;(supabase as unknown as { auth: unknown }).auth = {
getUser: vi.fn().mockResolvedValue({ data: { user: { id: 'user-1' } } }),
}
return { supabase, companyId: 'company-1' } as unknown as ExtensionContext
}
it('scopes the consent read to the caller company', async () => {
;(getConsent as Mock).mockResolvedValue({ id: 'consent-1', status: 5, provider: 'fortnox' })
await previewHandler(
createMockRequest('http://localhost/api/extensions/ext/arcim-migration/preview', {
searchParams: { consentId: 'consent-1' },
}),
buildCtx(),
)
expect(getConsent).toHaveBeenCalledWith('consent-1', 'company-1')
})
it('answers 404 without a status for a consent owned by another company', async () => {
;(getConsent as Mock).mockRejectedValue(new ConsentNotFoundError())
const res = await previewHandler(
createMockRequest('http://localhost/api/extensions/ext/arcim-migration/preview', {
searchParams: { consentId: 'other-tenants-consent' },
}),
buildCtx(),
)
const { status, body } = await parseJsonResponse<{
error: { code: string; details?: Record<string, unknown> }
}>(res)
expect(status).toBe(404)
expect(body.error.code).toBe('PROVIDER_CONSENT_NOT_FOUND')
// No consent state may leak: not the numeric status, not the provider.
// 404 with no state is exactly what a nonexistent consent returns too.
expect(body.error.details ?? {}).not.toHaveProperty('status')
expect(body.error.details ?? {}).not.toHaveProperty('provider')
})
})
@@ -0,0 +1,250 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
/**
* Unit-level guard on the two tenant boundaries in provider-client:
*
* 1. consumeOAuthState() must consume the OAuth state row with a SINGLE
* conditional UPDATE (`used_at IS NULL` + `expires_at > now` in the WHERE
* clause). A read-then-write would leave a window where two concurrent
* callbacks both see an unused row and both bind tokens.
* 2. getConsent() must filter on company_id. It runs on the service client,
* which bypasses RLS, so that predicate is the only thing stopping an
* authenticated user from reading another tenant's consent status.
*/
type QueryResult = { data?: unknown; error?: unknown }
type RecordedCall = { table: string; ops: [string, unknown[]][] }
/**
* Chainable Supabase stub that RECORDS the predicates it was given. The shared
* helper in tests/helpers.ts intentionally discards arguments; here the
* arguments are the thing under test.
*/
function createRecordingSupabase(results: QueryResult[]) {
const calls: RecordedCall[] = []
let index = 0
const from = (table: string) => {
const result = results[index++] ?? {}
const record: RecordedCall = { table, ops: [] }
calls.push(record)
const chain: unknown = new Proxy(
{},
{
get(_target, prop) {
if (prop === 'then') {
return (resolve: (v: unknown) => void) =>
resolve({ data: result.data ?? null, error: result.error ?? null })
}
return (...args: unknown[]) => {
record.ops.push([String(prop), args])
return chain
}
},
},
)
return chain
}
return { supabase: { from: vi.fn(from) }, calls }
}
const findOp = (call: RecordedCall, name: string, firstArg?: unknown) =>
call.ops.find(([op, args]) => op === name && (firstArg === undefined || args[0] === firstArg))
let serviceClient: { from: ReturnType<typeof vi.fn> }
vi.mock('@/lib/supabase/server', () => ({
createServiceClient: () => serviceClient,
createServiceClientNoCookies: () => serviceClient,
createClient: vi.fn(),
}))
import {
consumeOAuthState,
generateOtc,
getConsent,
ConsentNotFoundError,
} from '../lib/provider-client'
function useResults(results: QueryResult[]) {
const recording = createRecordingSupabase(results)
serviceClient = recording.supabase
return recording
}
describe('consumeOAuthState', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('consumes the state row with one conditional UPDATE, not a read then a write', async () => {
const { calls } = useResults([
{ data: { consent_id: 'consent-1' } },
{ data: { provider: 'fortnox' } },
])
await consumeOAuthState('state-token')
const otcCall = calls[0]
expect(otcCall.table).toBe('provider_otc')
// The very first thing done to the row is the UPDATE: there is no
// select-then-decide window for a concurrent replay to slip through.
expect(otcCall.ops[0][0]).toBe('update')
expect(findOp(otcCall, 'eq', 'code')?.[1]).toEqual(['code', 'state-token'])
// Single-use: the row is only claimed while it is still unclaimed.
expect(findOp(otcCall, 'is', 'used_at')?.[1]).toEqual(['used_at', null])
// Expiry is enforced in the same statement, not in JavaScript afterwards.
expect(findOp(otcCall, 'gt', 'expires_at')).toBeDefined()
expect(findOp(otcCall, 'select', 'consent_id')).toBeDefined()
})
it('returns the consent and the provider read from the server-side rows', async () => {
useResults([{ data: { consent_id: 'consent-1' } }, { data: { provider: 'visma' } }])
await expect(consumeOAuthState('state-token')).resolves.toEqual({
consentId: 'consent-1',
provider: 'visma',
})
})
it('reads the provider from provider_consents, never from the caller', async () => {
const { calls } = useResults([
{ data: { consent_id: 'consent-1' } },
{ data: { provider: 'fortnox' } },
])
await consumeOAuthState('state-token')
expect(calls[1].table).toBe('provider_consents')
expect(findOp(calls[1], 'eq', 'id')?.[1]).toEqual(['id', 'consent-1'])
})
it('returns null for an unknown or forged state token', async () => {
// No row matched the code: PostgREST answers with no representation.
useResults([{ data: null }])
await expect(consumeOAuthState('forged-token')).resolves.toBeNull()
})
it('returns null for an expired state token', async () => {
// The `expires_at > now` predicate excluded the row, so 0 rows updated.
useResults([{ data: null }])
await expect(consumeOAuthState('expired-token')).resolves.toBeNull()
})
it('returns null on replay: the second consume of the same token loses', async () => {
useResults([{ data: { consent_id: 'consent-1' } }, { data: { provider: 'fortnox' } }])
await expect(consumeOAuthState('one-time-token')).resolves.toEqual({
consentId: 'consent-1',
provider: 'fortnox',
})
// Replay: used_at is now set, so `is('used_at', null)` matches nothing.
useResults([{ data: null }])
await expect(consumeOAuthState('one-time-token')).resolves.toBeNull()
})
it('returns null when the consent behind a valid token is gone', async () => {
useResults([{ data: { consent_id: 'consent-1' } }, { data: null }])
await expect(consumeOAuthState('state-token')).resolves.toBeNull()
})
it('returns null when the update itself errors', async () => {
useResults([{ data: null, error: { message: 'boom' } }])
await expect(consumeOAuthState('state-token')).resolves.toBeNull()
})
})
describe('generateOtc', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('defaults the OAuth state lifetime to 10 minutes', async () => {
// The state only has to survive the round trip to the provider's login
// page. The old 60-minute default left a phished or leaked state
// consumable for an hour.
const { calls } = useResults([{ data: null }])
const before = Date.now()
const { expiresAt } = await generateOtc('consent-1')
const after = Date.now()
expect(calls[0].table).toBe('provider_otc')
const inserted = findOp(calls[0], 'insert')?.[1][0] as { consent_id: string; expires_at: string }
expect(inserted.consent_id).toBe('consent-1')
const tenMinutes = 10 * 60 * 1000
const expiry = new Date(expiresAt).getTime()
expect(expiry).toBeGreaterThanOrEqual(before + tenMinutes)
expect(expiry).toBeLessThanOrEqual(after + tenMinutes)
expect(new Date(inserted.expires_at).getTime()).toBe(expiry)
})
})
describe('getConsent', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('filters on the owning company', async () => {
const { calls } = useResults([
{ data: { id: 'consent-1', name: 'n', provider: 'fortnox', status: 1 } },
])
await getConsent('consent-1', 'company-1')
expect(calls[0].table).toBe('provider_consents')
expect(findOp(calls[0], 'eq', 'id')?.[1]).toEqual(['id', 'consent-1'])
expect(findOp(calls[0], 'eq', 'company_id')?.[1]).toEqual(['company_id', 'company-1'])
})
it('throws ConsentNotFoundError when the consent belongs to another company', async () => {
// The company_id predicate excluded the row: indistinguishable from a
// consent id that does not exist at all, which is the point.
useResults([{ data: null }])
await expect(getConsent('other-tenants-consent', 'company-1')).rejects.toBeInstanceOf(
ConsentNotFoundError,
)
})
it('leaks no consent state in the not-found error', async () => {
useResults([{ data: null }])
const error = await getConsent('other-tenants-consent', 'company-1').catch((e) => e)
expect(error).toBeInstanceOf(ConsentNotFoundError)
expect((error as Error).message).toBe('Consent not found')
expect((error as Error).message).not.toContain('other-tenants-consent')
})
it('returns the consent when the caller owns it', async () => {
useResults([
{
data: {
id: 'consent-1',
name: 'gnubok-migration-user-1',
provider: 'fortnox',
status: 1,
org_number: '5566778899',
company_name: 'Test AB',
},
},
])
await expect(getConsent('consent-1', 'company-1')).resolves.toEqual({
id: 'consent-1',
name: 'gnubok-migration-user-1',
provider: 'fortnox',
status: 1,
orgNumber: '5566778899',
companyName: 'Test AB',
})
})
})
+66 -37
View File
@@ -5,6 +5,7 @@ import {
getConsent,
listConsents,
generateOtc,
consumeOAuthState,
getAuthUrl,
exchangeAuthToken,
submitProviderToken,
@@ -24,7 +25,7 @@ import type { ArcimProvider } from './types'
import { ARCIM_PROVIDERS } from './types'
import { parseSIEFile, validateSIEFile } from '@/lib/import/sie-parser'
import { suggestMappings, getMappingStats, isSystemAccount } from '@/lib/import/account-mapper'
import { loadMappings, generateImportPreview, executeSIEImport, saveMappings } from '@/lib/import/sie-import'
import { loadMappings, generateImportPreview, executeSIEImport } from '@/lib/import/sie-import'
import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-reference'
import type { ProviderName } from '@/lib/providers/types'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
@@ -33,6 +34,15 @@ import { createLogger } from '@/lib/logger'
const moduleLog = createLogger('extensions/arcim-migration')
/**
* The one answer the unauthenticated OAuth callback gives for every state
* failure: forged, unknown, expired, replayed, or pointing at a consent that no
* longer exists. Distinguishing them would turn the callback into a probe for
* other tenants' consent ids.
*/
const STATE_REJECTED_MESSAGE =
'Ingen giltig migrationssession hittades. Starta om anslutningen.'
/**
* Map known OAuth error codes from providers (Fortnox, Visma) to actionable
* Swedish guidance. Falls back to the raw provider message so we never hide
@@ -64,6 +74,9 @@ function translateOAuthError(error: string, description: string | null): string
* refresh-token pair in place: no disconnect/recreate needed.
*/
async function buildArcimOAuthUrl(consentId: string, provider: ArcimProvider): Promise<string> {
// Server-side state row: consent id, provider (via the consent), expiry and a
// consumed marker all live in provider_otc. The `state` handed to the provider
// is that row's opaque random primary key, nothing more.
const otc = await generateOtc(consentId)
// Prefer a provider-specific redirect override (e.g. VISMA_REDIRECT_URI) when
@@ -82,11 +95,11 @@ async function buildArcimOAuthUrl(consentId: string, provider: ArcimProvider): P
? providerRedirectEnv
: `${appUrl}/api/extensions/ext/arcim-migration/callback`
// Encode consentId + provider in state so the callback rebinds to this consent
const statePayload = JSON.stringify({ otc: otc.code, consentId, provider })
const stateEncoded = Buffer.from(statePayload).toString('base64url')
const { url } = await getAuthUrl(provider, stateEncoded, callbackUrl)
// The state is the one-time code itself: an unguessable pointer to the row
// above. It deliberately encodes NOTHING. The previous base64url JSON payload
// was attacker-authored input the callback trusted, so anyone who learned a
// consent id could redirect their own provider tokens onto that consent.
const { url } = await getAuthUrl(provider, otc.code, callbackUrl)
return url
}
@@ -483,36 +496,28 @@ export const arcimMigrationExtension: Extension = {
}
try {
let consentId: string | null = null
let provider: ArcimProvider | null = null
// Single source of truth for who this callback belongs to: the
// server-written provider_otc row, consumed atomically here. The row
// carries the consent; the consent carries the provider. Nothing is
// read from the query string except the opaque state token itself, so
// an attacker who knows a victim's consent id still cannot steer their
// own provider tokens onto it.
const resolvedState = await consumeOAuthState(stateRaw)
try {
const decoded = JSON.parse(Buffer.from(stateRaw, 'base64url').toString())
if (decoded.consentId && decoded.provider) {
consentId = decoded.consentId
provider = decoded.provider as ArcimProvider
}
} catch {
// Legacy fallback
}
if (!consentId || !provider) {
consentId = ctx?.settings
? await ctx.settings.get<string>('consent_id')
: null
provider = ctx?.settings
? await ctx.settings.get<ArcimProvider>('provider')
: null
}
if (!consentId || !provider) {
log.error('OAuth callback could not resolve consent or provider', {
hasConsentId: !!consentId,
hasProvider: !!provider,
if (!resolvedState) {
// Unknown/forged state, expired state, replayed state and deleted
// consent all end here with the SAME message: this route is
// unauthenticated, so telling the caller which one it was would
// disclose whether a given consent exists.
log.error('OAuth callback state rejected', {
hasCode: !!code,
stateLength: stateRaw.length,
})
return respondWithError('Ingen aktiv migrationssession hittades. Starta om anslutningen.')
return respondWithError(STATE_REJECTED_MESSAGE)
}
const { consentId, provider } = resolvedState
const redirectUri = `${appUrl}/api/extensions/ext/arcim-migration/callback`
// Exchange OAuth code directly with the provider
@@ -566,7 +571,11 @@ export const arcimMigrationExtension: Extension = {
}
try {
const consent = await getConsent(consentId)
// Company-scoped read: the status below is returned to the caller, so
// an unscoped lookup would let any authenticated user probe another
// tenant's consent id for existence and connection state. A foreign
// consent throws ConsentNotFoundError, same as a nonexistent one.
const consent = await getConsent(consentId, companyId)
if (consent.status !== 0 && consent.status !== 1) {
return errorResponseFromCode('PROVIDER_CONSENT_NOT_READY', moduleLog, {
details: { consentId, status: consent.status },
@@ -635,6 +644,12 @@ export const arcimMigrationExtension: Extension = {
})
} catch (error) {
log.error('arcim preview failed', error as Error)
// Consent missing or owned by another company: same 404 either way.
if (error instanceof ConsentNotFoundError) {
return errorResponseFromCode('PROVIDER_CONSENT_NOT_FOUND', moduleLog, {
details: { consentId },
})
}
// Classify HTTP failures into typed codes so the toast can suggest
// reconnect / retry instead of a generic "preview failed".
const classified = classifyProviderError(error)
@@ -895,9 +910,13 @@ export const arcimMigrationExtension: Extension = {
// Account creation (and #KONTO renames) happen inside
// executeSIEImport via syncMappedAccounts: the auto-activate block
// that used to live here was a duplicate of that logic.
await saveMappings(supabase, user.id, mappings)
// that used to live here was a duplicate of that logic. Mapping
// persistence ALSO happens inside executeSIEImport, with the
// correct companyId + userId and non-fatal warning handling: the
// direct saveMappings call that used to sit here passed user.id in
// the companyId slot, which throws on RLS/FK now that the helper
// surfaces upsert failures, 500ing every provider-migration import
// before a single voucher was written. Do not re-add it.
const result = await executeSIEImport(supabase, companyId, user.id, parsed, mappings, {
filename: `migration-sie-${Date.now()}.se`,
fileContent: rawContent,
@@ -975,7 +994,11 @@ export const arcimMigrationExtension: Extension = {
}
try {
const consent = await getConsent(consentId)
// Company-scoped read: the status below is returned to the caller, so
// an unscoped lookup would let any authenticated user probe another
// tenant's consent id for existence and connection state. A foreign
// consent throws ConsentNotFoundError, same as a nonexistent one.
const consent = await getConsent(consentId, companyId)
if (consent.status !== 0 && consent.status !== 1) {
return errorResponseFromCode('PROVIDER_CONSENT_NOT_READY', moduleLog, {
details: { consentId, status: consent.status },
@@ -1033,6 +1056,12 @@ export const arcimMigrationExtension: Extension = {
return NextResponse.json({ success: true, results })
} catch (error) {
log.error('arcim migration failed', error as Error)
// Consent missing or owned by another company: same 404 either way.
if (error instanceof ConsentNotFoundError) {
return errorResponseFromCode('PROVIDER_CONSENT_NOT_FOUND', moduleLog, {
details: { consentId },
})
}
const classified = classifyProviderError(error)
return errorResponseFromCode(classified ?? 'PROVIDER_MIGRATE_FAILED', moduleLog, {
details: {
@@ -5,7 +5,9 @@
* provider into the exact shapes Accounted expects for database insertion.
*/
import type { CustomerType, SupplierType, VatTreatment } from '@/types'
import type { SupabaseClient } from '@supabase/supabase-js'
import { fetchExchangeRate } from '@/lib/currency/riksbanken'
import type { Currency, CustomerType, ExchangeRate, SupplierType, VatTreatment } from '@/types'
import type {
CustomerDto,
SupplierDto,
@@ -234,6 +236,187 @@ function inferVatRate(taxPercent?: number): number {
return 25 // Default to standard rate
}
// ── Currency conversion ─────────────────────────────────────────────
//
// The provider DTOs (lib/providers/dto.ts) carry NO exchange rate and NO SEK
// amount: an invoice exposes only `currencyCode` plus amounts already expressed
// in that currency. So for a foreign-currency document the SEK value has to be
// established here, at import, from the rate that was valid on the document's
// OWN date. An imported invoice is räkenskapsinformation (BFL 7 kap): its SEK
// value is part of the record, and stamping it with today's rate, or with a
// fabricated 1:1, would misstate it.
//
// Same pattern as lib/transactions/ingest.ts: pre-resolve the unique
// (currency, date) pairs once through fetchExchangeRate WITH the supabase
// client (so the shared `exchange_rates` cache absorbs repeat dates) and WITH
// the document date, then map synchronously against that index.
/**
* Currencies Riksbanken publishes a series for (SERIES_IDS in
* lib/currency/riksbanken.ts). A document in any other currency has no rate
* source at all, so it is reported rather than written with a silent null.
*/
const CONVERTIBLE_CURRENCIES: readonly Currency[] = ['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK']
/** Riksbanken fan-out bound, mirroring ingest.ts: a wide historical backfill
* used to fire every pair at once and get the whole batch rate-limited. */
const FX_FETCH_CONCURRENCY = 4
function asConvertibleCurrency(code: string | undefined | null): Currency | null {
if (!code) return null
const upper = code.toUpperCase() as Currency
return CONVERTIBLE_CURRENCIES.includes(upper) ? upper : null
}
/** Normalize a DTO date to a plain ISO day, or null when it isn't one. */
function isoDay(value: string | undefined | null): string | null {
if (!value || !/^\d{4}-\d{2}-\d{2}/.test(value)) return null
return value.slice(0, 10)
}
/** Pre-resolved rates, keyed by `${CURRENCY}|${YYYY-MM-DD}`. */
export type FxRateIndex = Map<string, ExchangeRate>
export function fxRateKey(currencyCode: string, isoDate: string): string {
return `${currencyCode.toUpperCase()}|${isoDate}`
}
/** Why a foreign-currency document could not be converted to SEK. */
export type FxUnresolvedReason =
/** Currency outside Riksbanken's published series: no rate source exists. */
| 'unsupported_currency'
/** Rate source exists but no observation could be obtained for that date. */
| 'rate_unavailable'
export interface FxUnresolved {
currency: string
/** The document's own date, i.e. the date a rate was needed for. */
date: string
reason: FxUnresolvedReason
}
interface FxResolution {
/** null for SEK documents (no rate applies) and for unconvertible ones. */
rate: number | null
/** Riksbanken observation date behind `rate`; null when there is no rate. */
rateDate: string | null
/** Factor to reach SEK: 1 for SEK documents, `rate` otherwise, null when
* the conversion could not be established at all. */
sekFactor: number | null
/** Set ONLY when a foreign document could not be converted. */
unresolved: FxUnresolved | null
}
/**
* Fetch the rate valid on each document's own date, once per unique
* (currency, date) pair. SEK documents need no rate and are skipped.
*
* A pair that cannot be fetched is simply absent from the index; the mapper
* then reports that document as unresolved rather than inventing a number.
*/
export async function buildFxRateIndex(
supabase: SupabaseClient,
documents: { currencyCode?: string; issueDate?: string }[],
): Promise<FxRateIndex> {
const index: FxRateIndex = new Map()
const pairs = new Map<string, { currency: Currency; date: string }>()
for (const doc of documents) {
const currency = asConvertibleCurrency(doc.currencyCode)
if (!currency || currency === 'SEK') continue
const date = isoDay(doc.issueDate)
if (!date) continue
const key = fxRateKey(currency, date)
if (!pairs.has(key)) pairs.set(key, { currency, date })
}
if (pairs.size === 0) return index
const entries = [...pairs.entries()]
for (let i = 0; i < entries.length; i += FX_FETCH_CONCURRENCY) {
const slice = entries.slice(i, i + FX_FETCH_CONCURRENCY)
const settled = await Promise.allSettled(
slice.map(([, { currency, date }]) => fetchExchangeRate(currency, new Date(date), supabase)),
)
for (let j = 0; j < slice.length; j++) {
const outcome = settled[j]
if (outcome.status === 'fulfilled' && outcome.value && outcome.value.rate > 0) {
index.set(slice[j][0], outcome.value)
}
// A miss deliberately leaves the key unset: never a made-up rate.
}
}
return index
}
/**
* Resolve the SEK conversion for one document.
*
* `rates` is optional so existing callers keep compiling; when it is omitted a
* foreign document resolves to `rate_unavailable` (reported), never to a
* silent 1:1. Only an actually-fetched positive rate produces a conversion.
*/
function resolveFx(
currencyCode: string | undefined,
issueDate: string | undefined,
rates?: FxRateIndex,
): FxResolution {
const code = (currencyCode || 'SEK').toUpperCase()
if (code === 'SEK') {
// Domestic document: the ledger currency IS SEK, so there is no exchange
// rate to record. A plain null, not a conversion we failed to make. The
// SEK amount columns still get filled, via sekFactor 1.
return { rate: null, rateDate: null, sekFactor: 1, unresolved: null }
}
const currency = asConvertibleCurrency(code)
if (!currency) {
return {
rate: null, rateDate: null, sekFactor: null,
unresolved: { currency: code, date: isoDay(issueDate) ?? '', reason: 'unsupported_currency' },
}
}
const date = isoDay(issueDate)
if (!date) {
return {
rate: null, rateDate: null, sekFactor: null,
unresolved: { currency: code, date: issueDate ?? '', reason: 'rate_unavailable' },
}
}
const hit = rates?.get(fxRateKey(currency, date))
if (!hit || !(hit.rate > 0)) {
return {
rate: null, rateDate: null, sekFactor: null,
unresolved: { currency: code, date, reason: 'rate_unavailable' },
}
}
return { rate: hit.rate, rateDate: hit.date, sekFactor: hit.rate, unresolved: null }
}
/** Convert to SEK, or null when no conversion could be established. */
function toSek(amount: number, sekFactor: number | null): number | null {
return sekFactor === null ? null : round2(amount * sekFactor)
}
/**
* A mapped invoice plus the FX verdict for it. `fxUnresolved` is non-null only
* for a FOREIGN document whose SEK value could not be established: the caller
* must surface those as needing attention instead of letting them pass as
* ordinary imports. They are still imported (dropping them would lose
* räkenskapsinformation) but carry exchange_rate = null, so every booking path
* refuses them loudly (SupplierInvoiceFxRateMissingError /
* InvoiceBookingRateMissingError) rather than posting at a fabricated 1:1.
*/
export interface MappedInvoice {
invoice: Record<string, unknown>
items: Record<string, unknown>[]
fxUnresolved: FxUnresolved | null
}
// ── Public mappers ──────────────────────────────────────────────────
export function mapCustomer(dto: CustomerDto, userId: string, companyId: string): Record<string, unknown> {
@@ -278,6 +461,9 @@ export function mapSupplier(dto: SupplierDto, userId: string, companyId: string)
bankgiro: dto.bankGiro || null,
plusgiro: dto.plusGiro || null,
bank_account: dto.bankAccount || null,
// SupplierDto carries bankAccount/bankGiro/plusGiro only: it has no IBAN,
// BIC or default expense account, so these are honest nulls, not dropped
// data. The user fills them in when a foreign payment first needs them.
iban: null,
bic: null,
default_expense_account: null,
@@ -291,8 +477,9 @@ export function mapSalesInvoice(
dto: SalesInvoiceDto,
userId: string,
companyId: string,
customerId: string
): { invoice: Record<string, unknown>; items: Record<string, unknown>[] } {
customerId: string,
fxRates?: FxRateIndex
): MappedInvoice {
const subtotal = round2(dto.legalMonetaryTotal.lineExtensionAmount.value)
const total = round2(dto.legalMonetaryTotal.payableAmount.value)
const vatAmount = round2(dto.taxTotal?.taxAmount.value ?? (total - subtotal))
@@ -314,6 +501,9 @@ export function mapSalesInvoice(
const isCreditNote = dto.invoiceTypeCode === '381'
// SEK value of a foreign invoice, at the rate valid on its own issue date.
const fx = resolveFx(dto.currencyCode, dto.issueDate, fxRates)
const invoice: Record<string, unknown> = {
user_id: userId,
company_id: companyId,
@@ -323,13 +513,16 @@ export function mapSalesInvoice(
due_date: dto.dueDate || dto.issueDate,
status: statusMap[dto.status] || 'sent',
currency: dto.currencyCode || 'SEK',
exchange_rate: dto.currencyCode === 'SEK' ? null : null,
// null for a SEK invoice (no rate applies) and for a foreign invoice whose
// rate could not be established: that case is reported via fxUnresolved.
exchange_rate: fx.rate,
exchange_rate_date: fx.rateDate,
subtotal,
subtotal_sek: dto.currencyCode === 'SEK' ? subtotal : null,
subtotal_sek: toSek(subtotal, fx.sekFactor),
vat_amount: vatAmount,
vat_amount_sek: dto.currencyCode === 'SEK' ? vatAmount : null,
vat_amount_sek: toSek(vatAmount, fx.sekFactor),
total,
total_sek: dto.currencyCode === 'SEK' ? total : null,
total_sek: toSek(total, fx.sekFactor),
vat_treatment: vatTreatment,
vat_rate: inferVatRate(primaryTaxPercent),
your_reference: null,
@@ -342,7 +535,7 @@ export function mapSalesInvoice(
const items = dto.lines.map((line, idx) => mapSalesInvoiceLine(line, idx))
return { invoice, items }
return { invoice, items, fxUnresolved: fx.unresolved }
}
function mapSalesInvoiceLine(line: SalesInvoiceLineDto, index: number): Record<string, unknown> {
@@ -362,8 +555,9 @@ export function mapSupplierInvoice(
dto: SupplierInvoiceDto,
userId: string,
companyId: string,
supplierId: string
): { invoice: Record<string, unknown>; items: Record<string, unknown>[] } {
supplierId: string,
fxRates?: FxRateIndex
): MappedInvoice {
const subtotal = round2(dto.legalMonetaryTotal.lineExtensionAmount.value)
const total = round2(dto.legalMonetaryTotal.payableAmount.value)
const vatAmount = round2(dto.taxTotal?.taxAmount.value ?? (total - subtotal))
@@ -415,6 +609,9 @@ export function mapSupplierInvoice(
resolvedStatus = mappedStatus
}
// SEK value of a foreign invoice, at the rate valid on its own issue date.
const fx = resolveFx(dto.currencyCode, dto.issueDate, fxRates)
const invoice: Record<string, unknown> = {
user_id: userId,
company_id: companyId,
@@ -426,13 +623,16 @@ export function mapSupplierInvoice(
delivery_date: dto.deliveryDate || null,
status: resolvedStatus,
currency: dto.currencyCode || 'SEK',
exchange_rate: dto.currencyCode === 'SEK' ? null : null,
// null for a SEK invoice (no rate applies) and for a foreign invoice whose
// rate could not be established: that case is reported via fxUnresolved.
exchange_rate: fx.rate,
exchange_rate_date: fx.rateDate,
subtotal,
subtotal_sek: dto.currencyCode === 'SEK' ? subtotal : null,
subtotal_sek: toSek(subtotal, fx.sekFactor),
vat_amount: vatAmount,
vat_amount_sek: dto.currencyCode === 'SEK' ? vatAmount : null,
vat_amount_sek: toSek(vatAmount, fx.sekFactor),
total,
total_sek: dto.currencyCode === 'SEK' ? total : null,
total_sek: toSek(total, fx.sekFactor),
vat_treatment: vatTreatment,
reverse_charge: vatTreatment === 'reverse_charge',
payment_reference: dto.ocrNumber || null,
@@ -447,7 +647,7 @@ export function mapSupplierInvoice(
const items = dto.lines.map((line, idx) => mapSupplierInvoiceLine(line, idx))
return { invoice, items }
return { invoice, items, fxUnresolved: fx.unresolved }
}
function mapSupplierInvoiceLine(line: SupplierInvoiceLineDto, index: number): Record<string, unknown> {
@@ -39,6 +39,8 @@ import {
mapSupplierInvoice,
mapCompanyInfo,
inferTypeFromParty,
buildFxRateIndex,
type FxUnresolved,
} from './entity-mapper'
export interface MigrationOptions {
@@ -80,6 +82,22 @@ function getOrgNumberFromParty(party: PartyDto): string | null {
)
}
/**
* Log a foreign-currency document that was imported WITHOUT a SEK conversion.
*
* It is still imported (dropping it would lose räkenskapsinformation), but it
* carries exchange_rate = null, so every booking path refuses it loudly rather
* than posting it as if 1 unit = 1 SEK. Counted into the step's result so the
* migration reports it instead of passing it off as an ordinary import.
*/
function logFxUnresolved(kind: string, invoiceNumber: string, fx: FxUnresolved): void {
console.error(
`[migration] ${kind} ${invoiceNumber}: imported without a SEK conversion ` +
`(${fx.currency} @ ${fx.date || 'okänt datum'}, reason=${fx.reason}). ` +
`Set an exchange rate before booking it.`
)
}
// ── Main orchestrator ─────────────────────────────────────────────
export async function executeMigration(options: MigrationOptions): Promise<MigrationResults> {
@@ -460,10 +478,21 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
return !!r.customerId
})
// Phase B2: resolve the SEK conversion for every foreign-currency
// invoice, at the rate valid on its OWN issue date. The provider DTO
// carries no rate and no SEK amount, so without this every foreign
// invoice lands unconverted. One pass over the whole step (not per
// chunk) so repeat (currency, date) pairs are fetched once.
const fxRates = await buildFxRateIndex(
supabase,
ready.map((r) => ({ currencyCode: r.dto.currencyCode, issueDate: r.dto.issueDate }))
)
let fxUnresolved = 0
// Phase C: chunk-insert invoices + their line items.
for (const batch of chunk(ready, INSERT_CHUNK_SIZE)) {
const mappedBatch = batch.map((r) => ({
...mapSalesInvoice(r.dto, userId, companyId, r.customerId),
...mapSalesInvoice(r.dto, userId, companyId, r.customerId, fxRates),
dto: r.dto,
}))
@@ -486,6 +515,11 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
for (const item of mappedBatch[i].items) {
allItems.push({ ...item, invoice_id: invoiceId })
}
const fx = mappedBatch[i].fxUnresolved
if (fx) {
fxUnresolved++
logFxUnresolved('Sales invoice', mappedBatch[i].dto.invoiceNumber, fx)
}
imported++
}
@@ -499,7 +533,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
}
}
results.salesInvoices = { total: invoices.length, imported, skipped, skipReasons }
results.salesInvoices = { total: invoices.length, imported, skipped, skipReasons, fxUnresolved }
} catch (err) {
console.error('Failed to import sales invoices:', err)
}
@@ -653,11 +687,21 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
return true
})
// Resolve the SEK conversion for every foreign-currency invoice at the
// rate valid on its OWN issue date (see the sales-invoice step).
const fxRates = await buildFxRateIndex(
supabase,
ready.map((r) => ({ currencyCode: r.dto.currencyCode, issueDate: r.dto.issueDate }))
)
let fxUnresolved = 0
for (const batch of chunk(ready, INSERT_CHUNK_SIZE)) {
const mappedBatch = batch.map((r) => {
const { invoice, items } = mapSupplierInvoice(r.dto, userId, companyId, r.supplierId)
const { invoice, items, fxUnresolved: fx } = mapSupplierInvoice(
r.dto, userId, companyId, r.supplierId, fxRates
)
invoice.arrival_number = nextArrivalNumber++
return { invoice, items, dto: r.dto }
return { invoice, items, fxUnresolved: fx, dto: r.dto }
})
const { data: insertedInvoices, error: invErr } = await supabase
@@ -681,6 +725,11 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
for (const item of mappedBatch[i].items) {
allItems.push({ ...item, supplier_invoice_id: invoiceId })
}
const fx = mappedBatch[i].fxUnresolved
if (fx) {
fxUnresolved++
logFxUnresolved('Supplier invoice', mappedBatch[i].dto.invoiceNumber, fx)
}
imported++
}
@@ -694,7 +743,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
}
}
results.supplierInvoices = { total: invoices.length, imported, skipped, skipReasons }
results.supplierInvoices = { total: invoices.length, imported, skipped, skipReasons, fxUnresolved }
} catch (err) {
console.error('Failed to import supplier invoices:', err)
}
@@ -6,6 +6,7 @@
* data fetching to the provider clients in lib/providers/.
*/
import { randomBytes } from 'node:crypto'
import { createServiceClient } from '@/lib/supabase/server'
import type { ProviderName } from '@/lib/providers/types'
import { getOAuthConfig } from '@/lib/providers/oauth-config'
@@ -121,17 +122,32 @@ export async function listConsents(companyId: string): Promise<ConsentRecord[]>
}))
}
export async function getConsent(consentId: string): Promise<ConsentRecord> {
/**
* Read a consent, scoped to the company that owns it.
*
* `ownerCompanyId` is mandatory: this module runs on the service client, which
* bypasses RLS, so the predicate below is the only tenant boundary. Without it
* any authenticated user could pass a foreign consent id and read back its
* status/provider/company name, i.e. a cross-tenant existence oracle. A consent
* that exists but belongs to another company throws the same
* ConsentNotFoundError as one that does not exist. Mirrors the guard in
* submitProviderToken() and resolveConsent().
*/
export async function getConsent(
consentId: string,
ownerCompanyId: string,
): Promise<ConsentRecord> {
const supabase = createServiceClient()
const { data, error } = await supabase
.from('provider_consents')
.select('*')
.eq('id', consentId)
.single()
.eq('company_id', ownerCompanyId)
.maybeSingle()
if (error || !data) {
throw new Error(`Consent not found: ${error?.message}`)
throw new ConsentNotFoundError()
}
return {
@@ -161,13 +177,28 @@ export async function deleteConsent(consentId: string): Promise<void> {
}
}
/**
* Mint a one-time code bound to a consent.
*
* The code doubles as the OAuth `state` parameter: it is an opaque random
* identifier for a server-written row, and the callback resolves the consent
* from that row rather than from anything the browser carried. 32 random bytes
* (not the previous 16 hex chars of a UUID) because this value is now the only
* thing standing between an attacker and binding their provider account to
* someone else's consent.
*
* Default lifetime is 10 minutes: the state only has to survive the redirect
* to the provider's login page and back. OAuth guidance puts state/OTC
* lifetimes at 5-10 minutes; every extra minute widens the window in which a
* leaked or phished state can still be consumed.
*/
export async function generateOtc(
consentId: string,
expiresInMinutes: number = 60,
expiresInMinutes: number = 10,
): Promise<OtcResponse> {
const supabase = createServiceClient()
const code = crypto.randomUUID().replace(/-/g, '').slice(0, 16)
const code = randomBytes(32).toString('base64url')
const expiresAt = new Date(Date.now() + expiresInMinutes * 60 * 1000).toISOString()
const { error } = await supabase
@@ -185,6 +216,57 @@ export async function generateOtc(
return { code, consentId, expiresAt }
}
/**
* Atomically consume an OAuth `state` token and resolve what it was minted for.
*
* The single UPDATE is the entire check: `used_at IS NULL` and
* `expires_at > now` sit in the WHERE clause, so a replayed callback loses the
* row-lock race (under READ COMMITTED the second statement re-evaluates the
* predicate after the first commits, matches nothing, and updates 0 rows).
* There is no read-then-write window to exploit.
*
* The provider is read from the consent row (written server-side at connect
* time), never from the callback query string.
*
* Returns null for every failure mode: unknown/forged state, expired state,
* already-consumed state, deleted consent. Callers must not distinguish them,
* that distinction is exactly the oracle this function exists to remove.
*/
export async function consumeOAuthState(
state: string,
): Promise<{ consentId: string; provider: ProviderName } | null> {
const supabase = createServiceClient()
const now = new Date().toISOString()
const { data: consumed, error } = await supabase
.from('provider_otc')
.update({ used_at: now })
.eq('code', state)
.is('used_at', null)
.gt('expires_at', now)
.select('consent_id')
.maybeSingle()
if (error || !consumed?.consent_id) {
return null
}
const { data: consent } = await supabase
.from('provider_consents')
.select('provider')
.eq('id', consumed.consent_id)
.maybeSingle()
if (!consent?.provider) {
return null
}
return {
consentId: consumed.consent_id as string,
provider: consent.provider as ProviderName,
}
}
// ── OAuth helpers (direct provider calls) ───────────────────────────
export async function getAuthUrl(
+11 -2
View File
@@ -63,12 +63,21 @@ export interface SkipReasons {
noMatch?: number
}
/**
* Foreign-currency invoices that were imported but whose SEK value could not
* be established (currency outside Riksbanken's series, or no observation for
* the invoice's own date). They are counted in `imported`: the record itself is
* räkenskapsinformation and dropping it would lose data. But they carry
* exchange_rate = null, so every booking path refuses them until a rate is
* set, and the migration reports them here instead of passing them off as
* ordinary imports. Per-invoice detail goes to the server log.
*/
export interface MigrationResults {
companyInfo?: { imported: boolean }
customers?: { total: number; imported: number; skipped: number; skipReasons?: SkipReasons }
suppliers?: { total: number; imported: number; skipped: number; skipReasons?: SkipReasons }
salesInvoices?: { total: number; imported: number; skipped: number; skipReasons?: SkipReasons }
supplierInvoices?: { total: number; imported: number; skipped: number; skipReasons?: SkipReasons }
salesInvoices?: { total: number; imported: number; skipped: number; skipReasons?: SkipReasons; fxUnresolved?: number }
supplierInvoices?: { total: number; imported: number; skipped: number; skipReasons?: SkipReasons; fxUnresolved?: number }
/**
* Auto-reconciliation of imported supplier invoices to the GL payment
* vouchers that the separate SIE import already posted. `autoLinked` invoices
@@ -1,4 +1,4 @@
import { describe, it, expect, vi } from 'vitest'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { invoiceInboxExtension } from '@/extensions/general/invoice-inbox'
import {
createQueuedMockSupabase,
@@ -14,6 +14,15 @@ vi.mock('@/lib/bookkeeping/supplier-invoice-entries', () => ({
createSupplierInvoiceRegistrationEntry: vi.fn().mockResolvedValue({ id: 'je-1' }),
}))
// Riksbanken backs the server-side rate lookup the convert route now performs.
// Spread the real module so nothing else importing from it breaks.
const mockFetchExchangeRate = vi.fn()
vi.mock('@/lib/currency/riksbanken', async () => {
const actual =
await vi.importActual<typeof import('@/lib/currency/riksbanken')>('@/lib/currency/riksbanken')
return { ...actual, fetchExchangeRate: (...args: unknown[]) => mockFetchExchangeRate(...args) }
})
// ── Helpers ──────────────────────────────────────────────────
function findRoute(method: string, path: string) {
@@ -259,6 +268,161 @@ describe('POST /items/:id/convert', () => {
})
})
// ── Exchange rate + SEK amounts on convert ───────────────────
// The queued Supabase mock is a bare Proxy, so the insert payload has to be
// recorded to be asserted: the route echoes back the enqueued fixture row.
type InsertRecord = { table: string; payload: Record<string, unknown> }
function wrapCapturing(chain: unknown, table: string, sink: InsertRecord[]): unknown {
return new Proxy(
{},
{
get(_target, prop) {
const inner = (chain as Record<string | symbol, unknown>)[prop as string]
if (prop === 'then') return inner
return (...args: unknown[]) => {
if (
prop === 'insert' &&
args[0] &&
typeof args[0] === 'object' &&
!Array.isArray(args[0])
) {
sink.push({ table, payload: args[0] as Record<string, unknown> })
}
return wrapCapturing((inner as (...a: unknown[]) => unknown)(...args), table, sink)
}
},
},
)
}
describe('POST /items/:id/convert: exchange rate + SEK amounts', () => {
const route = findRoute('POST', '/items/:id/convert')
beforeEach(() => {
mockFetchExchangeRate.mockReset()
})
/**
* Queued mock with insert capture, wired for the convert happy path:
* inbox item → supplier → arrival number → invoice insert → items insert →
* company_settings → inbox-item update.
*/
function setup() {
const { supabase, enqueue } = createQueuedMockSupabase()
const captured: InsertRecord[] = []
const baseFrom = supabase.from.getMockImplementation() as (...a: unknown[]) => unknown
supabase.from.mockImplementation((table: string) =>
wrapCapturing(baseFrom(table), table, captured),
)
return { supabase, enqueue, captured }
}
function enqueueHappyPath(enqueue: (r: { data?: unknown; error?: unknown }) => void) {
enqueue({ data: makeInvoiceInboxItem({ status: 'received' }) })
enqueue({ data: makeSupplier({ id: SUPPLIER_UUID }) })
enqueue({ data: 42 })
enqueue({ data: { id: 'invoice-fx', status: 'registered' } })
enqueue({ data: [], error: null })
enqueue({ data: makeCompanySettings({ accounting_method: 'cash' }) })
enqueue({ data: null, error: null })
}
function request(overrides: Record<string, unknown> = {}) {
return createMockRequest('/items/item-1/convert', {
method: 'POST',
body: { ...VALID_CONVERT_BODY, ...overrides },
searchParams: { _id: 'item-1' },
})
}
const siInsert = (captured: InsertRecord[]) =>
captured.find((c) => c.table === 'supplier_invoices')?.payload
it('populates total_sek for a SEK invoice and never asks for a rate', async () => {
const { supabase, enqueue, captured } = setup()
enqueueHappyPath(enqueue)
const res = await route.handler(request(), buildCtx(supabase))
const { status } = await parseJsonResponse(res)
expect(status).toBe(200)
const payload = siInsert(captured)
expect(payload).toBeDefined()
// 10 000 + 25 % = 12 500. total_sek used to stay NULL because the writer
// gated it on an exchange rate, which a SEK invoice never has.
expect(payload!.subtotal_sek).toBe(10000)
expect(payload!.vat_amount_sek).toBe(2500)
expect(payload!.total_sek).toBe(12500)
expect(payload!.total_sek).toBe(payload!.total)
expect(payload!.exchange_rate).toBeNull()
expect(payload!.exchange_rate_date).toBeNull()
expect(mockFetchExchangeRate).not.toHaveBeenCalled()
})
it('honours a caller-supplied rate on a foreign invoice', async () => {
const { supabase, enqueue, captured } = setup()
enqueueHappyPath(enqueue)
const res = await route.handler(
request({ currency: 'EUR', exchange_rate: 11.5 }),
buildCtx(supabase),
)
const { status } = await parseJsonResponse(res)
expect(status).toBe(200)
const payload = siInsert(captured)
expect(payload!.currency).toBe('EUR')
expect(payload!.exchange_rate).toBe(11.5)
expect(payload!.total_sek).toBe(143750)
expect(mockFetchExchangeRate).not.toHaveBeenCalled()
})
it('fetches the invoice-date rate when the extracted invoice carries none', async () => {
const { supabase, enqueue, captured } = setup()
enqueueHappyPath(enqueue)
mockFetchExchangeRate.mockResolvedValue({ currency: 'EUR', rate: 11.2, date: '2024-06-14' })
const res = await route.handler(request({ currency: 'EUR' }), buildCtx(supabase))
const { status } = await parseJsonResponse(res)
expect(status).toBe(200)
expect(mockFetchExchangeRate).toHaveBeenCalledTimes(1)
const [currencyArg, dateArg, clientArg] = mockFetchExchangeRate.mock.calls[0]
expect(currencyArg).toBe('EUR')
expect((dateArg as Date).toISOString().slice(0, 10)).toBe('2024-06-15')
// The supabase client must reach fetchExchangeRate or the shared
// exchange_rates cache is never consulted.
expect(clientArg).toBe(supabase)
const payload = siInsert(captured)
expect(payload!.exchange_rate).toBe(11.2)
expect(payload!.exchange_rate_date).toBe('2024-06-14')
expect(payload!.total_sek).toBe(140000)
})
it('refuses the conversion with SI_FX_RATE_MISSING when no rate can be resolved', async () => {
const { supabase, enqueue, captured } = setup()
enqueue({ data: makeInvoiceInboxItem({ status: 'received' }) })
enqueue({ data: makeSupplier({ id: SUPPLIER_UUID }) })
mockFetchExchangeRate.mockResolvedValue(null)
const res = await route.handler(request({ currency: 'USD' }), buildCtx(supabase))
const { status, body } = await parseJsonResponse<{
error: { code: string; details?: { currency?: string } }
}>(res)
expect(status).toBe(400)
expect(body.error.code).toBe('SI_FX_RATE_MISSING')
expect(body.error.details?.currency).toBe('USD')
// Nothing written, no ankomstnummer burned: the inbox item stays
// convertible once a rate is available or typed in.
expect(siInsert(captured)).toBeUndefined()
expect(supabase.rpc).not.toHaveBeenCalled()
})
})
// ── DELETE /items/:id ────────────────────────────────────────
describe('DELETE /items/:id', () => {
+42 -11
View File
@@ -33,6 +33,11 @@ import { suggestBalanceAccount } from '@/lib/bookkeeping/accruals/account-sugges
import { createJournalEntry } from '@/lib/bookkeeping/engine'
import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import {
resolveSupplierInvoiceExchangeRate,
supplierInvoiceSekAmounts,
} from '@/lib/currency/supplier-invoice-rate'
import { roundOre } from '@/lib/money'
import { linkToJournalEntry } from '@/lib/core/documents/document-service'
import { CreateSupplierInvoiceSchema, BookInboxItemDirectlySchema, BulkBookInboxSchema } from '@/lib/api/schemas'
import { bulkBookMatchedInboxItems } from '@/lib/transactions/categorize-core'
@@ -1896,6 +1901,25 @@ export const invoiceInboxExtension: Extension = {
}
}
// Same currency policy as POST /api/supplier-invoices and the v1 REST
// route (lib/currency/supplier-invoice-rate.ts): a non-SEK invoice with
// no caller-supplied rate gets one fetched from Riksbanken for the
// invoice date, and an unresolvable rate refuses the conversion instead
// of writing exchange_rate = NULL. That matters most here: inbox items
// are AI-extracted, the currency comes off the PDF and the rate never
// does, so this path produced unconverted rows most readily. Resolved
// before the arrival number so a refusal burns no ankomstnummer.
const fx = await resolveSupplierInvoiceExchangeRate(ctx.supabase, {
currency: body.currency,
invoiceDate: body.invoice_date,
suppliedRate: body.exchange_rate,
})
if (!fx.ok) {
return errorResponseFromCode('SI_FX_RATE_MISSING', ctx.log, {
details: { currency: fx.currency, invoice_date: fx.invoiceDate },
})
}
const { data: arrivalNum, error: arrivalError } = await ctx.supabase
.rpc('get_next_arrival_number', { p_company_id: ctx.companyId })
@@ -1943,12 +1967,16 @@ export const invoiceInboxExtension: Extension = {
const subtotal = items.reduce((sum, i) => sum + i.line_total, 0)
const totalVat = items.reduce((sum, i) => sum + i.vat_amount, 0)
const total = Math.round((subtotal + totalVat) * 100) / 100
// roundOre, not the naive form: `total` and `total_sek` must round
// identically or a SEK invoice ends up one öre apart.
const total = roundOre(subtotal + totalVat)
const exchangeRate = body.exchange_rate || null
const subtotalSek = exchangeRate ? Math.round(subtotal * exchangeRate * 100) / 100 : null
const vatAmountSek = exchangeRate ? Math.round(totalVat * exchangeRate * 100) / 100 : null
const totalSek = exchangeRate ? Math.round(total * exchangeRate * 100) / 100 : null
// SEK resolves to rate 1, so total_sek === total rather than NULL.
const {
subtotal_sek: subtotalSek,
vat_amount_sek: vatAmountSek,
total_sek: totalSek,
} = supplierInvoiceSekAmounts(fx.rate, { subtotal, vatAmount: totalVat, total })
const { data: invoice, error: invoiceError } = await ctx.supabase
.from('supplier_invoices')
@@ -1962,18 +1990,21 @@ export const invoiceInboxExtension: Extension = {
due_date: body.due_date,
delivery_date: body.delivery_date || null,
status: 'registered',
currency: body.currency || 'SEK',
exchange_rate: exchangeRate,
currency: fx.rate.currency,
exchange_rate: fx.rate.exchangeRate,
// Which day's kurs the SEK amounts were translated at: the audit
// trail that makes them verifiable (BFL 5 kap).
exchange_rate_date: fx.rate.exchangeRateDate,
vat_treatment: body.vat_treatment || 'standard_25',
reverse_charge: body.reverse_charge || false,
payment_reference: body.payment_reference || null,
subtotal: Math.round(subtotal * 100) / 100,
subtotal: roundOre(subtotal),
subtotal_sek: subtotalSek,
vat_amount: Math.round(totalVat * 100) / 100,
vat_amount: roundOre(totalVat),
vat_amount_sek: vatAmountSek,
total: Math.round(total * 100) / 100,
total,
total_sek: totalSek,
remaining_amount: Math.round(total * 100) / 100,
remaining_amount: total,
document_id: item.document_id || null,
notes: body.notes || null,
})
@@ -0,0 +1,164 @@
/**
* gnubok_categorize_transaction: the duplicate-guard refusal message.
*
* The MCP surface used to build its own two-branch claim with a raw
* `${dup.amount} kr` interpolation: a rateless foreign sibling (the
* `dup.amount === null` shape detectBookingDuplicate produces) rendered as
* "bokför null kr ...", and the unverified branch blamed the missing rate on
* the wrong row. The tool now shares the three-branch claim builder with the
* web surface (`buildDuplicateBookingClaim` in
* lib/transactions/categorize-core.ts); these tests pin the MCP side of that
* contract. Companion suites:
* - lib/transactions/__tests__/categorize-core.duplicate-message.test.ts
* (web/agent consumer + the builder itself)
* - lib/transactions/__tests__/booking-duplicate-detection.test.ts
* (producer of the candidate shapes)
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
const mockDetectDup = vi.fn()
vi.mock('@/lib/transactions/booking-duplicate-detection', () => ({
detectBookingDuplicate: (...args: unknown[]) => mockDetectDup(...args),
}))
import { tools } from '../server'
const categorize = tools.find((t) => t.name === 'gnubok_categorize_transaction')!
/** Same sv-SE prose formatting the shared claim builder uses. */
const sv = (n: number) =>
Math.abs(n).toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
const TX_ID = '00000000-0000-4000-8000-0000000000aa'
/** `transactions` row for categorizeTransactionCore's select('*'). Synthetic. */
const coreTxRow = (over: Record<string, unknown> = {}) => ({
id: TX_ID,
date: '2026-06-01',
amount: -1000,
currency: 'EUR',
amount_sek: null,
exchange_rate: null,
description: 'FIGMA',
merchant_name: null,
cash_account_id: null,
document_id: null,
journal_entry_id: null,
is_business: true,
...over,
})
/** The narrower projection the tool itself re-fetches for the guard + title. */
const guardTxRow = (over: Record<string, unknown> = {}) => ({
description: 'FIGMA',
merchant_name: null,
amount: -1000,
currency: 'EUR',
amount_sek: null,
exchange_rate: null,
date: '2026-06-01',
cash_account_id: null,
...over,
})
/** Detector candidate with the invariant defaults (SEK, verified). */
const candidate = (over: Record<string, unknown> = {}) => ({
transaction_id: 'sib-1',
journal_entry_id: 'je-1',
voucher_label: 'A142',
entry_date: '2026-06-01',
description: 'FIGMA',
amount: -11500,
account_number: null,
currency: null,
amount_in_currency: null,
amount_verified: true,
unverified_reason: null,
...over,
})
async function runCategorize(supabase: unknown): Promise<Error> {
try {
await categorize.execute(
{ transaction_id: TX_ID, category: 'expense_software' },
'company-1',
'user-1',
supabase as never,
{ type: 'api_key' },
)
} catch (err) {
return err as Error
}
throw new Error('expected the duplicate guard to throw')
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('gnubok_categorize_transaction duplicate-guard message', () => {
it('states the foreign amount, never "null kr", for a rateless foreign sibling', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: coreTxRow(), error: null }) // core: transactions
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null }) // core: settings
enqueue({ data: guardTxRow(), error: null }) // tool: transactions re-fetch
mockDetectDup.mockResolvedValue(
candidate({
amount: null,
currency: 'EUR',
amount_in_currency: -1000,
amount_verified: false,
unverified_reason: 'transaction_missing_sek_value',
}),
)
const err = await runCategorize(supabase)
expect(err.message).toContain('Möjlig dubblettbokföring: verifikat A142')
expect(err.message).toContain(`samma belopp (${sv(1000)} EUR)`)
expect(err.message).toContain('växelkurs saknas')
expect(err.message).not.toContain('null kr')
expect(err.message).not.toContain('null')
})
it('prints the verified candidate SEK figure as a magnitude with sv-SE formatting', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: coreTxRow({ amount: -1616, currency: 'SEK' }), error: null })
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null })
enqueue({ data: guardTxRow({ amount: -1616, currency: 'SEK' }), error: null })
mockDetectDup.mockResolvedValue(candidate({ amount: -1616 }))
const err = await runCategorize(supabase)
expect(err.message).toContain(`bokför redan ${sv(1616)} kr på bankkontot`)
// Magnitude only: no minus sign, no raw JS number wearing a kr label.
expect(err.message).not.toContain('-1616')
expect(err.message).not.toContain('1616 kr')
})
it('attributes the missing rate to the TARGET transaction in the unverified ledger branch', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: coreTxRow({ amount: -8570.87 }), error: null })
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null })
enqueue({ data: guardTxRow({ amount: -8570.87 }), error: null })
mockDetectDup.mockResolvedValue(
candidate({
transaction_id: null,
amount: 98565,
account_number: '1930',
amount_verified: false,
unverified_reason: 'transaction_missing_sek_value',
}),
)
const err = await runCategorize(supabase)
expect(err.message).toContain(`bokför ${sv(98565)} kr på bankkontot`)
expect(err.message).toContain('beloppen kunde inte jämföras')
expect(err.message).toContain('transaktionen är i EUR utan växelkurs')
})
})
@@ -0,0 +1,262 @@
import { describe, it, expect } from 'vitest'
import { companyCurrentResource } from '../resources/company-current'
/**
* The shared mock Supabase harness is a permissive proxy: it accepts any
* `.select()` string and resolves to whatever was enqueued, so a column that
* does not exist in Postgres passes every mocked test and only fails silently
* in production (PostgREST errors, the resource swallows it, the derived field
* is null forever). That is exactly how `invoices.sent_at` shipped: a phantom
* column that told every MCP agent no invoice had ever been sent.
*
* This recorder captures the table and column list of each query so the tests
* below can assert on what was actually requested.
*/
type RecordedCall = {
table: string
select: string | null
selectOptions: Record<string, unknown> | null
methods: { name: string; args: unknown[] }[]
}
type QueryResult = { data?: unknown; error?: unknown; count?: number | null }
function createRecordingSupabase(results: QueryResult[]) {
const calls: RecordedCall[] = []
let index = 0
const from = (table: string) => {
const result = results[index++] ?? {}
const call: RecordedCall = { table, select: null, selectOptions: null, methods: [] }
calls.push(call)
const chain: unknown = new Proxy(
{},
{
get(_target, prop) {
if (prop === 'then') {
return (resolve: (value: unknown) => void) =>
resolve({
data: result.data ?? null,
error: result.error ?? null,
count: result.count ?? null,
})
}
return (...args: unknown[]) => {
if (prop === 'select') {
call.select = (args[0] as string) ?? null
call.selectOptions = (args[1] as Record<string, unknown>) ?? null
}
call.methods.push({ name: String(prop), args })
return chain
}
},
},
)
return chain
}
return { supabase: { from } as never, calls }
}
const ctx = (supabase: never) => ({
supabase,
companyId: 'company-1',
userId: 'user-1',
scopes: [],
})
/**
* The queries in the order the resource issues them inside its Promise.all.
* Every column here was verified to exist against the production schema
* (information_schema.columns, project pwxtzglxptnnvjrpixpg) on 2026-07-26.
* If you change a select, re-verify it there before updating this list: the
* mocked harness will not catch a name you invented.
*/
const EXPECTED_QUERIES: { table: string; columns: string[] }[] = [
{ table: 'companies', columns: ['id', 'name', 'org_number', 'entity_type', 'archived_at', 'created_at'] },
{
table: 'company_settings',
columns: [
'pays_salaries', 'f_skatt', 'vat_registered', 'vat_number', 'moms_period',
'fiscal_year_start_month', 'accounting_method', 'default_voucher_series',
'bookkeeping_locked_through', 'auto_lock_period_days', 'invoice_prefix',
'next_invoice_number', 'invoice_default_days', 'is_sandbox',
],
},
{
table: 'fiscal_periods',
columns: ['id', 'name', 'period_start', 'period_end', 'is_closed', 'locked_at', 'closing_entry_id'],
},
{ table: 'fiscal_periods', columns: ['id', 'name', 'period_start', 'period_end', 'locked_at'] },
{ table: 'customers', columns: ['id'] },
{ table: 'suppliers', columns: ['id'] },
{ table: 'invoices', columns: ['id'] },
{ table: 'supplier_invoices', columns: ['id'] },
{ table: 'transactions', columns: ['id'] },
{
table: 'voucher_sequences',
columns: [
'voucher_series', 'last_number', 'fiscal_period_id',
'fiscal_periods!inner(name, period_start, period_end)',
],
},
{ table: 'journal_entries', columns: ['created_at'] },
{ table: 'invoice_deliveries', columns: ['sent_at'] },
{ table: 'bank_connections', columns: ['last_synced_at'] },
{ table: 'deadlines', columns: ['id', 'title', 'due_date', 'deadline_type', 'priority', 'status'] },
]
/** One empty result per query, so the resource can run end to end. */
function emptyResults(): QueryResult[] {
const results: QueryResult[] = EXPECTED_QUERIES.map(() => ({ data: null, count: 0 }))
results[0] = { data: { id: 'company-1', name: 'Test AB' } }
return results
}
function parseColumns(select: string | null): string[] {
if (!select) return []
// Split on commas that are not inside an embedded-resource parenthesis.
const columns: string[] = []
let depth = 0
let current = ''
for (const char of select) {
if (char === '(') depth++
if (char === ')') depth--
if (char === ',' && depth === 0) {
columns.push(current.trim())
current = ''
continue
}
current += char
}
if (current.trim()) columns.push(current.trim())
return columns.filter(Boolean)
}
function findCall(calls: RecordedCall[], table: string) {
return calls.filter((c) => c.table === table)
}
function argsOf(call: RecordedCall, method: string) {
return call.methods.filter((m) => m.name === method).map((m) => m.args)
}
describe('Accounted://company/current query shape', () => {
it('requests only tables and columns that exist in the schema', async () => {
const { supabase, calls } = createRecordingSupabase(emptyResults())
await companyCurrentResource.read(ctx(supabase))
expect(calls.map((c) => c.table)).toEqual(EXPECTED_QUERIES.map((q) => q.table))
calls.forEach((call, i) => {
expect(parseColumns(call.select)).toEqual(EXPECTED_QUERIES[i].columns)
})
})
it('reads the sent timestamp from invoice_deliveries, never from invoices', async () => {
const { supabase, calls } = createRecordingSupabase(emptyResults())
await companyCurrentResource.read(ctx(supabase))
// invoices has no sent timestamp of any kind; selecting one there always
// yields null, which the agent reads as "no invoice was ever sent".
for (const call of findCall(calls, 'invoices')) {
expect(parseColumns(call.select)).not.toContain('sent_at')
}
const [delivery] = findCall(calls, 'invoice_deliveries')
expect(delivery).toBeDefined()
expect(parseColumns(delivery.select)).toEqual(['sent_at'])
expect(argsOf(delivery, 'eq')).toContainEqual(['company_id', 'company-1'])
expect(argsOf(delivery, 'not')).toContainEqual(['sent_at', 'is', null])
expect(argsOf(delivery, 'order')).toContainEqual(['sent_at', { ascending: false }])
})
it('filters categorizations on a source_type the engine actually writes', async () => {
const { supabase, calls } = createRecordingSupabase(emptyResults())
await companyCurrentResource.read(ctx(supabase))
// journal_entries_source_type_check has no 'transaction' member; the
// engine writes 'bank_transaction' when a bank transaction is booked.
const [entries] = findCall(calls, 'journal_entries')
expect(argsOf(entries, 'eq')).toContainEqual(['source_type', 'bank_transaction'])
expect(argsOf(entries, 'eq')).not.toContainEqual(['source_type', 'transaction'])
})
})
describe('Accounted://company/current recency signals', () => {
it('surfaces the delivery timestamp as last_invoice_sent_at', async () => {
const results = emptyResults()
results[10] = { data: { created_at: '2026-07-20T08:00:00.000Z' } }
results[11] = { data: { sent_at: '2026-07-25T13:09:31.130Z' } }
results[12] = { data: { last_synced_at: '2026-07-26T04:00:00.000Z' } }
const { supabase } = createRecordingSupabase(results)
const result = (await companyCurrentResource.read(ctx(supabase))) as {
recent: {
last_categorization_at: string | null
last_invoice_sent_at: string | null
last_bank_sync_at: string | null
}
}
expect(result.recent).toEqual({
last_categorization_at: '2026-07-20T08:00:00.000Z',
last_invoice_sent_at: '2026-07-25T13:09:31.130Z',
last_bank_sync_at: '2026-07-26T04:00:00.000Z',
})
})
it('reports no activity as null when the tables are genuinely empty', async () => {
const { supabase } = createRecordingSupabase(emptyResults())
const result = (await companyCurrentResource.read(ctx(supabase))) as {
recent: Record<string, string | null>
}
expect(result.recent).toEqual({
last_categorization_at: null,
last_invoice_sent_at: null,
last_bank_sync_at: null,
})
})
it('throws instead of reporting a failed delivery read as "never sent"', async () => {
const results = emptyResults()
results[11] = { error: { code: '42703', message: 'column invoice_deliveries.sent_at does not exist' } }
const { supabase } = createRecordingSupabase(results)
await expect(companyCurrentResource.read(ctx(supabase))).rejects.toThrow(
/Failed to read last invoice delivery/,
)
})
it('throws when the categorization or bank-sync read fails', async () => {
const categorizationFailed = emptyResults()
categorizationFailed[10] = { error: { code: '57014', message: 'statement timeout' } }
await expect(
companyCurrentResource.read(ctx(createRecordingSupabase(categorizationFailed).supabase)),
).rejects.toThrow(/Failed to read last categorization/)
const bankSyncFailed = emptyResults()
bankSyncFailed[12] = { error: { code: '57014', message: 'statement timeout' } }
await expect(
companyCurrentResource.read(ctx(createRecordingSupabase(bankSyncFailed).supabase)),
).rejects.toThrow(/Failed to read last bank sync/)
})
it('treats PGRST116 as no rows, not as a failure', async () => {
const results = emptyResults()
results[11] = { error: { code: 'PGRST116', message: 'no rows returned' } }
const { supabase } = createRecordingSupabase(results)
const result = (await companyCurrentResource.read(ctx(supabase))) as {
recent: Record<string, string | null>
}
expect(result.recent.last_invoice_sent_at).toBeNull()
})
})
@@ -29,6 +29,38 @@ describe('company settings MCP tools: registration', () => {
expect(updateTool().inputSchema.additionalProperties).toBe(false)
})
it('exposes the same field set on the read and write tools', () => {
const readFields = (
(getTool().outputSchema as { required: string[] }).required
)
.filter((field) => field !== 'company_id')
.sort()
const writeFields = Object.keys(
(updateTool().inputSchema as { properties: Record<string, unknown> }).properties,
)
.filter((field) => field !== 'dry_run' && field !== 'idempotency_key')
.sort()
expect(writeFields).toEqual(readFields)
expect(readFields).toEqual(
[
'account_number',
'bank_name',
'bankgiro',
'bic',
'clearing_number',
'contact_person',
'email',
'iban',
'invoice_email_texts',
'phone',
'plusgiro',
'swish',
'website',
],
)
})
it('keeps both settings schemas discoverable through tool search', async () => {
const search = tools.find((tool) => tool.name === 'gnubok_search_tools')!
const readResult = (await search.execute(
@@ -169,6 +201,79 @@ describe('gnubok_update_company_settings', () => {
expect(supabase.from).toHaveBeenCalledTimes(1)
})
it('rejects an unknown invoice email placeholder before querying the database', async () => {
const { supabase } = createQueuedMockSupabase()
await expect(
updateTool().execute(
{
invoice_email_texts: { sv: { body: 'Betala med OCR {ocr}.' } },
dry_run: true,
},
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/placeholder/i)
expect(supabase.from).not.toHaveBeenCalled()
})
it('stages contact details and invoice email texts with a mapped preview', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({
data: {
bank_name: null,
clearing_number: null,
account_number: null,
bankgiro: null,
plusgiro: null,
swish: null,
iban: null,
bic: null,
default_our_reference: null,
email: null,
phone: null,
website: null,
invoice_email_texts: null,
},
})
enqueue({ data: { id: 'op-settings-2' } })
const result = (await updateTool().execute(
{
email: 'faktura@example.se',
phone: '08-123 456 78',
website: 'https://example.se',
invoice_email_texts: { sv: { subject: 'Faktura {fakturanummer}' } },
},
'company-1',
'user-1',
supabase as never,
)) as {
staged: boolean
operation_id?: string
preview: {
changes?: Record<string, unknown>
proposed?: Record<string, unknown>
}
}
expect(result.staged).toBe(true)
expect(result.operation_id).toBe('op-settings-2')
expect(result.preview.changes).toMatchObject({
email: 'faktura@example.se',
phone: '08-123 456 78',
website: 'https://example.se',
invoice_email_texts: { sv: { subject: 'Faktura {fakturanummer}' } },
})
expect(result.preview.changes).not.toHaveProperty('default_our_reference')
expect(result.preview.proposed).toMatchObject({
email: 'faktura@example.se',
website: 'https://example.se',
})
expect(supabase.from).toHaveBeenNthCalledWith(2, 'pending_operations')
})
it('stages a validated update for approval', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({
@@ -0,0 +1,108 @@
/**
* gnubok_create_invoice VAT-rate gate.
*
* The tool validates every line's rate at STAGING time, so a gate that is too
* narrow refuses the invoice before the executor (commitCreateInvoice) is ever
* reached: the MCP surface was the last one that could not issue a lawful
* taxed-where-performed invoice to a foreign business.
*
* Huvudregeln (ML 6 kap. 34 §) taxes a B2B service where the buyer is
* established, so 0% reverse charge stays the DEFAULT for a VAT-validated EU
* business. The ML 6 kap. exceptions taxed where the supply is performed
* (hotel/restaurang 12%, persontransport and event admission 6%,
* fastighetstjänst and korttidsuthyrning 25%) carry Swedish VAT even then.
* Rates outside {0, 25, 12, 6} are not Swedish VAT rates at all and stay
* refused for every customer type.
*
* The structural sibling of this file is lib/invoices/__tests__/
* vat-rate-gate-parity.test.ts, which pins that all seven write paths call the
* same getPermittedVatRates helper.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
import { tools } from '../server'
const createInvoice = tools.find((t) => t.name === 'gnubok_create_invoice')!
/** Synthetic VAT-validated EU business: reverse charge is its default. */
const EU_CUSTOMER = {
id: 'cust-eu',
name: 'Muster GmbH',
customer_type: 'eu_business',
vat_number_validated: true,
default_payment_terms: 30,
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('gnubok_create_invoice: VAT-rate gate for a foreign business', () => {
it('accepts a 12% line to a VAT-validated EU business (hotel night in Sweden)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: EU_CUSTOMER, error: null }) // customers fetch
enqueue({ data: null, error: null }) // resolvePeriodStatusForDate layer 1
enqueue({ data: null, error: null }) // resolvePeriodStatusForDate layer 2
enqueue({ data: { id: 'op-hotel' }, error: null }) // pending_operations insert
const result = (await createInvoice.execute(
{
customer_id: 'cust-eu',
invoice_date: '2026-05-12',
items: [
{ description: 'Hotellnatt Stockholm', quantity: 2, unit: 'st', unit_price: 1000, vat_rate: 12 },
],
},
'company-1',
'user-1',
supabase as never,
)) as { staged: boolean; preview: { total?: number } }
expect(result.staged).toBe(true)
// 2000 net + 240 moms: the Swedish rate actually reached the staged totals.
expect(result.preview.total).toBe(2240)
})
it('still refuses a rate that is not a Swedish VAT rate', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: EU_CUSTOMER, error: null })
await expect(
createInvoice.execute(
{
customer_id: 'cust-eu',
invoice_date: '2026-05-12',
items: [
{ description: 'Konsultarvode', quantity: 1, unit: 'tim', unit_price: 1000, vat_rate: 10 },
],
},
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/VAT rate 10% is not allowed/)
})
it('keeps 0% as the default when a line omits vat_rate', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: EU_CUSTOMER, error: null })
enqueue({ data: null, error: null })
enqueue({ data: null, error: null })
enqueue({ data: { id: 'op-consulting' }, error: null })
const result = (await createInvoice.execute(
{
customer_id: 'cust-eu',
invoice_date: '2026-05-12',
items: [{ description: 'Konsultarvode', quantity: 1, unit: 'tim', unit_price: 1000 }],
},
'company-1',
'user-1',
supabase as never,
)) as { staged: boolean; preview: { total?: number; vat_amount?: number } }
expect(result.staged).toBe(true)
expect(result.preview.vat_amount).toBe(0)
expect(result.preview.total).toBe(1000)
})
})
@@ -122,10 +122,20 @@ describe('gnubok_create_supplier: input validation', () => {
).rejects.toThrow(/default_payment_terms/i)
})
it('requires vat_number when supplier_type is eu_business', async () => {
await expect(
tool().execute({ name: 'Acme GmbH', supplier_type: 'eu_business' }, 'company-1', 'user-1', noopSupabase),
).rejects.toThrow(/vat_number/i)
// vat_number is optional for every supplier type, including eu_business: an
// EU supplier below its national registration threshold has none, and nothing
// downstream needs one (reverse charge keys off supplier_type). See the header
// of lib/pending-operations/schemas/create-supplier.ts.
it('accepts supplier_type eu_business without a vat_number', async () => {
const result = await tool().execute(
{ name: 'Kleinanbieter GmbH', supplier_type: 'eu_business', dry_run: true },
'company-1',
'user-1',
noopSupabase,
) as { preview?: { supplier_type?: string; vat_number?: string }; dry_run?: boolean }
expect(result.dry_run).toBe(true)
expect(result.preview?.supplier_type).toBe('eu_business')
expect(result.preview?.vat_number).toBeUndefined()
})
})
@@ -0,0 +1,240 @@
/**
* Tests for gnubok_get_invoice_deliveries.
*
* The tool is the agent-facing window into invoice email delivery history,
* including the provider outcome (delivered/bounced/...), so an agent chasing
* an unpaid invoice can see the mail never arrived. Privacy contract under
* test: recipients arrive pre-masked from the service RPC (migration
* 20260727100000), and the exact payload (body_html, body_text, bcc_addresses)
* must never reach the MCP surface even if the RPC were ever widened: the tool
* maps an explicit field list, and this suite pins that.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
import { TOOL_SCOPE_MAP } from '@/lib/auth/api-keys'
import { tools } from '../server'
const getDeliveries = tools.find((t) => t.name === 'gnubok_get_invoice_deliveries')!
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
const USER_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
const INVOICE_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'
const DELIVERY_ID = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd'
// Shape of one row as the service RPC returns it: recipients already masked,
// no payload columns present.
const BOUNCED_ROW = {
id: DELIVERY_ID,
channel: 'email',
status: 'sent',
to_addresses: ['***@example.com'],
cc_addresses: ['***@example.org'],
provider: 'resend',
provider_status: 'bounced',
provider_status_at: '2026-07-20T10:00:00+00:00',
provider_status_detail: 'smtp; 550 5.1.1 ***@example.com recipient rejected',
error_code: null,
document_attachment_id: 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee',
attachment_filename: 'faktura-1042.pdf',
sent_at: '2026-07-19T09:00:00+00:00',
failed_at: null,
created_at: '2026-07-19T09:00:00+00:00',
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('gnubok_get_invoice_deliveries: registration', () => {
it('is registered as a plain read-only tool (not a staged operation)', () => {
expect(getDeliveries).toBeDefined()
expect(getDeliveries.annotations.readOnlyHint).toBe(true)
expect(getDeliveries.annotations.destructiveHint).toBe(false)
// Read tools return their data directly; the staged-operation envelope
// (staged/risk_level/actor) must not appear here.
const outputProps = (getDeliveries.outputSchema as { properties: Record<string, unknown> })
.properties
expect(outputProps.deliveries).toBeDefined()
expect(outputProps.staged).toBeUndefined()
})
it('requires invoice_id and rejects unknown input properties', () => {
const schema = getDeliveries.inputSchema as {
additionalProperties: boolean
required: string[]
}
expect(schema.additionalProperties).toBe(false)
expect(schema.required).toEqual(['invoice_id'])
})
it('is mapped to invoices:read scope', () => {
expect(TOOL_SCOPE_MAP.gnubok_get_invoice_deliveries).toBe('invoices:read')
})
it('is search-only in the catalog (tools/list context budget)', () => {
// Deliberate: a specialized per-invoice diagnostic stays out of the
// default tools/list (payload-size.bench.test.ts ceiling) and is found
// through gnubok_search_tools, like the company-settings tools.
expect(getDeliveries.catalogVisibility).toBe('search')
})
it('uses only qualified identifiers in the output schema', () => {
const itemProps = (
getDeliveries.outputSchema as {
properties: { deliveries: { items: { properties: Record<string, unknown> } } }
}
).properties.deliveries.items.properties
expect(itemProps.invoice_delivery_id).toBeDefined()
expect(itemProps.id).toBeUndefined()
})
})
describe('gnubok_get_invoice_deliveries: execute', () => {
it('returns delivery attempts with the provider outcome and masked recipients', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: INVOICE_ID } }) // invoice existence check
enqueue({ data: [BOUNCED_ROW] }) // RPC result
const result = (await getDeliveries.execute(
{ invoice_id: INVOICE_ID },
COMPANY_ID,
USER_ID,
supabase as never,
{ type: 'api_key' } as never,
)) as { deliveries: Array<Record<string, unknown>>; count: number }
expect(result.count).toBe(1)
const delivery = result.deliveries[0]
expect(delivery.invoice_delivery_id).toBe(DELIVERY_ID)
expect('id' in delivery).toBe(false)
expect(delivery.channel).toBe('email')
expect(delivery.status).toBe('sent')
expect(delivery.provider).toBe('resend')
expect(delivery.provider_status).toBe('bounced')
expect(delivery.provider_status_at).toBe('2026-07-20T10:00:00+00:00')
expect(delivery.provider_status_detail).toContain('550 5.1.1')
expect(delivery.error_code).toBeNull()
expect(delivery.to_addresses).toEqual(['***@example.com'])
expect(delivery.cc_addresses).toEqual(['***@example.org'])
expect(delivery.attachment_filename).toBe('faktura-1042.pdf')
expect(delivery.sent_at).toBe('2026-07-19T09:00:00+00:00')
expect(delivery.failed_at).toBeNull()
expect(delivery.created_at).toBe('2026-07-19T09:00:00+00:00')
})
it('calls the service RPC with the routed company and acting user, never the table', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: INVOICE_ID } })
enqueue({ data: [] })
await getDeliveries.execute(
{ invoice_id: INVOICE_ID },
COMPANY_ID,
USER_ID,
supabase as never,
{ type: 'api_key' } as never,
)
expect(supabase.rpc).toHaveBeenCalledWith('list_invoice_delivery_summaries_for_service', {
p_company_id: COMPANY_ID,
p_user_id: USER_ID,
p_invoice_id: INVOICE_ID,
})
// The invoice_deliveries table carries the exact subject/body/BCC of a
// customer mail: the tool must never select it directly.
expect(supabase.from).toHaveBeenCalledTimes(1)
expect(supabase.from).toHaveBeenCalledWith('invoices')
})
it('drops payload fields even if the RPC were widened to return them', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: INVOICE_ID } })
enqueue({
data: [
{
...BOUNCED_ROW,
// A hypothetical future regression in the RPC: these must not
// survive the tool's explicit field mapping.
subject: 'LEAKED-SUBJECT',
body_text: 'LEAKED-BODY-TEXT',
body_html: '<p>LEAKED-BODY-HTML</p>',
bcc_addresses: ['leaked.bcc@example.net'],
},
],
})
const result = await getDeliveries.execute(
{ invoice_id: INVOICE_ID },
COMPANY_ID,
USER_ID,
supabase as never,
{ type: 'api_key' } as never,
)
const serialized = JSON.stringify(result)
expect(serialized).not.toContain('LEAKED-SUBJECT')
expect(serialized).not.toContain('LEAKED-BODY-TEXT')
expect(serialized).not.toContain('LEAKED-BODY-HTML')
expect(serialized).not.toContain('leaked.bcc')
expect(serialized).not.toContain('bcc_addresses')
})
it('throws Invoice not found for an invoice outside the routed company', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null }) // maybeSingle miss
await expect(
getDeliveries.execute(
{ invoice_id: INVOICE_ID },
COMPANY_ID,
USER_ID,
supabase as never,
{ type: 'api_key' } as never,
),
).rejects.toThrow(/invoice not found/i)
expect(supabase.rpc).not.toHaveBeenCalled()
})
it('requires invoice_id', async () => {
const { supabase } = createQueuedMockSupabase()
await expect(
getDeliveries.execute({}, COMPANY_ID, USER_ID, supabase as never, {
type: 'api_key',
} as never),
).rejects.toThrow(/invoice_id is required/)
})
it('surfaces an RPC failure instead of reporting zero deliveries', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: INVOICE_ID } })
enqueue({ data: null, error: { message: 'permission denied for function' } })
await expect(
getDeliveries.execute(
{ invoice_id: INVOICE_ID },
COMPANY_ID,
USER_ID,
supabase as never,
{ type: 'api_key' } as never,
),
).rejects.toThrow(/Database error/)
})
it('returns an empty list for an invoice with no delivery attempts', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: INVOICE_ID } })
enqueue({ data: [] })
const result = (await getDeliveries.execute(
{ invoice_id: INVOICE_ID },
COMPANY_ID,
USER_ID,
supabase as never,
{ type: 'api_key' } as never,
)) as { deliveries: unknown[]; count: number }
expect(result.deliveries).toEqual([])
expect(result.count).toBe(0)
})
})
@@ -0,0 +1,145 @@
/**
* gnubok_lock_period: the staging pre-check must enforce the SAME predicate
* as the commit path (lockPeriod in lib/core/bookkeeping/period-service.ts).
*
* The old inline check (journal_entry_id IS NULL AND is_business = true) was
* dead: triage is what sets is_business, so it never matched an untriaged
* row, and bulk-booked transactions keep journal_entry_id NULL while anchored
* via transaction_voucher_links, so it blocked on already-booked rows. An
* agent could stage a lock over thousands of untriaged transactions and
* present an approval card claiming zero unbooked (BFL 5 kap 2 §).
*
* The corrected guard counts:
* - untriaged: is_business IS NULL AND is_ignored = false
* - businessUnbooked: is_business = true, is_ignored = false,
* journal_entry_id IS NULL, minus rows anchored via
* transaction_voucher_links / invoice_payments / supplier_invoice_payments
* and fails CLOSED when the check itself cannot run.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
import { tools } from '../server'
const lockPeriod = tools.find((t) => t.name === 'gnubok_lock_period')!
const PERIOD = {
id: 'fp-1',
name: 'FY 2026',
period_start: '2026-01-01',
period_end: '2026-12-31',
is_closed: false,
locked_at: null,
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('gnubok_lock_period staging guard', () => {
it('refuses to stage while untriaged transactions exist (the predicate the old check never matched)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: PERIOD }) // fiscal_periods
enqueue({ data: null, count: 3 }) // untriaged head-count
enqueue({ data: [] }) // business candidates page (none)
await expect(
lockPeriod.execute(
{ fiscal_period_id: 'fp-1' }, 'company-1', 'user-1', supabase as never, { type: 'api_key' },
),
).rejects.toThrow(/Kan inte låsa period: 3 banktransaktion\(er\) i perioden saknar bokföring \(3 ej hanterade\)/)
const fromCalls = (supabase.from as ReturnType<typeof vi.fn>).mock.calls
expect(fromCalls.some((args) => args[0] === 'pending_operations')).toBe(false)
})
it('keeps the load-bearing phrases for both error-code matchers', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: PERIOD })
enqueue({ data: null, count: 1 })
enqueue({ data: [] })
const err = await lockPeriod
.execute({ fiscal_period_id: 'fp-1' }, 'company-1', 'user-1', supabase as never, { type: 'api_key' })
.then(() => null, (e: Error) => e)
expect(err).toBeInstanceOf(Error)
// Route regex: "saknar bokföring"; inferCode regex:
// /Kan inte låsa period:.*affärstransaktion/ (same phrases the
// period-service message carries).
expect(err!.message).toMatch(/saknar bokföring/)
expect(err!.message).toMatch(/Kan inte låsa period:.*affärstransaktion/)
})
it('refuses to stage over business-triaged rows with no verifikat anchor anywhere', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: PERIOD })
enqueue({ data: null, count: 0 }) // untriaged: none
enqueue({ data: [{ id: 'tx-9' }] }) // business candidates
enqueue({ data: [] }) // transaction_voucher_links
enqueue({ data: [] }) // invoice_payments
enqueue({ data: [] }) // supplier_invoice_payments
await expect(
lockPeriod.execute(
{ fiscal_period_id: 'fp-1' }, 'company-1', 'user-1', supabase as never, { type: 'api_key' },
),
).rejects.toThrow(/1 markerade som affärshändelse men utan verifikat/)
})
it('stages when the business candidates are anchored via transaction_voucher_links (bulk-booked rows no longer block)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: PERIOD })
enqueue({ data: null, count: 0 }) // untriaged: none
// Two bulk-booked rows: journal_entry_id NULL but anchored via links. The
// OLD predicate counted these and refused; the shared predicate subtracts
// them.
enqueue({ data: [{ id: 'tx-1' }, { id: 'tx-2' }] })
enqueue({ data: [{ transaction_id: 'tx-1' }, { transaction_id: 'tx-2' }] }) // transaction_voucher_links
enqueue({ data: [] }) // invoice_payments
enqueue({ data: [] }) // supplier_invoice_payments
enqueue({ data: { id: 'op-lock-1' } }) // pending_operations insert
const result = (await lockPeriod.execute(
{ fiscal_period_id: 'fp-1' }, 'company-1', 'user-1', supabase as never, { type: 'api_key' },
)) as { staged: boolean; operation_id?: string; preview: Record<string, unknown> }
expect(result.staged).toBe(true)
expect(result.operation_id).toBe('op-lock-1')
expect(result.preview.unbooked_business_transactions).toBe(0)
expect(result.preview.untriaged_transactions).toBe(0)
})
it('stages the clean period (no untriaged, no candidates)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: PERIOD })
enqueue({ data: null, count: 0 })
enqueue({ data: [] }) // no business candidates: no anchor lookups run
enqueue({ data: { id: 'op-lock-2' } }) // pending_operations insert
const result = (await lockPeriod.execute(
{ fiscal_period_id: 'fp-1' }, 'company-1', 'user-1', supabase as never, { type: 'api_key' },
)) as { staged: boolean }
expect(result.staged).toBe(true)
})
it('fails CLOSED when the guard query errors: never stages, and the message steers to retry, not remediation', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: PERIOD })
enqueue({ data: null, error: { message: 'connection reset' }, count: null }) // untriaged count fails
const err = await lockPeriod
.execute({ fiscal_period_id: 'fp-1' }, 'company-1', 'user-1', supabase as never, { type: 'api_key' })
.then(() => null, (e: Error) => e)
expect(err).toBeInstanceOf(Error)
expect(err!.message).toMatch(/Kunde inte kontrollera obokförda banktransaktioner/)
// Deliberately matches NEITHER remediation matcher: an unreachable DB
// must not send an agent off booking transactions.
expect(err!.message).not.toMatch(/saknar bokföring/)
expect(err!.message).not.toMatch(/Kan inte låsa period/)
const fromCalls = (supabase.from as ReturnType<typeof vi.fn>).mock.calls
expect(fromCalls.some((args) => args[0] === 'pending_operations')).toBe(false)
})
})
@@ -136,9 +136,18 @@ describe('tools/list payload size guard', () => {
// discovery round-trips. Schema prose trimmed to the floor first;
// headroom before the change was ~15 tokens, and the remainder is
// the wire contract agents read the loadout through.
// * 57.5K → 58K with the run-scoped AGI filing contract on
// gnubok_agi_status (filing_state enum + kvittensnummer + run-scoped
// local_state): a correction run must be able to tell, from the wire
// contract alone, that it is unfiled for this run even though the
// period record holds the superseded original's receipt (a correction
// is a full resubmission with its own kvittens). Descriptions were
// trimmed to the floor first (agi_status, lock_period, list_employees
// gave back ~100 tokens); the ~90-token remainder is the contract
// agents read the filing state through.
// Long-term answer to growth is leaning harder on gnubok_search_tools: if this
// fires again, prefer trimming descriptions or making a tool opt-in via search
// before bumping further.
expect(approxTokens).toBeLessThan(57_500)
expect(approxTokens).toBeLessThan(58_000)
})
})
@@ -1,10 +1,15 @@
/**
* Tests for the payroll MCP read tools (payroll gap-closure 1.6):
* gnubok_get_employee, gnubok_get_payslip, gnubok_list_absence.
* gnubok_get_employee, gnubok_get_payslip, gnubok_list_absence, plus the
* personnummer contract on gnubok_list_employees and gnubok_get_salary_run.
*
* PII rule under test: personnummer is ALWAYS masked on the MCP surface
* (LLM context is a leak surface); there is no full-value drill-in here,
* unlike v1's GET /employees/{id}.
* unlike v1's GET /employees/{id}. The mask is YYYYMMDD-XXXX, so
* personnummer_last4 must never ride along in the same payload: mask + last4
* reconstructs the full personnummer by concatenation. Responses expose only
* personnummer_masked (via maskEmployeeForResponse), never the ciphertext,
* never last4, and never a mask under the writable `personnummer` key.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
@@ -15,6 +20,8 @@ const getEmployee = tools.find((t) => t.name === 'gnubok_get_employee')!
const getPayslip = tools.find((t) => t.name === 'gnubok_get_payslip')!
const listAbsence = tools.find((t) => t.name === 'gnubok_list_absence')!
const getVacationBalance = tools.find((t) => t.name === 'gnubok_get_vacation_balance')!
const listEmployees = tools.find((t) => t.name === 'gnubok_list_employees')!
const getSalaryRun = tools.find((t) => t.name === 'gnubok_get_salary_run')!
// Synthetic fixture personnummer (year 1900, zero suffix): must not look
// like production-format PII.
@@ -95,6 +102,112 @@ describe('gnubok_get_employee', () => {
})
})
describe('gnubok_list_employees: personnummer contract', () => {
it('strips personnummer + personnummer_last4 and exposes only personnummer_masked', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
// The fixture deliberately carries personnummer_last4 even though the
// select no longer requests it: the helper must strip it regardless, so
// a projection regression can never leak it again.
enqueue({
data: [
{
id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',
first_name: 'Anna',
last_name: 'Andersson',
personnummer: SAMPLE_PERSONNUMMER,
personnummer_last4: '0000',
employment_type: 'employee',
monthly_salary: 35000,
hourly_rate: null,
employment_degree: 100,
tax_table_number: 33,
tax_column: 1,
salary_type: 'monthly',
default_dimensions: {},
is_active: true,
},
],
})
const result = (await listEmployees.execute(
{}, 'company-1', 'user-1', supabase as never, { type: 'api_key' },
)) as { employees: Array<Record<string, unknown>>; count: number }
expect(result.count).toBe(1)
const emp = result.employees[0]
expect(emp.personnummer_masked).toBe('19000101-XXXX')
expect(emp.first_name).toBe('Anna')
expect(emp.monthly_salary).toBe(35000)
// Neither the ciphertext key nor last4 survives, and the mask never goes
// out under the writable `personnummer` key.
expect(emp).not.toHaveProperty('personnummer')
expect(emp).not.toHaveProperty('personnummer_last4')
const serialized = JSON.stringify(result)
expect(serialized).not.toContain(SAMPLE_PERSONNUMMER)
expect(serialized).not.toContain('personnummer_last4')
})
})
describe('gnubok_get_salary_run: personnummer contract', () => {
it('masks the embedded employee and never returns last4 or ciphertext', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({
data: {
id: 'run-1',
status: 'draft',
period_year: 2026,
period_month: 6,
total_gross: 35000,
total_net: 26800,
},
})
enqueue({
data: [
{
id: 'sre-1',
salary_run_id: 'run-1',
employee_id: 'emp-1',
gross_salary: 35000,
net_salary: 26800,
// Fixture carries last4 on purpose: the helper must strip it even
// if the embed projection ever regresses to include it.
employee: {
first_name: 'Anna',
last_name: 'Andersson',
personnummer: SAMPLE_PERSONNUMMER,
personnummer_last4: '0000',
},
},
],
})
const result = (await getSalaryRun.execute(
{ salary_run_id: 'run-1' }, 'company-1', 'user-1', supabase as never, { type: 'api_key' },
)) as { employees: Array<{ employee: Record<string, unknown> | null }> }
const emp = result.employees[0].employee!
expect(emp.personnummer_masked).toBe('19000101-XXXX')
expect(emp.first_name).toBe('Anna')
expect(emp).not.toHaveProperty('personnummer')
expect(emp).not.toHaveProperty('personnummer_last4')
const serialized = JSON.stringify(result)
expect(serialized).not.toContain(SAMPLE_PERSONNUMMER)
expect(serialized).not.toContain('personnummer_last4')
})
it('keeps a null employee embed as null', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'run-1', status: 'draft' } })
enqueue({ data: [{ id: 'sre-1', salary_run_id: 'run-1', employee_id: 'emp-1', employee: null }] })
const result = (await getSalaryRun.execute(
{ salary_run_id: 'run-1' }, 'company-1', 'user-1', supabase as never, { type: 'api_key' },
)) as { employees: Array<{ employee: unknown }> }
expect(result.employees[0].employee).toBeNull()
})
})
describe('gnubok_get_payslip', () => {
const SRE_ROW = {
id: 'dddddddd-dddd-4ddd-8ddd-dddddddddddd',
@@ -465,6 +465,150 @@ describe('gnubok_set_employee_opening_balances', () => {
),
).rejects.toThrow(/Invalid opening balances/)
})
// The tool advertises idempotentHint: true and only requires employee_id +
// cutover_date, but every omitted field carries a Zod .default(). Without a
// merge, correcting one YTD figure silently zeroes the other eight columns:
// the semesterlöneskuld on 2920/2940 and the sparade dagar carried across
// years are real balances, not blanks to be re-derived.
const STORED_ROW = {
employee_id: EMPLOYEE_ID,
ytd_gross: 210000,
ytd_tax: 48000,
ytd_net: 162000,
vacation_paid_days_remaining: 12.5,
vacation_saved_days_by_year: { [`${CURRENT_YEAR - 1}`]: 5 },
opening_semester_liability: 42000,
opening_semester_liability_avgifter: 13196.4,
karens_periods_adjustment: 1,
}
function mockWithStoredRow(stored: Record<string, unknown> | null) {
return makeCapturingSupabase({
employee_opening_balances: { data: stored ? [stored] : [] },
employees: {
data: [{ id: EMPLOYEE_ID, employment_start: '2024-01-15', is_active: true }],
},
salary_run_employees: { data: [] },
pending_operations: { data: { id: 'op-6' }, error: null },
})
}
/** The items actually persisted to pending_operations.params. */
function stagedItems(mock: { inserts: Record<string, unknown[]> }) {
const insert = mock.inserts.pending_operations?.[0] as {
params: { items: Array<Record<string, unknown>> }
}
return insert.params.items
}
it('correcting one YTD figure leaves the other eight fields untouched', async () => {
const supabaseMock = mockWithStoredRow(STORED_ROW)
const result = (await setOpeningBalances.execute(
{
items: [
{
employee_id: EMPLOYEE_ID,
cutover_date: `${CURRENT_YEAR}-07-01`,
ytd_gross: 215000,
},
],
},
'company-1', 'user-1', supabaseMock as never, { type: 'agent_chat' },
)) as { staged: boolean; preview: Record<string, unknown> }
expect(result.staged).toBe(true)
const [item] = stagedItems(supabaseMock)
expect(item.ytd_gross).toBe(215000)
expect(item.ytd_tax).toBe(48000)
expect(item.ytd_net).toBe(162000)
expect(item.vacation_paid_days_remaining).toBe(12.5)
expect(item.vacation_saved_days_by_year).toEqual({ [`${CURRENT_YEAR - 1}`]: 5 })
expect(item.opening_semester_liability).toBe(42000)
expect(item.opening_semester_liability_avgifter).toBe(13196.4)
expect(item.karens_periods_adjustment).toBe(1)
expect(result.preview.updated_rows).toBe(1)
expect(result.preview.new_rows).toBe(0)
expect(result.preview.fields_provided).toEqual(['ytd_gross'])
})
it('clears a field only when it is sent explicitly', async () => {
const supabaseMock = mockWithStoredRow(STORED_ROW)
await setOpeningBalances.execute(
{
items: [
{
employee_id: EMPLOYEE_ID,
cutover_date: `${CURRENT_YEAR}-07-01`,
karens_periods_adjustment: 0,
vacation_saved_days_by_year: {},
},
],
},
'company-1', 'user-1', supabaseMock as never, { type: 'agent_chat' },
)
const [item] = stagedItems(supabaseMock)
expect(item.karens_periods_adjustment).toBe(0)
expect(item.vacation_saved_days_by_year).toEqual({})
// Untouched neighbours survive the clear.
expect(item.opening_semester_liability).toBe(42000)
expect(item.ytd_gross).toBe(210000)
})
it('first-time cutover still lands on the schema defaults', async () => {
const supabaseMock = mockWithStoredRow(null)
const result = (await setOpeningBalances.execute(
{
items: [
{
employee_id: EMPLOYEE_ID,
cutover_date: `${CURRENT_YEAR}-07-01`,
ytd_gross: 210000,
ytd_tax: 48000,
},
],
},
'company-1', 'user-1', supabaseMock as never, { type: 'agent_chat' },
)) as { staged: boolean; preview: Record<string, unknown> }
expect(result.staged).toBe(true)
const [item] = stagedItems(supabaseMock)
expect(item.ytd_gross).toBe(210000)
expect(item.ytd_tax).toBe(48000)
expect(item.ytd_net).toBe(0)
expect(item.vacation_paid_days_remaining).toBe(0)
expect(item.vacation_saved_days_by_year).toEqual({})
expect(item.opening_semester_liability).toBe(0)
expect(item.karens_periods_adjustment).toBe(0)
expect(result.preview.new_rows).toBe(1)
expect(result.preview.updated_rows).toBe(0)
})
it('validates the MERGED state, not the sparse patch', async () => {
// Stored ytd_gross is 210000; lowering it below the stored ytd_tax must
// fail rather than quietly persisting tax > gross.
const supabaseMock = mockWithStoredRow(STORED_ROW)
await expect(
setOpeningBalances.execute(
{
items: [
{
employee_id: EMPLOYEE_ID,
cutover_date: `${CURRENT_YEAR}-07-01`,
ytd_gross: 1000,
},
],
},
'company-1', 'user-1', supabaseMock as never, { type: 'agent_chat' },
),
).rejects.toThrow(/ytd_tax/)
})
})
describe('gnubok_close_vacation_year', () => {
@@ -62,6 +62,57 @@ function makeChainMock(lines: unknown[], count: number) {
} as never
}
/**
* Mock for the NON-text path, which uses the two-step entry-lines fetch
* (lib/bookkeeping/entry-lines.ts): journal_entries is queried first, then
* journal_entry_lines by parent id, and the parent is reattached under
* `journal_entries`. Both steps page with `.order('id').range(from, to)`, so
* `.range()` is the terminal and one short page ends the paging loop.
*
* Fixtures stay embed-shaped (a line with its `journal_entries` parent); the
* mock splits them into the two row sets the helper actually fetches, so the
* value the tool sees is byte-identical to the old embed result.
*/
function makeEntryLinesMock(rows: Array<Record<string, unknown>>) {
const tables: string[] = []
const entries = [
...new Map(
rows.map((r) => {
const e = r.journal_entries as { id: string }
return [e.id, e]
}),
).values(),
]
const bareLines = rows.map((r) => {
const { journal_entries: parent, ...line } = r
return { ...line, journal_entry_id: (parent as { id: string }).id }
})
const chain = (data: unknown[]): unknown =>
new Proxy(
{},
{
get(_t, prop) {
if (prop === 'then') {
return (resolve: (v: unknown) => void) =>
resolve({ data, error: null, count: data.length })
}
if (prop === 'range') return () => ({ data, error: null, count: data.length })
return () => chain(data)
},
},
)
const supabase = {
from: vi.fn().mockImplementation((table: string) => {
tables.push(table)
return chain(table === 'journal_entries' ? entries : bareLines)
}),
} as never
return { supabase, tables }
}
/**
* Richer mock for the text-search path: returns queued results across
* successive .from() calls and records every .ilike(column, pattern) call so
@@ -162,13 +213,16 @@ describe('gnubok_query_journal: entry notes (verifikat-anteckningar)', () => {
makeLineRow({ id: 'l1', entry_notes: 'Avser Q1-hyran, se mail 12/3' }),
makeLineRow({ id: 'l2' }),
]
const supabase = makeChainMock(rows, rows.length)
const { supabase, tables } = makeEntryLinesMock(rows)
const result = (await tool.execute(
{ accounts: ['4010'] },
'company-1', 'user-1', supabase,
)) as { lines: Array<{ line_id: string; entry_notes: string | null }> }
// The parent entry is reattached under the same key the old embed used,
// so every mapped field still resolves.
expect(tables).toEqual(['journal_entries', 'journal_entry_lines'])
expect(result.lines.find((l) => l.line_id === 'l1')?.entry_notes).toBe(
'Avser Q1-hyran, se mail 12/3',
)
@@ -203,7 +257,7 @@ describe('gnubok_query_journal: execute', () => {
},
},
]
const supabase = makeChainMock(lines, 2)
const { supabase } = makeEntryLinesMock(lines)
const result = (await tool.execute(
{ account_from: '4000', account_to: '4999', amount_min: 1000, limit: 100 },
@@ -243,23 +297,18 @@ describe('gnubok_query_journal: execute', () => {
})
it('marks truncated=true and computes totals over the FULL match set when the slice is capped', async () => {
// Regression for the slice-totals bug: the display query is capped at
// `limit`, but totals/total_lines must come from the fetchAllRows
// aggregate pass over ALL matching lines (totals_scope='full_match').
// Regression for the slice-totals bug: the returned lines are capped at
// `limit`, but totals/total_lines must cover ALL matching lines
// (totals_scope='full_match'). One two-step fetch feeds both: the full
// match set is sorted and sliced in JS for the display window.
const tool = tools.find((t) => t.name === 'gnubok_query_journal')!
const displaySlice = [makeLineRow({ id: 'l1', account_number: '1930', debit_amount: 100 })]
const fullMatchSet = [
makeLineRow({ id: 'l1', account_number: '1930', debit_amount: 100 }),
makeLineRow({ id: 'l2', account_number: '1930', debit_amount: 200 }),
makeLineRow({ id: 'l3', account_number: '1930', debit_amount: 300 }),
]
// .from() call order: display query first, then the aggregate pass
// (single page: 3 rows < PAGE_SIZE ends the fetchAllRows loop).
const { supabase, callCount } = makeQueueMock([
{ data: displaySlice, count: 1 },
{ data: fullMatchSet, count: 3 },
])
const { supabase, tables } = makeEntryLinesMock(fullMatchSet)
const result = (await tool.execute(
{ accounts: ['1930'], limit: 1 },
@@ -274,7 +323,9 @@ describe('gnubok_query_journal: execute', () => {
totals_scope: string
}
expect(callCount()).toBe(2)
// Entry side first, then the lines: no query starts on
// journal_entry_lines with the tenant scope hidden in an embed.
expect(tables).toEqual(['journal_entries', 'journal_entry_lines'])
expect(result.truncated).toBe(true)
expect(result.total_lines).toBe(3)
expect(result.returned_lines).toBe(1)
@@ -303,8 +354,8 @@ describe('gnubok_query_journal: execute', () => {
makeLineRow({ id: 'l2', account_number: '4010', debit_amount: 50 }),
makeLineRow({ id: 'l3', account_number: '5010', debit_amount: 0, credit_amount: 30 }),
]
// makeChainMock feeds the SAME rows to display + aggregate passes.
const supabase = makeChainMock(rows, 3)
// One two-step fetch feeds both the display slice and the aggregate.
const { supabase } = makeEntryLinesMock(rows)
const result = (await tool.execute(
{ group_by: 'account_number', limit: 100 },
@@ -333,7 +384,7 @@ describe('gnubok_query_journal: execute', () => {
{ ...makeLineRow({ id: 'l2', account_number: '4011', debit_amount: 50 }), dimensions: { '6': 'P001', '1': 'KS01' } },
{ ...makeLineRow({ id: 'l3', account_number: '5010', debit_amount: 0, credit_amount: 30 }), dimensions: null },
]
const supabase = makeChainMock(rows, 3)
const { supabase } = makeEntryLinesMock(rows)
const result = (await tool.execute(
{ group_by_dimension: '6', limit: 100 },
@@ -55,7 +55,7 @@ vi.mock('@/lib/events/bus', () => ({
vi.mock('@/lib/invoices/vat-rules', () => ({
getVatRules: vi.fn(),
getAvailableVatRates: vi.fn(),
getPermittedVatRates: vi.fn(),
}))
vi.mock('@/lib/currency/riksbanken', () => ({
@@ -142,7 +142,11 @@ vi.mock('@/lib/email/service', () => ({
getEmailService: vi.fn().mockReturnValue({ sendInvoice: vi.fn() }),
}))
vi.mock('@/lib/email/invoice-templates', () => ({
// Partial mock: server.ts also pulls INVOICE_EMAIL_PLACEHOLDER_KEYS through
// the company-settings staging schema, so keep the real exports and stub only
// the render functions.
vi.mock('@/lib/email/invoice-templates', async (importOriginal) => ({
...(await importOriginal<typeof import('@/lib/email/invoice-templates')>()),
generateInvoiceEmailHtml: vi.fn(),
generateInvoiceEmailText: vi.fn(),
generateInvoiceEmailSubject: vi.fn(),
@@ -0,0 +1,158 @@
import { describe, it, expect } from 'vitest'
import { recentActivityResource } from '../resources/recent-activity'
/**
* Real column names, verified against information_schema on the production
* database. The mocked Supabase harness happily returns rows for columns that
* do not exist, so the only way to catch a phantom column (PostgREST 42703 in
* production) is to assert the column list the resource actually asks for.
*/
const REAL_COLUMNS: Record<string, Set<string>> = {
journal_entries: new Set(
`id user_id fiscal_period_id voucher_number voucher_series entry_date description
source_type source_id status attachment_urls created_at updated_at committed_at
reversed_by_id reverses_id correction_of_id company_id notes commit_method
rubric_version source_voucher_series source_voucher_number created_via
source_proposal_id committed_actor_type committed_actor_label`.split(/\s+/)
),
invoices: new Set(
`id user_id customer_id invoice_number invoice_date due_date status currency
exchange_rate exchange_rate_date subtotal subtotal_sek vat_amount vat_amount_sek
total total_sek vat_treatment vat_rate moms_ruta your_reference our_reference notes
reverse_charge_text credited_invoice_id paid_at paid_amount created_at updated_at
ocr_number bankgiro_number plusgiro_number payment_type is_recurring
recurring_invoice_id document_type converted_from_id remaining_amount company_id
delivery_date deduction_total deduction_personnummer_encrypted
deduction_personnummer_last4 is_self_billed external_invoice_number
self_billing_agreement_ref received_date journal_entry_id ore_rounding
default_dimensions payment_link_url stripe_payment_link_id payment_link_auto
creation_complete`.split(/\s+/)
),
transactions: new Set(
`id user_id bank_connection_id external_id date description amount currency amount_sek
exchange_rate exchange_rate_date category is_business invoice_id potential_invoice_id
journal_entry_id mcc_code merchant_name receipt_id notes created_at updated_at
supplier_invoice_id import_source reference reconciliation_method
potential_supplier_invoice_id company_id document_id counterparty_iban
counterparty_account is_ignored original_description title_edited_at cash_account_id
enrichment`.split(/\s+/)
),
}
type TableResult = { data?: unknown; error?: { message: string } }
function createRecordingSupabase(results: Record<string, TableResult> = {}) {
const selects: Record<string, string> = {}
const supabase = {
from(table: string) {
const result = results[table] ?? { data: [] }
const chain: Record<string, unknown> = {}
const passthrough = () => chain
chain.select = (columns: string) => {
selects[table] = columns
return chain
}
chain.eq = passthrough
chain.order = passthrough
chain.limit = passthrough
chain.then = (resolve: (v: unknown) => void) =>
resolve({ data: result.data ?? null, error: result.error ?? null })
return chain
},
}
return { supabase, selects }
}
const ctx = (supabase: unknown, query?: URLSearchParams) => ({
supabase: supabase as never,
companyId: 'company-1',
userId: 'user-1',
scopes: [],
query,
})
describe('Accounted://recent-activity', () => {
it('only requests columns that exist on the queried tables', async () => {
const { supabase, selects } = createRecordingSupabase()
await recentActivityResource.read(ctx(supabase))
expect(Object.keys(selects).sort()).toEqual([
'invoices',
'journal_entries',
'transactions',
])
for (const [table, columns] of Object.entries(selects)) {
const requested = columns.split(',').map((c) => c.trim())
const phantom = requested.filter((c) => !REAL_COLUMNS[table].has(c))
expect(phantom, `phantom column(s) selected from ${table}`).toEqual([])
}
})
it('selects the invoice total as "total", never "total_amount"', async () => {
const { supabase, selects } = createRecordingSupabase()
await recentActivityResource.read(ctx(supabase))
const invoiceColumns = selects.invoices.split(',').map((c) => c.trim())
expect(invoiceColumns).toContain('total')
expect(invoiceColumns).not.toContain('total_amount')
})
it('returns the rows and counts uncategorized transactions', async () => {
const { supabase } = createRecordingSupabase({
journal_entries: { data: [{ id: 'je-1', voucher_number: 1 }] },
invoices: { data: [{ id: 'inv-1', invoice_number: '1', total: 1250, currency: 'SEK' }] },
transactions: {
data: [
{ id: 't-1', journal_entry_id: 'je-1' },
{ id: 't-2', journal_entry_id: null },
],
},
})
const result = (await recentActivityResource.read(
ctx(supabase, new URLSearchParams('limit=5'))
)) as {
limit: number
journal_entries: unknown[]
invoices: Array<{ total: number }>
transactions: unknown[]
uncategorized_transaction_count: number
}
expect(result.limit).toBe(5)
expect(result.journal_entries).toHaveLength(1)
expect(result.invoices[0].total).toBe(1250)
expect(result.uncategorized_transaction_count).toBe(1)
})
it('surfaces a query failure instead of reporting zero invoices', async () => {
const { supabase } = createRecordingSupabase({
invoices: { error: { message: 'column invoices.total_amount does not exist' } },
})
await expect(recentActivityResource.read(ctx(supabase))).rejects.toThrow(
/Failed to read recent invoices/
)
})
it('surfaces journal entry and transaction failures too', async () => {
const jeFail = createRecordingSupabase({
journal_entries: { error: { message: 'boom' } },
})
await expect(recentActivityResource.read(ctx(jeFail.supabase))).rejects.toThrow(
/Failed to read recent journal entries/
)
const txFail = createRecordingSupabase({
transactions: { error: { message: 'boom' } },
})
await expect(recentActivityResource.read(ctx(txFail.supabase))).rejects.toThrow(
/Failed to read recent transactions/
)
})
})
@@ -0,0 +1,392 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
import { TOOL_SCOPE_MAP } from '@/lib/auth/api-keys'
import { OPERATION_RISK_TIERS } from '@/lib/pending-operations/risk-tiers'
import { eventBus } from '@/lib/events/bus'
import { tools } from '../server'
const CUSTOMER_ID = '11111111-1111-4111-8111-111111111111'
const SCHEDULE_ID = '22222222-2222-4222-8222-222222222222'
const listTool = () => tools.find((t) => t.name === 'gnubok_list_recurring_schedules')!
const createTool = () => tools.find((t) => t.name === 'gnubok_create_recurring_schedule')!
const updateTool = () => tools.find((t) => t.name === 'gnubok_update_recurring_schedule')!
function currentSchedule(overrides: Record<string, unknown> = {}) {
return {
id: SCHEDULE_ID,
name: 'Månadsavgift',
status: 'active',
customer_id: CUSTOMER_ID,
day_of_month: 25,
send_hour: 8,
payment_terms_days: 30,
currency: 'SEK',
your_reference: null,
our_reference: null,
notes: null,
auto_send: false,
next_run_date: '2999-01-25',
customer: { name: 'Test Customer AB', email: 'billing@example.test' },
items: [
{ description: 'Support', quantity: 1, unit: 'st', unit_price: 5000, vat_rate: null, sort_order: 0 },
],
...overrides,
}
}
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
})
describe('recurring schedule tools: registration', () => {
it('registers all three tools with strict input schemas', () => {
for (const tool of [listTool(), createTool(), updateTool()]) {
expect(tool).toBeDefined()
expect(tool.inputSchema.additionalProperties).toBe(false)
}
expect(listTool().annotations.readOnlyHint).toBe(true)
expect(createTool().annotations.readOnlyHint).toBe(false)
expect(updateTool().annotations.readOnlyHint).toBe(false)
})
it('maps scopes and risk tiers', () => {
expect(TOOL_SCOPE_MAP.gnubok_list_recurring_schedules).toBe('invoices:read')
expect(TOOL_SCOPE_MAP.gnubok_create_recurring_schedule).toBe('invoices:write')
expect(TOOL_SCOPE_MAP.gnubok_update_recurring_schedule).toBe('invoices:write')
expect(OPERATION_RISK_TIERS.create_recurring_schedule).toBe('medium')
expect(OPERATION_RISK_TIERS.update_recurring_schedule).toBe('medium')
})
it('both writes declare the staged-operation output contract', () => {
for (const tool of [createTool(), updateTool()]) {
const schema = tool.outputSchema as { properties?: Record<string, unknown>; required?: string[] }
expect(schema?.properties?.staged).toBeDefined()
expect(schema?.required).toContain('staged')
}
})
it('descriptions state day-of-month clamping and Stockholm send hour', () => {
for (const tool of [listTool(), createTool(), updateTool()]) {
expect(tool.description).toMatch(/clamps to the last day in shorter months/i)
expect(tool.description).toMatch(/Europe\/Stockholm/i)
}
})
})
describe('gnubok_list_recurring_schedules', () => {
it('returns qualified ids and sorted items', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({
data: [
{
id: SCHEDULE_ID,
name: 'Månadsavgift',
status: 'active',
customer_id: CUSTOMER_ID,
day_of_month: 31,
send_hour: 8,
payment_terms_days: 30,
currency: 'SEK',
auto_send: false,
next_run_date: '2026-08-31',
last_run_at: null,
last_invoice_id: null,
last_run_warning: null,
generated_count: 0,
customer: { name: 'Test Customer AB' },
items: [
{ description: 'Timmar', quantity: 1, unit: 'tim', unit_price: 50, vat_rate: 25, sort_order: 1 },
{ description: 'Support', quantity: 2, unit: 'st', unit_price: 100, vat_rate: null, sort_order: 0 },
],
},
],
count: 1,
})
const result = (await listTool().execute({}, 'company-1', 'user-1', supabase as never)) as {
schedules: Array<Record<string, unknown>>
count: number
total_count: number
}
expect(result.count).toBe(1)
expect(result.total_count).toBe(1)
const row = result.schedules[0]
expect(row.recurring_schedule_id).toBe(SCHEDULE_ID)
expect(row).not.toHaveProperty('id')
expect(row.customer_id).toBe(CUSTOMER_ID)
expect(row.customer_name).toBe('Test Customer AB')
expect(row.last_invoice_id).toBeNull()
expect(row.monthly_total_excl_vat).toBe(250)
expect((row.items as Array<{ description: string }>).map((i) => i.description)).toEqual([
'Support',
'Timmar',
])
})
})
describe('gnubok_create_recurring_schedule: validation and staging', () => {
const validArgs = {
customer_id: CUSTOMER_ID,
name: 'Månadsavgift',
day_of_month: 25,
items: [{ description: 'Support', quantity: 1, unit: 'st', unit_price: 5000 }],
}
it('rejects missing items before querying the database', async () => {
const { supabase } = createQueuedMockSupabase()
await expect(
createTool().execute(
{ customer_id: CUSTOMER_ID, name: 'X', day_of_month: 25 },
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/items/i)
expect(supabase.from).not.toHaveBeenCalled()
})
it('rejects a VAT rate outside the Swedish set before querying the database', async () => {
const { supabase } = createQueuedMockSupabase()
await expect(
createTool().execute(
{
...validArgs,
items: [{ description: 'Support', quantity: 1, unit: 'st', unit_price: 5000, vat_rate: 10 }],
},
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/vat_rate/i)
expect(supabase.from).not.toHaveBeenCalled()
})
it('fails when the customer is outside the selected company', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null })
await expect(
createTool().execute({ ...validArgs, dry_run: true }, 'company-1', 'user-1', supabase as never),
).rejects.toThrow(/not found/i)
})
it('rejects auto_send when the customer has no email', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: CUSTOMER_ID, name: 'Test Customer AB', email: null } })
await expect(
createTool().execute(
{ ...validArgs, auto_send: true, dry_run: true },
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/email/i)
})
it('defaults auto_send to false and surfaces it explicitly in the dry-run preview', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: CUSTOMER_ID, name: 'Test Customer AB', email: 'billing@example.test' } })
const result = (await createTool().execute(
{ ...validArgs, dry_run: true },
'company-1',
'user-1',
supabase as never,
)) as { staged: boolean; dry_run?: boolean; preview: Record<string, unknown> }
expect(result.staged).toBe(false)
expect(result.dry_run).toBe(true)
expect(result.preview.auto_send).toBe(false)
expect(result.preview.monthly_total_excl_vat).toBe(5000)
expect(result.preview.projected_first_run_date).toMatch(/^\d{4}-\d{2}-\d{2}$/)
// Preview only: nothing staged.
expect(supabase.from).toHaveBeenCalledTimes(1)
})
it('stages the schedule for approval at medium risk', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: CUSTOMER_ID, name: 'Test Customer AB', email: 'billing@example.test' } })
enqueue({ data: { id: 'op-recurring-1' } })
const result = (await createTool().execute(validArgs, 'company-1', 'user-1', supabase as never)) as {
staged: boolean
operation_id?: string
risk_level: string
preview: Record<string, unknown>
}
expect(result).toMatchObject({
staged: true,
operation_id: 'op-recurring-1',
risk_level: 'medium',
})
expect(result.preview.auto_send).toBe(false)
expect(supabase.from).toHaveBeenNthCalledWith(2, 'pending_operations')
})
it('stages an explicit auto_send=true at HIGH risk with the flag visible in the preview', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: CUSTOMER_ID, name: 'Test Customer AB', email: 'billing@example.test' } })
enqueue({ data: { id: 'op-recurring-2' } })
const result = (await createTool().execute(
{ ...validArgs, auto_send: true },
'company-1',
'user-1',
supabase as never,
)) as { staged: boolean; risk_level: string; preview: Record<string, unknown> }
expect(result.staged).toBe(true)
expect(result.preview.auto_send).toBe(true)
// Param escalation (risk-tiers paramEscalatedRisk): an auto-sending
// schedule is a standing order for outbound customer email with no
// per-send approval, the same external side-effect that puts one-off
// send_invoice at 'high'. The static tier stays 'medium' (asserted
// above); the staged operation must carry the escalated level so
// auto-commit can never touch it and approval requires confirmed=true.
expect(result.risk_level).toBe('high')
})
})
describe('gnubok_update_recurring_schedule: validation and staging', () => {
it('requires at least one changed field', async () => {
const { supabase } = createQueuedMockSupabase()
await expect(
updateTool().execute({ schedule_id: SCHEDULE_ID, dry_run: true }, 'company-1', 'user-1', supabase as never),
).rejects.toThrow(/at least one/i)
expect(supabase.from).not.toHaveBeenCalled()
})
it('rejects an unknown status before querying the database', async () => {
const { supabase } = createQueuedMockSupabase()
await expect(
updateTool().execute(
{ schedule_id: SCHEDULE_ID, status: 'stopped', dry_run: true },
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/status/i)
expect(supabase.from).not.toHaveBeenCalled()
})
it('fails when the schedule is outside the selected company', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null })
await expect(
updateTool().execute(
{ schedule_id: SCHEDULE_ID, status: 'paused', dry_run: true },
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/not found/i)
})
it('rejects enabling auto_send when the current customer has no email', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: currentSchedule({ customer: { name: 'Test Customer AB', email: null } }) })
await expect(
updateTool().execute(
{ schedule_id: SCHEDULE_ID, auto_send: true, dry_run: true },
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/email/i)
})
it('stages an update that enables auto_send at HIGH risk', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: currentSchedule() }) // current has auto_send: false + customer email
enqueue({ data: { id: 'op-recurring-4' } })
const result = (await updateTool().execute(
{ schedule_id: SCHEDULE_ID, auto_send: true },
'company-1',
'user-1',
supabase as never,
)) as { staged: boolean; risk_level: string }
expect(result.staged).toBe(true)
// Same escalation as the create tool: turning auto_send on converts the
// schedule into recurring outbound email, so the staged op is 'high'.
expect(result.risk_level).toBe('high')
})
it('stages a pause via the status field', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: currentSchedule() })
enqueue({ data: { id: 'op-recurring-3' } })
const result = (await updateTool().execute(
{ schedule_id: SCHEDULE_ID, status: 'paused' },
'company-1',
'user-1',
supabase as never,
)) as {
staged: boolean
operation_id?: string
preview: { current: Record<string, unknown>; proposed: Record<string, unknown> }
}
expect(result.staged).toBe(true)
expect(result.operation_id).toBe('op-recurring-3')
expect(result.preview.current.status).toBe('active')
expect(result.preview.proposed.status).toBe('paused')
expect(result.preview.current.recurring_schedule_id).toBe(SCHEDULE_ID)
})
it('previews an item replace against the current lines', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: currentSchedule() })
const result = (await updateTool().execute(
{
schedule_id: SCHEDULE_ID,
items: [{ description: 'Ny rad', quantity: 2, unit: 'tim', unit_price: 1200 }],
dry_run: true,
},
'company-1',
'user-1',
supabase as never,
)) as {
dry_run?: boolean
preview: {
current: { items: Array<{ description: string }> }
proposed: { items: Array<{ description: string }> }
}
}
expect(result.dry_run).toBe(true)
expect(result.preview.current.items.map((i) => i.description)).toEqual(['Support'])
expect(result.preview.proposed.items.map((i) => i.description)).toEqual(['Ny rad'])
})
it('verifies a new customer belongs to the company', async () => {
const otherCustomerId = '33333333-3333-4333-8333-333333333333'
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: currentSchedule() })
enqueue({ data: null })
await expect(
updateTool().execute(
{ schedule_id: SCHEDULE_ID, customer_id: otherCustomerId, dry_run: true },
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/not found/i)
expect(supabase.from).toHaveBeenNthCalledWith(2, 'customers')
})
})
@@ -181,6 +181,104 @@ describe('gnubok_agi_submit', () => {
})
})
describe('gnubok_agi_status: run-scoped filing state', () => {
// The period-keyed extension_data record is shared by every salary run of
// the month (a correction coexists with the run it corrects, migration
// 20260414130000). The tool must resolve it per run via
// lib/salary/agi-submission-state.ts, exactly like AGIPanel: a correction
// run reports unfiled-for-this-run even though the ORIGINAL's record says
// 'signed', so the filing action stays visible.
const ORIGINAL_RECORD = {
status: 'signed',
kvittensnummer: 'KV-ORIG-1',
salaryRunId: 'sr-original',
signeradTid: '2026-07-01T09:00:00Z',
updatedAt: '2026-07-01T09:00:00Z',
}
function enqueueStatusReads(
enqueue: (r: { data?: unknown; error?: unknown }) => void,
run: Record<string, unknown>,
record: Record<string, unknown> | null,
) {
enqueue({ data: run }) // salary_runs
enqueue({ data: record ? { value: JSON.stringify(record) } : null }) // extension_data
}
beforeEach(() => {
mockResolveRedovisare.mockResolvedValue('165560000000')
// Nothing retrievable live: the run-scoping under test is purely local.
mockKvittenser.mockResolvedValue({ ok: false, status: 404 })
})
it('a correction run reports generated (unfiled) although the period record is the original run\'s signed receipt', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueueStatusReads(enqueue, {
id: 'sr-correction',
period_year: 2026,
period_month: 6,
agi_generated_at: '2026-07-05T10:00:00Z',
agi_submitted_at: null,
}, ORIGINAL_RECORD)
const result = (await agiStatus.execute(
{ salary_run_id: 'sr-correction' }, 'company-1', 'user-1', supabase as never, { type: 'api_key' },
)) as {
filing_state: string
kvittensnummer: string | null
local_state: Record<string, unknown> | null
}
// The correction has its own XML but no submission of its own: the
// original's receipt must not render it as filed.
expect(result.filing_state).toBe('generated')
expect(result.kvittensnummer).toBeNull()
expect(result.local_state).toBeNull()
})
it('the run that owns the record reports signed with its kvittensnummer', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueueStatusReads(enqueue, {
id: 'sr-original',
period_year: 2026,
period_month: 6,
agi_generated_at: '2026-06-28T08:00:00Z',
agi_submitted_at: '2026-07-01T09:00:00Z',
}, ORIGINAL_RECORD)
const result = (await agiStatus.execute(
{ salary_run_id: 'sr-original' }, 'company-1', 'user-1', supabase as never, { type: 'api_key' },
)) as {
filing_state: string
kvittensnummer: string | null
local_state: Record<string, unknown> | null
}
expect(result.filing_state).toBe('signed')
expect(result.kvittensnummer).toBe('KV-ORIG-1')
expect(result.local_state).toMatchObject({ status: 'signed', salaryRunId: 'sr-original' })
})
it('a run with neither XML nor record reports none', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueueStatusReads(enqueue, {
id: 'sr-fresh',
period_year: 2026,
period_month: 6,
agi_generated_at: null,
agi_submitted_at: null,
}, null)
const result = (await agiStatus.execute(
{ salary_run_id: 'sr-fresh' }, 'company-1', 'user-1', supabase as never, { type: 'api_key' },
)) as { filing_state: string; kvittensnummer: string | null; local_state: unknown }
expect(result.filing_state).toBe('none')
expect(result.kvittensnummer).toBeNull()
expect(result.local_state).toBeNull()
})
})
describe('Skatteverket tools: scopes', () => {
it('maps the five tools to the right scopes', () => {
expect(TOOL_SCOPE_MAP.gnubok_vat_declaration_validate).toBe('compliance:read')
@@ -40,6 +40,30 @@ function makeLineRow(i: number, overrides: Record<string, unknown> = {}) {
}
}
/**
* Enqueue the two pages the two-step entry-lines fetch reads
* (lib/bookkeeping/entry-lines.ts): the parent entries first, then the bare
* lines keyed by journal_entry_id. Fixtures stay embed-shaped; the helper
* reattaches the parent under `journal_entries`, so the tool sees exactly
* what the old `journal_entries!inner` embed produced.
*/
function enqueueMatchedLines(
enqueue: (result: { data?: unknown; error?: unknown }) => void,
rows: ReturnType<typeof makeLineRow>[],
) {
const entries = [
...new Map(rows.map((r) => [r.journal_entries.id, r.journal_entries])).values(),
]
enqueue({ data: entries, error: null })
enqueue({
data: rows.map(({ journal_entries: parent, ...line }) => ({
...line,
journal_entry_id: parent.id,
})),
error: null,
})
}
// ── Registration + contracts ─────────────────────────────────────────────────
describe('gnubok_tag_journal_lines registration', () => {
@@ -96,7 +120,7 @@ describe('gnubok_tag_journal_lines: filter gates', () => {
it('throws a helpful error when no posted lines match', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { dimensions_enabled: false }, error: null }) // resolveDimensionBags passthrough
enqueue({ data: [], error: null }) // line match query → empty
enqueue({ data: [], error: null }) // entry match query, empty: no line query runs
await expect(
tagJournalLines.execute(
@@ -111,7 +135,7 @@ describe('gnubok_tag_journal_lines: filter gates', () => {
it('throws asking to narrow the filter when more than 500 lines match', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { dimensions_enabled: false }, error: null })
enqueue({ data: Array.from({ length: 501 }, (_, i) => makeLineRow(i)), error: null })
enqueueMatchedLines(enqueue, Array.from({ length: 501 }, (_, i) => makeLineRow(i)))
await expect(
tagJournalLines.execute(
@@ -125,6 +149,50 @@ describe('gnubok_tag_journal_lines: filter gates', () => {
const insertCalls = (supabase.from as ReturnType<typeof vi.fn>).mock.calls
expect(insertCalls.some((args) => args[0] === 'pending_operations')).toBe(false)
})
it('stops fetching line chunks as soon as the cap is exceeded (no full-ledger walk)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { dimensions_enabled: false }, error: null })
// 300 matching entries → 3 line chunks of up to 100 entry ids each. The
// FIRST chunk alone already returns 501 lines (past RETAG_MAX_LINES), so
// chunks 2-3 must never be fetched: the outcome is decided.
enqueue({
data: Array.from({ length: 300 }, (_, i) => ({
id: `je-${i}`,
entry_date: '2024-03-01',
voucher_number: i,
voucher_series: 'A',
status: 'posted',
})),
error: null,
})
enqueue({
data: Array.from({ length: 501 }, (_, i) => ({
id: `00000000-0000-4000-8000-${String(i).padStart(12, '0')}`,
journal_entry_id: `je-${i % 300}`,
account_number: '4010',
debit_amount: 250,
credit_amount: 0,
sort_order: 1,
})),
error: null,
})
await expect(
tagJournalLines.execute(
{ dimensions: { '6': 'P01' }, reason: 'Retro-taggning', filters: { only_untagged: true } },
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/fler än 500 rader/)
const fromCalls = (supabase.from as ReturnType<typeof vi.fn>).mock.calls
// Exactly one journal_entry_lines query ran: the overflow short-circuits
// before the second and third chunks.
expect(fromCalls.filter((args) => args[0] === 'journal_entry_lines')).toHaveLength(1)
expect(fromCalls.some((args) => args[0] === 'pending_operations')).toBe(false)
})
})
// ── Staging ──────────────────────────────────────────────────────────────────
@@ -133,7 +201,7 @@ describe('gnubok_tag_journal_lines: staging', () => {
it('stages a retag_line_dimensions op with matched line_ids + preview sample', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { dimensions_enabled: false }, error: null }) // passthrough (free-text era)
enqueue({ data: [makeLineRow(1), makeLineRow(2)], error: null }) // line match query
enqueueMatchedLines(enqueue, [makeLineRow(1), makeLineRow(2)]) // line match query
enqueue({ data: { id: 'op-retag-1' }, error: null }) // pending_operations insert
const result = (await tagJournalLines.execute(
@@ -192,7 +260,7 @@ describe('gnubok_tag_journal_lines: staging', () => {
],
error: null,
})
enqueue({ data: [makeLineRow(1)], error: null }) // line match query
enqueueMatchedLines(enqueue, [makeLineRow(1)]) // line match query
enqueue({ data: { id: 'op-retag-2' }, error: null }) // pending_operations insert
const result = (await tagJournalLines.execute(
@@ -259,7 +327,7 @@ describe('gnubok_tag_journal_lines: staging', () => {
it('dry_run previews the match without inserting a pending operation', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { dimensions_enabled: false }, error: null })
enqueue({ data: [makeLineRow(1)], error: null }) // line match query
enqueueMatchedLines(enqueue, [makeLineRow(1)]) // line match query
const result = (await tagJournalLines.execute(
{
@@ -0,0 +1,216 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
import { TOOL_SCOPE_MAP } from '@/lib/auth/api-keys'
import { OPERATION_RISK_TIERS } from '@/lib/pending-operations/risk-tiers'
import { tools } from '../server'
const INVOICE_ID = '22222222-2222-4222-8222-222222222222'
const tool = () => tools.find((candidate) => candidate.name === 'gnubok_update_invoice')!
function draftInvoice(overrides: Record<string, unknown> = {}) {
return {
id: INVOICE_ID,
invoice_number: null,
status: 'draft',
document_type: 'invoice',
journal_entry_id: null,
is_self_billed: false,
credited_invoice_id: null,
total: 12500,
currency: 'SEK',
customer: { name: 'Acme AB' },
...overrides,
}
}
describe('gnubok_update_invoice: registration', () => {
it('is a strict, staged invoices:write tool at medium risk', () => {
expect(tool()).toBeDefined()
expect(tool().inputSchema.additionalProperties).toBe(false)
expect(tool().annotations.readOnlyHint).toBe(false)
expect(tool().annotations.destructiveHint).toBe(false)
expect(tool().annotations.idempotentHint).toBe(true)
expect(tool().catalogVisibility).toBe('search')
expect(TOOL_SCOPE_MAP.gnubok_update_invoice).toBe('invoices:write')
expect(OPERATION_RISK_TIERS.update_invoice).toBe('medium')
})
it('returns the staged-operation envelope (staged completion signal)', () => {
const schema = tool().outputSchema as { properties?: Record<string, unknown>; required?: string[] }
expect(schema?.properties?.staged).toBeDefined()
expect(schema?.required).toContain('staged')
})
it('keeps its description within the 280-char budget and declares staging', () => {
expect(tool().description.length).toBeLessThanOrEqual(280)
expect(tool().description).toMatch(/stag(e|ing)/i)
})
it('does not accept structural or server-controlled fields', () => {
const properties = tool().inputSchema.properties as Record<string, unknown>
for (const forbidden of ['customer_id', 'currency', 'document_type', 'invoice_number', 'status']) {
expect(properties, `must not expose ${forbidden}`).not.toHaveProperty(forbidden)
}
})
})
describe('gnubok_update_invoice: validation and staging', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('requires invoice_id', async () => {
const { supabase } = createQueuedMockSupabase()
await expect(
tool().execute({ notes: 'x' }, 'company-1', 'user-1', supabase as never),
).rejects.toThrow(/invoice_id/i)
expect(supabase.from).not.toHaveBeenCalled()
})
it('requires at least one changed field', async () => {
const { supabase } = createQueuedMockSupabase()
await expect(
tool().execute(
{ invoice_id: INVOICE_ID, dry_run: true },
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/at least one/i)
expect(supabase.from).not.toHaveBeenCalled()
})
it('rejects an empty items array (full-replace needs at least one line)', async () => {
const { supabase } = createQueuedMockSupabase()
await expect(
tool().execute(
{ invoice_id: INVOICE_ID, items: [] },
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/non-empty/i)
expect(supabase.from).not.toHaveBeenCalled()
})
it('rejects an item without a positive quantity before querying', async () => {
const { supabase } = createQueuedMockSupabase()
await expect(
tool().execute(
{
invoice_id: INVOICE_ID,
items: [{ description: 'Konsultation', quantity: 0, unit: 'tim', unit_price: 1000 }],
},
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/quantity/i)
expect(supabase.from).not.toHaveBeenCalled()
})
it('fails when the invoice is outside the selected company', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null })
await expect(
tool().execute(
{ invoice_id: INVOICE_ID, notes: 'Ny anteckning' },
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/not found/i)
})
it.each([
['sent invoice', { status: 'sent' }],
['paid invoice', { status: 'paid' }],
['draft with a posted verifikat', { journal_entry_id: 'je-1' }],
['self-billed draft', { is_self_billed: true }],
['credit-note draft', { credited_invoice_id: '33333333-3333-4333-8333-333333333333' }],
])('refuses a %s at staging time', async (_label, overrides) => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: draftInvoice(overrides) })
await expect(
tool().execute(
{ invoice_id: INVOICE_ID, notes: 'Ny anteckning' },
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/not an editable draft/i)
expect(supabase.from).toHaveBeenCalledTimes(1)
})
it('returns a dry-run preview without staging', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: draftInvoice() })
const result = (await tool().execute(
{ invoice_id: INVOICE_ID, due_date: '2026-08-31', dry_run: true },
'company-1',
'user-1',
supabase as never,
)) as { staged: boolean; dry_run?: boolean; preview: Record<string, unknown> }
expect(result.staged).toBe(false)
expect(result.dry_run).toBe(true)
expect(result.preview).toMatchObject({
invoice_id: INVOICE_ID,
customer_name: 'Acme AB',
changes: { due_date: '2026-08-31' },
})
expect(supabase.from).toHaveBeenCalledTimes(1)
})
it('stages a header edit for approval', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: draftInvoice() })
enqueue({ data: { id: 'op-invoice-1' } })
const result = (await tool().execute(
{ invoice_id: INVOICE_ID, notes: 'Uppdaterad anteckning' },
'company-1',
'user-1',
supabase as never,
)) as { staged: boolean; operation_id?: string; risk_level: string }
expect(result).toMatchObject({
staged: true,
operation_id: 'op-invoice-1',
risk_level: 'medium',
})
expect(supabase.from).toHaveBeenNthCalledWith(2, 'pending_operations')
})
it('stages a full item replace with the replace marker in the preview', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: draftInvoice() })
enqueue({ data: { id: 'op-invoice-2' } })
const result = (await tool().execute(
{
invoice_id: INVOICE_ID,
items: [
{ description: 'Konsultation', quantity: 2, unit: 'tim', unit_price: 1000, vat_rate: 25 },
],
},
'company-1',
'user-1',
supabase as never,
)) as { staged: boolean; preview: Record<string, unknown> }
expect(result.staged).toBe(true)
expect(result.preview).toMatchObject({
invoice_id: INVOICE_ID,
items_replace: true,
item_count: 1,
})
})
})
@@ -0,0 +1,267 @@
/**
* gnubok_vat_close_check: momsdeklaration completeness gate.
*
* Regression cover for the pre-flight hole this tool used to have. Its only
* declaration check was `acquisitionAndImportBase > 0 && ruta48 === 0`, and
* ruta 48 aggregates 2641/2642/2645/2646/2647/2649: a single ordinary domestic
* receipt in the period made it unreachable, and there was no basbelopp check
* (rutor 20-24 against rutor 30-32) at all. A declaration with fiktiv moms and
* no underlag, the FK004 shape Skatteverket rejects, came back as "Klart för
* stängning".
*
* The tool now runs the SHARED core checks (lib/reports/vat-declaration-checks)
* over the full SKV 4700 projection, folded through the same filing gate the
* web UI uses, so these assertions are really asserting "MCP agrees with the
* web UI".
*
* Only the bank reconciliation is mocked: it queries shapes this fixture has no
* opinion about, and an unreconciled bank would add an unrelated blocker.
*/
import { describe, it, expect, vi } from 'vitest'
vi.mock('@/lib/reconciliation/bank-reconciliation', () => ({
getReconciliationStatus: vi.fn(async () => ({
is_reconciled: true,
difference: 0,
unmatched_transaction_count: 0,
unmatched_gl_line_count: 0,
})),
}))
import { computeVatCloseCheck } from '../server'
interface MockLine {
entry?: string
account_number: string
debit_amount?: number
credit_amount?: number
source_type?: string | null
}
/**
* Table-routed Supabase double. journal_entries / journal_entry_lines serve the
* fixture; everything else (transactions, supplier_invoices, company_settings)
* comes back empty so no unrelated blocker fires.
*/
function mockSupabase(lines: MockLine[]) {
const entries = [
...new Map(
lines.map((l, i) => {
const id = l.entry ?? `entry-${i}`
return [
id,
{
id,
source_type: l.source_type ?? 'manual',
voucher_series: 'A',
voucher_number: 100 + i,
entry_date: '2026-01-15',
description: 'Fixture',
status: 'posted',
},
]
}),
).values(),
]
const bareLines = lines.map((l, i) => ({
id: `line-${String(i).padStart(4, '0')}`,
journal_entry_id: l.entry ?? `entry-${i}`,
account_number: l.account_number,
debit_amount: l.debit_amount ?? 0,
credit_amount: l.credit_amount ?? 0,
}))
const makeChain = (rows: unknown[]): Record<string, unknown> => {
const chain: Record<string, unknown> = {}
const settled = { data: rows, error: null, count: rows.length }
chain.range = () => settled
chain.single = async () => ({ data: null, error: null })
chain.maybeSingle = async () => ({ data: null, error: null })
chain.then = (resolve: (v: unknown) => void) => resolve(settled)
for (const m of [
'order', 'lte', 'gte', 'neq', 'in', 'eq', 'is', 'select',
'limit', 'contains', 'filter', 'not', 'or',
]) {
chain[m] = () => chain
}
return chain
}
return {
from: (table: string) => {
if (table === 'journal_entries') return makeChain(entries)
if (table === 'journal_entry_lines') return makeChain(bareLines)
return makeChain([])
},
// The missing-underlag blocker reads the verifikat_without_documents RPC,
// which answers with an envelope rather than a row set. This fixture has no
// opinion about underlag, so it answers "none missing"; the predicate itself
// is covered by vat-close-check-missing-underlag.test.ts.
rpc: (fn: string) =>
fn === 'verifikat_without_documents'
? Promise.resolve({ data: { ok: true, total_count: 0, verifikat: [] }, error: null })
: makeChain([]),
} as never
}
const PERIOD = { period_type: 'monthly', year: 2026, period: 1 }
describe('gnubok_vat_close_check: declaration completeness', () => {
it('refuses the #1164 declaration: fiktiv moms on 2614/2645 with no basbelopp on 44xx/45xx', async () => {
// Both VAT legs of a reverse-charge purchase booked, but the cost went
// straight to 6540 instead of the 4535 basis account, so rutor 20-24 stay
// empty while ruta 30 carries 1 250 kr. This is FK004.
const result = await computeVatCloseCheck(
PERIOD,
'company-1',
mockSupabase([
{ entry: 'e1', account_number: '3001', credit_amount: 10000 },
{ entry: 'e1', account_number: '2611', credit_amount: 2500 },
{ entry: 'e1', account_number: '1510', debit_amount: 12500 },
{ entry: 'e2', account_number: '6540', debit_amount: 5000 },
{ entry: 'e2', account_number: '2440', credit_amount: 5000 },
{ entry: 'e2', account_number: '2614', credit_amount: 1250 },
{ entry: 'e2', account_number: '2645', debit_amount: 1250 },
]),
)
expect(result.ready_to_close).toBe(false)
expect(result.summary).not.toMatch(/Klart för stängning/)
expect(result.summary).toMatch(/ofullständigt/)
const codes = result.declaration_checks.map((c) => c.code)
expect(codes).toContain('RC_BASIS_MISSING')
const finding = result.declaration_checks.find((c) => c.code === 'RC_BASIS_MISSING')!
expect(finding.status).toBe('ERROR')
expect(finding.message).toMatch(/FK004/)
const blocker = result.blockers.find((b) => b.check_code === 'RC_BASIS_MISSING')!
expect(blocker.kind).toBe('reverse_charge_input_missing')
expect(blocker.severity).toBe('high')
// The old mirror could not see this at all: ruta 48 carries the 2645 leg.
expect(result.rutor.ruta48).toBe(1250)
})
it('one ordinary domestic receipt no longer suppresses the reverse-charge check', async () => {
// The exact unreachability bug: 2641 from a normal receipt puts 200 kr in
// ruta 48, so `ruta48 === 0` was false and the whole period went unchecked
// even though the reverse charge is booked on one side only.
const result = await computeVatCloseCheck(
PERIOD,
'company-1',
mockSupabase([
{ entry: 'e1', account_number: '6210', debit_amount: 800 },
{ entry: 'e1', account_number: '2641', debit_amount: 200 },
{ entry: 'e1', account_number: '1930', credit_amount: 1000 },
{ entry: 'e2', account_number: '6540', debit_amount: 5000 },
{ entry: 'e2', account_number: '2440', credit_amount: 5000 },
{ entry: 'e2', account_number: '2614', credit_amount: 1250 },
]),
)
expect(result.rutor.ruta48).toBe(200)
expect(result.rutor.ruta48).not.toBe(0)
expect(result.ready_to_close).toBe(false)
const codes = result.declaration_checks.map((c) => c.code)
expect(codes).toContain('RC_BASIS_MISSING')
expect(codes).toContain('RC_INPUT_VAT_MISMATCH')
// The input-VAT mismatch stays advisory (partial deduction can explain a
// shortfall); the missing basbelopp is what blocks.
const mismatch = result.blockers.find((b) => b.check_code === 'RC_INPUT_VAT_MISMATCH')!
expect(mismatch.severity).toBe('medium')
expect(
result.blockers.some((b) => b.check_code === 'RC_BASIS_MISSING' && b.severity === 'high'),
).toBe(true)
})
// The check the shared core sharpened, now reachable from MCP: rutor 30-32 are
// compared against the reverse-charge INPUT accounts (2645/2647), not against
// the ruta 48 aggregate that ordinary debiterad ingående moms inflates.
it('sees a completely missing beräknad ingående moms behind a larger ruta 48', async () => {
const result = await computeVatCloseCheck(
PERIOD,
'company-1',
mockSupabase([
// EU services purchase, correctly booked on the underlag side: basis on
// 4535 (ruta 21 = 200 000) and fiktiv utgående moms on 2614 (ruta 30 =
// 50 000), so there is no FK004 finding to hide behind. The beräknad
// ingående moms on 2645 was never booked.
{ entry: 'e1', account_number: '4535', debit_amount: 200000 },
{ entry: 'e1', account_number: '2440', credit_amount: 200000 },
{ entry: 'e1', account_number: '2614', credit_amount: 50000 },
// Ordinary domestic purchases: 60 000 kr of debiterad ingående moms.
// This is the mask: ruta 48 (60 000) stays ABOVE the RC output (50 000).
{ entry: 'e2', account_number: '5410', debit_amount: 240000 },
{ entry: 'e2', account_number: '2641', debit_amount: 60000 },
{ entry: 'e2', account_number: '1930', credit_amount: 300000 },
]),
)
expect(result.rutor.ruta30).toBe(50000)
expect(result.rutor.ruta48).toBe(60000)
// Precisely the state the aggregate comparison called fine.
expect(result.rutor.ruta48).toBeGreaterThan(result.rutor.ruta30)
const mismatch = result.declaration_checks.find((c) => c.code === 'RC_INPUT_VAT_MISMATCH')!
expect(mismatch).toBeDefined()
// Still a WARNING, deliberately: limited avdragsrätt (blandad verksamhet,
// ML 13 kap 18/24-25 §§) makes a shortfall correct for some filers.
expect(mismatch.status).toBe('WARNING')
expect(mismatch.message).toMatch(/2645/)
// \s, not a literal space: sv-SE groups thousands with a no-break space.
expect(mismatch.message).toMatch(/50\s000 kr saknas/)
expect(result.declaration_checks.map((c) => c.code)).not.toContain('RC_BASIS_MISSING')
// A warning is not a blocker: ruta 48 is non-zero, so a partial-deduction
// story exists and the noInputVatAtAll escalation must stay out of it.
const blocker = result.blockers.find((b) => b.check_code === 'RC_INPUT_VAT_MISMATCH')!
expect(blocker.kind).toBe('reverse_charge_input_missing')
expect(blocker.severity).toBe('medium')
})
it('still blocks import output VAT with no deductible input at all (ruta 60 without ruta 48)', async () => {
// The one case the shared checks do not compare against ruta 48: keep the
// local escalation so removing the old mirror loses no coverage.
const result = await computeVatCloseCheck(
PERIOD,
'company-1',
mockSupabase([
{ entry: 'e1', account_number: '4545', debit_amount: 10000 },
{ entry: 'e1', account_number: '2440', credit_amount: 10000 },
{ entry: 'e1', account_number: '2615', credit_amount: 2500 },
]),
)
expect(result.ready_to_close).toBe(false)
const blocker = result.blockers.find((b) => b.kind === 'reverse_charge_input_missing')!
expect(blocker.severity).toBe('high')
expect(blocker.message).toMatch(/ruta 60\/61\/62/)
})
it('a clean period still passes: reverse charge booked with basbelopp AND both VAT legs', async () => {
const result = await computeVatCloseCheck(
PERIOD,
'company-1',
mockSupabase([
{ entry: 'e1', account_number: '3001', credit_amount: 10000 },
{ entry: 'e1', account_number: '2611', credit_amount: 2500 },
{ entry: 'e1', account_number: '1510', debit_amount: 12500 },
// EU services purchase, correctly booked: basis on 4535 (ruta 21),
// fiktiv utgående på 2614 (ruta 30), beräknad ingående på 2645 (ruta 48).
{ entry: 'e2', account_number: '4535', debit_amount: 5000 },
{ entry: 'e2', account_number: '2440', credit_amount: 5000 },
{ entry: 'e2', account_number: '2614', credit_amount: 1250 },
{ entry: 'e2', account_number: '2645', debit_amount: 1250 },
]),
)
expect(result.declaration_checks).toEqual([])
expect(result.blockers.filter((b) => b.severity === 'high')).toEqual([])
expect(result.ready_to_close).toBe(true)
expect(result.summary).toMatch(/Klart för stängning/)
expect(result.rutor.ruta49).toBe(2500)
})
})
@@ -0,0 +1,143 @@
/**
* gnubok_vat_close_check: the missing-underlag blocker delegates to the
* `verifikat_without_documents` RPC.
*
* Regression cover for the hand-rolled scan that used to sit in
* countMissingUnderlagInPeriod. It filtered journal entries on
* `.in('source_type', ['bank_transaction', 'supplier_invoice', 'receipt'])`,
* and the last two literals are not members of the journal_entries.source_type
* CHECK at all: PostgREST matched zero rows, so supplier-invoice verifikat
* never surfaced and the momsperiod got a clean bill of health on exactly the
* entry types most likely to be missing their underlag.
*
* The honest assertion here is therefore DELEGATION, not a re-derived count:
* the journal fixture below is EMPTY, so any local scan would find nothing.
* A non-zero blocker count can only have come from the RPC envelope, which is
* the same SQL predicate behind the web worklist badge and
* gnubok_list_verifikat_without_documents (waivers, is_current_version and
* the BFL 5 kap 7 § payment-verifikat hänvisning included).
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('@/lib/reconciliation/bank-reconciliation', () => ({
getReconciliationStatus: vi.fn(async () => ({
is_reconciled: true,
difference: 0,
unmatched_transaction_count: 0,
unmatched_gl_line_count: 0,
})),
}))
import { computeVatCloseCheck } from '../server'
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
const PERIOD = { period_type: 'monthly', year: 2026, period: 1 }
/**
* Table-routed Supabase double with an EMPTY ledger: every table read settles
* to zero rows, so no other blocker fires and no local scan can produce a
* count. The verifikat_without_documents RPC answers with the envelope shape
* the real function returns, keyed on p_since so the two page-of-one calls
* (period start, day after period end) get their own totals.
*/
function mockSupabase(rpcResults: Record<string, unknown>) {
const makeChain = (rows: unknown[]): Record<string, unknown> => {
const chain: Record<string, unknown> = {}
const settled = { data: rows, error: null, count: rows.length }
chain.range = () => settled
chain.single = async () => ({ data: null, error: null })
chain.maybeSingle = async () => ({ data: null, error: null })
chain.then = (resolve: (v: unknown) => void) => resolve(settled)
for (const m of [
'order', 'lte', 'gte', 'neq', 'in', 'eq', 'is', 'select',
'limit', 'contains', 'filter', 'not', 'or',
]) {
chain[m] = () => chain
}
return chain
}
const rpc = vi.fn((fn: string, params?: Record<string, unknown>) => {
if (fn === 'verifikat_without_documents') {
const since = String(params?.p_since ?? '')
return Promise.resolve({ data: rpcResults[since] ?? { ok: true, total_count: 0, verifikat: [] }, error: null })
}
return makeChain([])
})
return {
supabase: { from: () => makeChain([]), rpc } as never,
rpc,
}
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('gnubok_vat_close_check: missing-underlag comes from the shared RPC predicate', () => {
it('surfaces the RPC total as the blocker count even though no journal rows exist locally', async () => {
// 5 verifikat missing underlag since period start, 2 of them dated after
// the period: 3 in the period. An empty local ledger cannot explain a
// non-zero count, so this pins the RPC as the source of truth. In prod the
// RPC total includes supplier-invoice verifikat, the type the old local
// scan structurally excluded.
const { supabase, rpc } = mockSupabase({
'2026-01-01': { ok: true, total_count: 5, verifikat: [] },
'2026-02-01': { ok: true, total_count: 2, verifikat: [] },
})
const result = await computeVatCloseCheck(PERIOD, COMPANY_ID, supabase)
const underlagCalls = rpc.mock.calls.filter(([fn]) => fn === 'verifikat_without_documents')
expect(underlagCalls).toHaveLength(2)
// Same predicate parameters as the web worklist badge: the 4 000 kr
// förenklad faktura floor (ML 17 kap 26-28 §) and a page of one, because
// only total_count is consumed.
expect(underlagCalls.map(([, params]) => params)).toEqual(
expect.arrayContaining([
{ p_company_id: COMPANY_ID, p_since: '2026-01-01', p_min_amount: 4000, p_limit: 1, p_offset: 0 },
{ p_company_id: COMPANY_ID, p_since: '2026-02-01', p_min_amount: 4000, p_limit: 1, p_offset: 0 },
]),
)
const blocker = result.blockers.find((b) => b.kind === 'missing_high_value_receipts')!
expect(blocker).toBeDefined()
expect(blocker.count).toBe(3)
expect(blocker.severity).toBe('medium')
expect(blocker.message).toMatch(/3 verifikat över 4000 kr saknar underlag/)
// The hint routes the agent to the SAME predicate's listing tool with the
// same filters, so what it lists is exactly what was counted.
expect(blocker.hint).toContain('gnubok_list_verifikat_without_documents')
expect(blocker.hint).toContain('since=2026-01-01')
expect(blocker.hint).toContain('min_amount=4000')
// Missing underlag is advisory for the momsdeklaration itself (medium, not
// high): it must warn, not block the close.
expect(result.ready_to_close).toBe(true)
})
it('reports no blocker when the RPC counts nothing in the period', async () => {
// Everything missing an underlag is dated after the period: 4 - 4 = 0.
const { supabase } = mockSupabase({
'2026-01-01': { ok: true, total_count: 4, verifikat: [] },
'2026-02-01': { ok: true, total_count: 4, verifikat: [] },
})
const result = await computeVatCloseCheck(PERIOD, COMPANY_ID, supabase)
expect(result.blockers.find((b) => b.kind === 'missing_high_value_receipts')).toBeUndefined()
})
it('fails loudly when the RPC refuses, instead of reporting a clean period', async () => {
// A silent zero here would be the original bug in a new coat: the check
// must never pass because the predicate could not run.
const { supabase } = mockSupabase({
'2026-01-01': { ok: false, code: 'COMPANY_NOT_FOUND' },
})
await expect(computeVatCloseCheck(PERIOD, COMPANY_ID, supabase)).rejects.toThrow(
/verifikat_without_documents failed: COMPANY_NOT_FOUND/,
)
})
})
@@ -0,0 +1,257 @@
/**
* gnubok_vat_declaration_validate: arithmetic vs completeness.
*
* The tool used to be a pure proxy for Skatteverket's POST /kontrollera, which
* lib/reports/vat-declaration-checks.ts documents as arithmetic-only:
*
* "Skatteverket's 'validering' only confirms that the payload is internally
* arithmetically consistent: it does NOT confirm that the declaration
* reflects reality. A declaration of all zeros validates fine; one with
* output VAT but no underlying purchases validates fine too, until the
* gateway-level FK004 rule fires."
*
* So a green kontrollresultat was being handed to the agent as if it meant
* "safe to file". The tool now also runs the local completeness checks and
* reports the two verdicts under separate keys.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import type { VatDeclarationRutor } from '@/types'
const mockSkvRequest = vi.fn()
vi.mock('@/extensions/general/skatteverket/lib/api-client', async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>
return { ...actual, skvRequest: (...a: unknown[]) => mockSkvRequest(...a) }
})
vi.mock('@/extensions/general/skatteverket/lib/declaration-prep', () => ({
buildMomsuppgift: vi.fn(async () => ({
redovisare: '165560000000',
redovisningsperiod: '202601',
momsuppgift: { summaMoms: 2500 },
})),
resolveRedovisare: vi.fn(async () => '165560000000'),
}))
vi.mock('@/extensions/general/skatteverket/lib/audit', () => ({
writeSkatteverketAudit: vi.fn(),
}))
// The declaration figures are the unit under test here, so they are supplied
// directly instead of being reconstructed from a ledger fixture.
const mockCalculateVatDeclaration = vi.fn()
vi.mock('@/lib/reports/vat-declaration', async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>
return {
...actual,
calculateVatDeclaration: (...a: unknown[]) => mockCalculateVatDeclaration(...a),
}
})
// The per-verifikat FK004 scan has its own coverage in lib/reports/__tests__;
// here it must simply not add findings of its own.
vi.mock('@/lib/reports/rc-basis-gaps', () => ({
findRcBasisGaps: vi.fn(async () => []),
}))
import { tools } from '../server'
const validate = tools.find((t) => t.name === 'gnubok_vat_declaration_validate')!
/** Full SKV 4700 projection with ruta 49 derived, so SUMMA_MOMS_DRIFT stays quiet. */
function makeRutor(partial: Partial<VatDeclarationRutor> = {}): VatDeclarationRutor {
const base: VatDeclarationRutor = {
ruta05: 0, ruta06: 0, ruta07: 0, ruta08: 0,
ruta10: 0, ruta11: 0, ruta12: 0,
ruta20: 0, ruta21: 0, ruta22: 0, ruta23: 0, ruta24: 0,
ruta30: 0, ruta31: 0, ruta32: 0,
ruta35: 0, ruta36: 0, ruta37: 0, ruta38: 0,
ruta39: 0, ruta40: 0, ruta41: 0, ruta42: 0,
ruta48: 0, ruta49: 0,
ruta50: 0, ruta60: 0, ruta61: 0, ruta62: 0,
...partial,
}
base.ruta49 =
base.ruta10 + base.ruta11 + base.ruta12 +
base.ruta30 + base.ruta31 + base.ruta32 +
base.ruta60 + base.ruta61 + base.ruta62 -
base.ruta48
return base
}
/**
* `rcInputAccountTotals` is the 2645/2647 pair the real calculateVatDeclaration
* carries alongside the rutor. Omitting it here is not neutral: the tool then
* runs RC_INPUT_VAT_MISMATCH against the ruta 48 aggregate instead of the
* reverse-charge input accounts, which is exactly the weaker form.
*/
function setDeclaration(
rutor: VatDeclarationRutor,
rcInput?: Record<string, { debit: number; credit: number }>,
) {
mockCalculateVatDeclaration.mockResolvedValue({
rutor,
...(rcInput ? { rcInputAccountTotals: rcInput } : {}),
})
}
/** Period debit balances for 2645/2647, in the wire shape the declaration uses. */
const rcInput = (accounts: Record<string, number>) =>
Object.fromEntries(
['2645', '2647'].map((a) => [a, { debit: accounts[a] ?? 0, credit: 0 }]),
)
/** A period that would be filed cleanly: RC basis, fiktiv moms and input all present. */
const CLEAN = makeRutor({ ruta05: 10000, ruta10: 2500, ruta21: 5000, ruta30: 1250, ruta48: 1250 })
/** The FK004 shape: fiktiv moms in ruta 30 with no beskattningsunderlag behind it. */
const INCOMPLETE = makeRutor({ ruta05: 10000, ruta10: 2500, ruta30: 1250, ruta48: 1450 })
const ARGS = { period_type: 'monthly', year: 2026, period: 1 }
const supabase = {} as never
function skvOk(body: unknown = { kontrollResultat: { status: 'OK', resultat: [] } }) {
mockSkvRequest.mockResolvedValue({ ok: true, status: 200, json: async () => body })
}
let prevEnv: string | undefined
beforeEach(() => {
vi.clearAllMocks()
prevEnv = process.env.SKATTEVERKET_ENABLED
process.env.SKATTEVERKET_ENABLED = 'true'
})
afterEach(() => {
if (prevEnv === undefined) delete process.env.SKATTEVERKET_ENABLED
else process.env.SKATTEVERKET_ENABLED = prevEnv
})
interface ValidateResult {
arithmetic_ok: boolean
completeness_ok: boolean
completeness_checks: { code: string; status: string; message: string; rutor: string[] }[]
summary: string
kontrollresultat: unknown
}
async function run(): Promise<ValidateResult> {
return (await validate.execute(ARGS, 'company-1', 'user-1', supabase, {
type: 'api_key',
})) as ValidateResult
}
describe('gnubok_vat_declaration_validate', () => {
it('separates arithmetic (Skatteverket) from completeness (local) on an incomplete declaration', async () => {
setDeclaration(INCOMPLETE)
skvOk()
const result = await run()
// Skatteverket is happy: the boxes add up.
expect(result.arithmetic_ok).toBe(true)
// We are not: ruta 30 has no basbelopp in rutor 20-24.
expect(result.completeness_ok).toBe(false)
const codes = result.completeness_checks.map((c) => c.code)
expect(codes).toContain('RC_BASIS_MISSING')
expect(
result.completeness_checks.find((c) => c.code === 'RC_BASIS_MISSING')?.status,
).toBe('ERROR')
// The summary must not let a green SKV result read as an all-clear.
expect(result.summary).toMatch(/Skatteverket/)
expect(result.summary).toMatch(/ofullständig/)
})
it('reports both green on a clean period, and still calls only /kontrollera', async () => {
setDeclaration(CLEAN)
skvOk()
const result = await run()
expect(result.arithmetic_ok).toBe(true)
expect(result.completeness_ok).toBe(true)
expect(result.completeness_checks).toEqual([])
expect(mockSkvRequest).toHaveBeenCalledTimes(1)
expect(mockSkvRequest.mock.calls[0][3]).toMatch(/^\/kontrollera\//)
})
// The masking case: rutor 30-32 are compared against 2645/2647, so ordinary
// debiterad ingående moms sitting in the same ruta 48 can no longer hide a
// missing beräknad ingående moms on an otherwise well-formed declaration.
it('reports incomplete when no 2645/2647 backs the fiktiv utgående moms, even though ruta 48 is larger', async () => {
// Underlag correctly booked (ruta 21 = 200 000 against ruta 30 = 50 000), so
// this is NOT FK004; ruta 48 carries 60 000 kr of ordinary 2641 only.
const masked = makeRutor({ ruta21: 200000, ruta30: 50000, ruta48: 60000 })
setDeclaration(masked, rcInput({}))
skvOk()
const result = await run()
// Skatteverket still validates it: the boxes add up.
expect(result.arithmetic_ok).toBe(true)
const mismatch = result.completeness_checks.find((c) => c.code === 'RC_INPUT_VAT_MISMATCH')
expect(mismatch?.status).toBe('WARNING')
// \s, not a literal space: sv-SE groups thousands with a no-break space.
expect(mismatch?.message).toMatch(/50\s000 kr saknas/)
expect(result.completeness_checks.map((c) => c.code)).not.toContain('RC_BASIS_MISSING')
// A WARNING does not block filing (isFilingBlocked reads ERROR only): the
// shortfall is legally correct under limited avdragsrätt.
expect(result.completeness_ok).toBe(true)
// Without the pair the same declaration comes back with nothing to say,
// which is what the ruta 48 comparison alone does.
setDeclaration(masked)
skvOk()
expect((await run()).completeness_checks).toEqual([])
})
it('stays quiet when 2645 mirrors the fiktiv utgående moms exactly', async () => {
setDeclaration(
makeRutor({ ruta21: 200000, ruta30: 50000, ruta48: 110000 }),
rcInput({ '2645': 50000 }),
)
skvOk()
const result = await run()
expect(result.completeness_checks).toEqual([])
expect(result.completeness_ok).toBe(true)
})
it('arithmetic_ok is false when Skatteverket returns an ERROR, independently of completeness', async () => {
setDeclaration(CLEAN)
skvOk({
kontrollResultat: {
status: 'ERROR',
resultat: [{ kod: '49', status: 'ERROR', beskrivning: 'Summa moms stämmer inte' }],
},
})
const result = await run()
expect(result.arithmetic_ok).toBe(false)
expect(result.completeness_ok).toBe(true)
expect(result.summary).toMatch(/1 fel/)
})
it('reads an unwrapped kontrollresultat body too (no silent all-clear on a shape change)', async () => {
setDeclaration(CLEAN)
skvOk({ status: 'ERROR', resultat: [{ kod: '20', status: 'ERROR', beskrivning: 'fel' }] })
const result = await run()
expect(result.arithmetic_ok).toBe(false)
})
it('declares the two verdicts in its output schema and stays inside the description budget', () => {
const schema = validate.outputSchema as {
properties: Record<string, unknown>
required: string[]
}
expect(schema.properties).toHaveProperty('arithmetic_ok')
expect(schema.properties).toHaveProperty('completeness_ok')
expect(schema.properties).toHaveProperty('completeness_checks')
expect(schema.required).toContain('arithmetic_ok')
expect(schema.required).toContain('completeness_ok')
expect(validate.description.length).toBeLessThanOrEqual(280)
// The description must not imply Skatteverket checked the underlag.
expect(validate.description).toMatch(/completeness/i)
})
})
@@ -14,27 +14,47 @@ interface MockLine {
debit_amount: number
credit_amount: number
journal_entry_id?: string
journal_entries?: { source_type: string | null } | Array<{ source_type: string | null }>
journal_entries?: { source_type: string | null }
}
function mockSupabaseWithLines(lines: MockLine[]) {
// Build a chain that matches the call path in computeVatReport:
// .from('journal_entry_lines').select(...).eq(...).in(...).gte(...).lte(...).range(from, to)
// computeVatReport now paginates via fetchAllRows, so the terminal call is
// `.range(from, to)`. Returning all lines on the first page (always < the
// 1000-row PAGE_SIZE for these fixtures) makes fetchAllRows stop after one page.
const terminal = { data: lines, error: null }
const chain: Record<string, () => unknown> = {}
chain.range = () => terminal
chain.order = () => chain
chain.lte = () => chain
chain.gte = () => chain
chain.neq = () => chain
chain.in = () => chain
chain.eq = () => chain
chain.select = () => chain
chain.from = () => chain
return { from: chain.from } as never
// computeVatReport uses the two-step entry-lines fetch
// (lib/bookkeeping/entry-lines.ts): journal_entries is queried first, then
// journal_entry_lines by parent id, and the parent is reattached under
// `journal_entries`. Both steps page with `.order('id').range(from, to)`,
// so `.range()` is the terminal; one short page (always < the 1000-row
// PAGE_SIZE for these fixtures) ends the paging loop.
//
// Fixtures stay line-shaped for readability; the parent rows are derived
// from them here.
const entries = [
...new Map(
lines.map((l, i) => {
const id = l.journal_entry_id ?? `entry-${i}`
return [id, { id, source_type: l.journal_entries?.source_type ?? null }]
}),
).values(),
]
const bareLines = lines.map((l, i) => ({
id: `line-${String(i).padStart(4, '0')}`,
journal_entry_id: l.journal_entry_id ?? `entry-${i}`,
account_number: l.account_number,
debit_amount: l.debit_amount,
credit_amount: l.credit_amount,
}))
const makeChain = (rows: unknown[]) => {
const chain: Record<string, () => unknown> = {}
chain.range = () => ({ data: rows, error: null })
for (const m of ['order', 'lte', 'gte', 'neq', 'in', 'eq', 'select', 'limit', 'contains', 'filter']) {
chain[m] = () => chain
}
return chain
}
return {
from: (table: string) => (table === 'journal_entries' ? makeChain(entries) : makeChain(bareLines)),
} as never
}
describe('computeVatReport', () => {
@@ -210,16 +230,15 @@ describe('computeVatReport', () => {
expect(result.rutor.ruta49).toBe(250)
})
it('settlement-shape exclusion handles the array-typed embed and exempts opening balances', async () => {
it('settlement-shape exclusion covers stornos and exempts opening balances', async () => {
const lines: MockLine[] = [
// Storno of a settlement, with the embed in array form (the client's
// inferred shape): must be excluded from ruta10.
{ journal_entry_id: 'e3', account_number: '2611', debit_amount: 0, credit_amount: 100, journal_entries: [{ source_type: 'storno' }] },
{ journal_entry_id: 'e3', account_number: '2650', debit_amount: 100, credit_amount: 0, journal_entries: [{ source_type: 'storno' }] },
// Storno of a settlement: must be excluded from ruta10.
{ journal_entry_id: 'e3', account_number: '2611', debit_amount: 0, credit_amount: 100, journal_entries: { source_type: 'storno' } },
{ journal_entry_id: 'e3', account_number: '2650', debit_amount: 100, credit_amount: 0, journal_entries: { source_type: 'storno' } },
// Opening balance carrying undeclared input VAT and a prior VAT debt:
// stays IN the projection.
{ journal_entry_id: 'ib', account_number: '2641', debit_amount: 500, credit_amount: 0, journal_entries: [{ source_type: 'opening_balance' }] },
{ journal_entry_id: 'ib', account_number: '2650', debit_amount: 0, credit_amount: 300, journal_entries: [{ source_type: 'opening_balance' }] },
{ journal_entry_id: 'ib', account_number: '2641', debit_amount: 500, credit_amount: 0, journal_entries: { source_type: 'opening_balance' } },
{ journal_entry_id: 'ib', account_number: '2650', debit_amount: 0, credit_amount: 300, journal_entries: { source_type: 'opening_balance' } },
]
const result = await computeVatReport(
@@ -105,17 +105,26 @@ export const companyCurrentResource: McpResource = {
.order('voucher_series', { ascending: true }),
// Recency signals: when did each surface last move?
// 'bank_transaction' is the source_type the engine writes when a bank
// transaction is categorized (journal_entries_source_type_check); there
// is no 'transaction' value, so filtering on it matched nothing.
supabase
.from('journal_entries')
.select('created_at')
.eq('company_id', companyId)
.eq('source_type', 'transaction')
.eq('source_type', 'bank_transaction')
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle(),
// "Sent" lives in the delivery log, not on invoices: that table has no
// sent timestamp at all. Both send paths write invoice_deliveries.sent_at
// (email -> status 'sent', manual mark-as-sent -> 'marked_sent'), and the
// invoice detail view reads the same rows for its "Skickad" date, so the
// agent and the UI now agree. Only sent_at is selected: the rest of the
// delivery row is recipient/message PII this resource has no use for.
supabase
.from('invoices')
.from('invoice_deliveries')
.select('sent_at')
.eq('company_id', companyId)
.not('sent_at', 'is', null)
@@ -151,6 +160,22 @@ export const companyCurrentResource: McpResource = {
throw new Error(`Company not found: ${companyRes.error?.message ?? 'unknown'}`)
}
// A null recency signal is a factual claim ("this never happened") that the
// agent acts on, so a failed read must surface as an error rather than
// degrade into that claim. resources/read turns the throw into a JSON-RPC
// error, same as period-active does for its period read. PGRST116 is the
// legitimate no-rows case and stays null.
const recencyReads = [
{ label: 'last categorization', error: lastCategorizationRes.error },
{ label: 'last invoice delivery', error: lastInvoiceSentRes.error },
{ label: 'last bank sync', error: lastBankSyncRes.error },
]
for (const read of recencyReads) {
if (read.error && read.error.code !== 'PGRST116') {
throw new Error(`Failed to read ${read.label}: ${read.error.message}`)
}
}
const settings = settingsRes.data
const activePeriod = activePeriodRes.data
const periodStatus: 'open' | 'locked' | 'closed' = activePeriod?.is_closed
@@ -17,7 +17,7 @@ export const recentActivityResource: McpResource = {
.limit(limit),
supabase
.from('invoices')
.select('id, invoice_number, customer_id, invoice_date, due_date, total_amount, currency, status, created_at')
.select('id, invoice_number, customer_id, invoice_date, due_date, total, currency, status, created_at')
.eq('company_id', companyId)
.order('created_at', { ascending: false })
.limit(limit),
@@ -29,6 +29,18 @@ export const recentActivityResource: McpResource = {
.limit(limit),
])
// Never report an empty list when the query failed: an agent that is told
// "zero invoices" reasons from false state, which is worse than an error.
if (journalEntries.error) {
throw new Error(`Failed to read recent journal entries: ${journalEntries.error.message}`)
}
if (invoices.error) {
throw new Error(`Failed to read recent invoices: ${invoices.error.message}`)
}
if (transactions.error) {
throw new Error(`Failed to read recent transactions: ${transactions.error.message}`)
}
return {
limit,
journal_entries: journalEntries.data ?? [],
@@ -1,5 +1,5 @@
import type { McpResource } from './types'
import { getAvailableVatRates, getVatRules } from '@/lib/invoices/vat-rules'
import { getAvailableVatRates, getPermittedVatRates, getVatRules } from '@/lib/invoices/vat-rules'
import type { CustomerType } from '@/types'
const CUSTOMER_TYPES: CustomerType[] = ['individual', 'swedish_business', 'eu_business', 'non_eu_business']
@@ -9,17 +9,28 @@ export const vatTreatmentsResource: McpResource = {
name: 'VAT Treatments',
description: 'Available VAT treatments and rates per customer type, and the resulting moms ruta on the VAT declaration. Use before creating invoices to pick the right VAT rate.',
mimeType: 'application/json',
// Two rate sets, always both, always labelled. Publishing only the default
// set (the single locked 0% for a foreign business) told an agent that 0% was
// the only lawful rate, so it would never attempt a lawful Stockholm hotel
// night for a German company; publishing only the lawful set would invite
// 25% on a plain consulting invoice to that same company. The pair plus the
// swedish_vat_to_foreign_business note below is what an agent needs to pick
// correctly: start from default_rates, deviate only for a supply that the
// note names. Read on demand via resources/read, so this costs nothing in the
// tools/list payload budget.
read: async () => {
const matrix: Record<string, unknown> = {}
for (const ct of CUSTOMER_TYPES) {
matrix[ct] = {
unvalidated_vat: {
rates: getAvailableVatRates(ct, false),
default_rates: getAvailableVatRates(ct, false),
permitted_rates: getPermittedVatRates(ct, false),
default_rule: getVatRules(ct, false),
},
validated_vat: {
rates: getAvailableVatRates(ct, true),
default_rates: getAvailableVatRates(ct, true),
permitted_rates: getPermittedVatRates(ct, true),
default_rule: getVatRules(ct, true),
},
}
@@ -29,8 +40,10 @@ export const vatTreatmentsResource: McpResource = {
treatments: ['standard_25', 'reduced_12', 'reduced_6', 'reverse_charge', 'export', 'exempt'],
by_customer_type: matrix,
notes: {
eu_business_validated: 'Reverse charge applies: invoice 0%, customer self-accounts via moms ruta 39.',
non_eu_business: 'Export: invoice 0%, no Swedish VAT, moms ruta 40.',
rate_sets: 'default_rates is what a line should normally use; permitted_rates is everything the invoice tools accept. Identical for Swedish customers; wider for a foreign business, see swedish_vat_to_foreign_business.',
swedish_vat_to_foreign_business: 'Huvudregeln (ML 6 kap. 34 §) taxes a B2B service where the buyer is established, so 0% is the default for a VAT-validated EU business and for a non-EU business. Supplies taxed where they are performed carry Swedish VAT even to a foreign business: hotel and restaurang/catering 12%, persontransport and admission to cultural or sporting events 6%, fastighetstjänst and short-term vehicle hire 25%. Set such a rate on the line only for that kind of supply; consulting, licensing and other huvudregel services stay at 0%.',
eu_business_validated: 'Reverse charge is the default: invoice 0%, customer self-accounts, moms ruta 39.',
non_eu_business: 'Export is the default: invoice 0%, no Swedish VAT, moms ruta 40.',
mixed_rate: 'Invoice line items can have individual VAT rates; the engine generates per-rate lines.',
},
}
File diff suppressed because it is too large Load Diff
@@ -57,7 +57,7 @@ VAT and Skatteverket charges interest + penalty later.
Accounted's \`gnubok_create_customer\` runs VIES automatically when \`vat_number\`
is provided. The result is stored in \`vat_number_validated\` on the customer.
Subsequent invoices read this field via \`getAvailableVatRates\` to determine
Subsequent invoices read this field via \`getPermittedVatRates\` to determine
which VAT rates are legal.
If VIES is temporarily down (intermittent), Accounted marks the customer as
@@ -84,15 +84,15 @@ appears in \`gnubok_list_customers\` and can be invoiced via \`gnubok_create_inv
Read \`vat_number_validated\` (for EU); if false, double-check the VAT number.
When you eventually call \`gnubok_create_invoice\`, Accounted uses the
\`getAvailableVatRates\` helper to constrain the dropdown of legal rates per
customer type. If you see "VAT rate X% is not allowed for customer type Y",
the customer_type is wrong: re-onboard correctly via the agent flow, do NOT
hand-pick a different rate.
\`getPermittedVatRates\` helper to constrain the legal rates per customer type.
The accepted set is 0/25/12/6 % for every customer type, so "VAT rate X% is not
allowed for customer type Y" means X is not a Swedish VAT rate at all: fix the
rate, and check the customer_type while you are there.
## Common errors
- *"VAT rate 25% is not allowed for customer type eu_business"*: the customer's VAT number is validated → reverse charge required. Use 0 % on line items, and Accounted will add the "Omvänd betalningsskyldighet" notation automatically.
- *"VAT rate 0% is not allowed for customer type swedish_business"*: domestic customers always get 25/12/6 %. Pick the right rate.
- *A validated \`eu_business\` invoiced at 25 %*: not refused, but almost always wrong. Reverse charge is the DEFAULT for a validated EU VAT number: leave \`vat_rate\` off (or 0) and Accounted adds the "Omvänd betalningsskyldighet" notation automatically. A Swedish rate is lawful here only for a supply taxed where it is performed (hotel/restaurang 12 %, persontransport and event admission 6 %, fastighetstjänst and korttidsuthyrning 25 %); read the \`Accounted://settings/vat-treatments\` resource before using one.
- *A \`swedish_business\` invoiced at 0 %*: also accepted (momsfritt supplies exist), but the normal domestic rate is 25/12/6 %. Do not use 0 % to avoid picking a rate.
- *"Customer not found"* on \`gnubok_create_invoice\`: the customer wasn't created (or its pending_operation wasn't approved). Run \`gnubok_list_customers\` to confirm.
## ROT/RUT-eligible customers (Swedish individuals)
@@ -112,7 +112,7 @@ for the booking details (BAS accounts 1513 + 3740).
- \`gnubok_list_customers\`: check for existing customers
- \`gnubok_create_customer\`: stage the new customer
- \`gnubok_create_invoice\`: uses the customer_type for VAT-rate validation
- (Helper, not a tool but referenced) \`getAvailableVatRates(customer_type, vat_number_validated)\`: drives the legal-rates check
- (Helper, not a tool but referenced) \`getPermittedVatRates(customer_type, vat_number_validated)\`: drives the legal-rates check
`
export const customerOnboardingSkill: Skill = {
@@ -8,7 +8,6 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useToast } from '@/components/ui/use-toast'
import { createClient } from '@/lib/supabase/client'
import { useCompany } from '@/contexts/CompanyContext'
import { Bell, BellOff, Loader2, Moon } from 'lucide-react'
import type { NotificationSettings as NotificationSettingsType } from '@/types'
@@ -19,7 +18,6 @@ interface NotificationSettingsProps {
export function NotificationSettings({ onSettingsChange }: NotificationSettingsProps) {
const { toast } = useToast()
const supabase = createClient()
const { company } = useCompany()
const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false)
@@ -49,14 +47,19 @@ export function NotificationSettings({ onSettingsChange }: NotificationSettingsP
const fetchSettings = async () => {
setIsLoading(true)
// Notification settings are per user, not per company: quiet hours and the
// per-category toggles belong to the person, and push_subscriptions are
// keyed on a device endpoint. Neither table carries a company_id.
const { data: { user } } = await supabase.auth.getUser()
if (!user) return
if (!company) return
if (!user) {
setIsLoading(false)
return
}
const { data, error } = await supabase
.from('notification_settings')
.select('*')
.eq('company_id', company.id)
.eq('user_id', user.id)
.single()
if (!error && data) {
@@ -67,7 +70,6 @@ export function NotificationSettings({ onSettingsChange }: NotificationSettingsP
.from('notification_settings')
.insert({
user_id: user.id,
company_id: company.id,
tax_deadlines_enabled: true,
invoice_reminders_enabled: true,
push_enabled: true,
@@ -0,0 +1,138 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import type { ExtensionContext } from '@/lib/extensions/types'
import { pushNotificationsApiRoutes } from '../api-routes'
/**
* The subscribe path, asserted against the columns it actually names.
*
* `createMockSupabase()` cannot be used here: it is a permissive proxy, so
* `.eq('company_id', userId)` on a table with no company_id resolves happily and
* an `onConflict` naming a non-existent unique constraint never surfaces. Both
* shipped bugs would pass under it. This recorder captures the payload, the
* upsert options and every filter so the test can assert on them; the companion
* check that those column names exist in the schema at all lives in
* `tests/schema/no-phantom-columns.test.ts`.
*/
interface Call {
table: string
op: string
payload?: Record<string, unknown>
options?: Record<string, unknown>
filters: [string, unknown][]
}
const calls: Call[] = []
function recorder() {
const from = (table: string) => {
const make = (op: string, payload?: Record<string, unknown>, options?: Record<string, unknown>) => {
const call: Call = { table, op, payload, options, filters: [] }
calls.push(call)
const chain: Record<string, unknown> = {
eq: (column: string, value: unknown) => {
call.filters.push([column, value])
return chain
},
select: () => chain,
single: async () => ({ data: { id: 'sub-1' }, error: null }),
then: (resolve: (v: unknown) => void) => resolve({ data: null, error: null }),
}
return chain
}
return {
upsert: (payload: Record<string, unknown>, options?: Record<string, unknown>) =>
make('upsert', payload, options),
delete: () => make('delete'),
insert: (payload: Record<string, unknown>) => make('insert', payload),
}
}
return { from }
}
vi.mock('@/lib/supabase/server', () => ({
createClient: async () => recorder(),
}))
const USER_ID = '11111111-1111-4111-8111-111111111111'
const ENDPOINT = 'https://push.example.test/v1/synthetic-endpoint'
const ctx = { userId: USER_ID } as unknown as ExtensionContext
const route = (method: string, path: string) =>
pushNotificationsApiRoutes.find((r) => r.method === method && r.path === path)!.handler
const jsonRequest = (body: unknown) =>
new Request('http://localhost/api/extensions/ext/push-notifications/subscribe', {
method: 'POST',
headers: { 'content-type': 'application/json', 'user-agent': 'SyntheticAgent/1.0' },
body: JSON.stringify(body),
})
beforeEach(() => {
calls.length = 0
vi.clearAllMocks()
})
describe('POST /subscribe', () => {
const validBody = {
endpoint: ENDPOINT,
keys: { p256dh: 'synthetic-p256dh', auth: 'synthetic-auth' },
}
it('upserts onto the endpoint unique constraint', async () => {
// push_subscriptions is unique on `endpoint` alone. Naming `user_id,endpoint`
// is not a unique set, so Postgres raised 42P10 on every subscribe call.
const response = await route('POST', '/subscribe')(jsonRequest(validBody), ctx)
expect(response.status).toBe(200)
const upsert = calls.find((c) => c.table === 'push_subscriptions' && c.op === 'upsert')
expect(upsert).toBeDefined()
expect(upsert!.options?.onConflict).toBe('endpoint')
})
it('keys the subscription by user, never by company', async () => {
// A push endpoint belongs to a browser profile, and a user can belong to
// several companies: the table is user-scoped and has no company_id.
await route('POST', '/subscribe')(jsonRequest(validBody), ctx)
const upsert = calls.find((c) => c.table === 'push_subscriptions' && c.op === 'upsert')!
expect(upsert.payload!.user_id).toBe(USER_ID)
expect(upsert.payload).not.toHaveProperty('company_id')
const settings = calls.find((c) => c.table === 'notification_settings')!
expect(settings.payload!.user_id).toBe(USER_ID)
expect(settings.payload).not.toHaveProperty('company_id')
expect(settings.options?.onConflict).toBe('user_id')
})
it('rejects a subscription with no keys', async () => {
const response = await route('POST', '/subscribe')(
jsonRequest({ endpoint: ENDPOINT, keys: {} }),
ctx
)
expect(response.status).toBe(400)
expect(calls).toEqual([])
})
})
describe('DELETE /subscribe', () => {
it('scopes the delete to the calling user, not to a company', async () => {
// The shipped code filtered `.eq('company_id', userId)`: a user id compared
// against a column that does not exist, so unsubscribing removed nothing and
// the device kept receiving pushes.
const response = await route('DELETE', '/subscribe')(jsonRequest({ endpoint: ENDPOINT }), ctx)
expect(response.status).toBe(200)
const del = calls.find((c) => c.table === 'push_subscriptions' && c.op === 'delete')!
expect(del.filters).toEqual([
['user_id', USER_ID],
['endpoint', ENDPOINT],
])
})
it('rejects a delete with no endpoint', async () => {
const response = await route('DELETE', '/subscribe')(jsonRequest({}), ctx)
expect(response.status).toBe(400)
expect(calls).toEqual([])
})
})
@@ -0,0 +1,288 @@
import { describe, it, expect, beforeAll, beforeEach, vi, type Mock } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
/**
* Consent polarity for the cron send pipeline.
*
* The invariant under test: a notification_settings row that CANNOT BE READ
* (RLS failure, network error, schema drift) must mean DO NOT SEND, while a
* row that genuinely DOES NOT EXIST means the user never touched the toggles
* and the defaults apply. PostgREST distinguishes the two: `.maybeSingle()`
* yields `{ data: null, error: null }` for zero rows and a non-null `error`
* for a failed read. Collapsing both into "settings undefined" made every
* gate of the form `if (settings && !settings.x_enabled)` fail open: a user
* who had opted out would be notified anyway whenever the read failed.
*
* The stub below emulates PostgREST semantics (not the implementation) so the
* same tests are meaningful against both the old `.single()` reads and the
* shared readNotificationSettings() helper.
*/
vi.mock('web-push', () => ({
default: {
setVapidDetails: vi.fn(),
sendNotification: vi.fn().mockResolvedValue(undefined),
},
}))
const USER_ID = '11111111-1111-4111-8111-111111111111'
const SUBSCRIPTION = {
endpoint: 'https://push.example.test/v1/synthetic-endpoint',
p256dh: 'synthetic-p256dh',
auth: 'synthetic-auth',
}
const todayStr = () => {
const today = new Date()
today.setHours(0, 0, 0, 0)
return today.toISOString().split('T')[0]
}
/** A full notification_settings row; quiet window is zero-length so time of day never skips a send. */
function settingsRow(overrides: Record<string, unknown> = {}) {
return {
id: '22222222-2222-4222-8222-222222222222',
user_id: USER_ID,
tax_deadlines_enabled: true,
invoice_reminders_enabled: true,
quiet_start: '00:00',
quiet_end: '00:00',
email_enabled: true,
push_enabled: true,
period_locked_enabled: true,
period_year_closed_enabled: true,
invoice_sent_enabled: false,
receipt_extracted_enabled: true,
receipt_matched_enabled: true,
missing_underlag_enabled: true,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
...overrides,
}
}
interface StubConfig {
/** Rows in notification_settings for this user, or 'unreadable' to fail the read. */
settings: Record<string, unknown>[] | 'unreadable'
/** Rows the dedup check finds in notification_log, or 'unreadable' to fail the read. */
notificationLog?: Record<string, unknown>[] | 'unreadable'
deadlines?: Record<string, unknown>[]
invoices?: Record<string, unknown>[]
journalEntries?: Record<string, unknown>[]
subscriptions?: Record<string, unknown>[]
}
type Result = { data: unknown; error: unknown }
const READ_ERROR = {
code: '42501',
message: 'permission denied for table notification_settings',
}
const PGRST116 = {
code: 'PGRST116',
message: 'JSON object requested, multiple (or no) rows returned',
}
function makeSupabaseStub(config: StubConfig): SupabaseClient {
const rowsResult = (
rows: Record<string, unknown>[] | 'unreadable' | undefined,
method: 'single' | 'maybeSingle' | 'list'
): Result => {
if (rows === 'unreadable') return { data: null, error: READ_ERROR }
const list = rows ?? []
if (method === 'list') return { data: list, error: null }
if (list.length === 0) {
// PostgREST: .single() errors on zero rows, .maybeSingle() does not.
return method === 'single'
? { data: null, error: PGRST116 }
: { data: null, error: null }
}
return { data: list[0], error: null }
}
const from = vi.fn().mockImplementation((table: string) => {
let isWrite = false
const resultFor = (method: 'single' | 'maybeSingle' | 'list'): Result => {
if (isWrite) return { data: null, error: null }
switch (table) {
case 'notification_settings':
return rowsResult(config.settings, method)
case 'notification_log':
return rowsResult(config.notificationLog, method)
case 'deadlines':
return rowsResult(config.deadlines, 'list')
case 'invoices':
return rowsResult(config.invoices, 'list')
case 'journal_entries':
return rowsResult(config.journalEntries, 'list')
case 'push_subscriptions':
return rowsResult(config.subscriptions ?? [SUBSCRIPTION], 'list')
default:
// document_attachments, supplier_invoices, supplier_invoice_payments,
// journal_entry_no_doc_required: empty is the neutral answer.
return { data: [], error: null }
}
}
/* eslint-disable @typescript-eslint/no-explicit-any */
const chain: any = {
select: () => chain,
insert: () => {
isWrite = true
return chain
},
update: () => {
isWrite = true
return chain
},
eq: () => chain,
in: () => chain,
not: () => chain,
order: () => chain,
range: () => chain,
limit: () => chain,
single: async () => resultFor('single'),
maybeSingle: async () => resultFor('maybeSingle'),
then: (resolve: (v: unknown) => void) => resolve(resultFor('list')),
}
/* eslint-enable @typescript-eslint/no-explicit-any */
return chain
})
return { from } as unknown as SupabaseClient
}
let sendTaxDeadlineNotifications: typeof import('../notification-scheduler').sendTaxDeadlineNotifications
let sendInvoiceNotifications: typeof import('../notification-scheduler').sendInvoiceNotifications
let sendMissingUnderlagNotifications: typeof import('../notification-scheduler').sendMissingUnderlagNotifications
let webpushSend: Mock
beforeAll(async () => {
// The sender module captures the VAPID keys at import time: set them before
// the dynamic import so the send path is actually exercised.
process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY = 'synthetic-vapid-public-key'
process.env.VAPID_PRIVATE_KEY = 'synthetic-vapid-private-key'
const scheduler = await import('../notification-scheduler')
sendTaxDeadlineNotifications = scheduler.sendTaxDeadlineNotifications
sendInvoiceNotifications = scheduler.sendInvoiceNotifications
sendMissingUnderlagNotifications = scheduler.sendMissingUnderlagNotifications
webpushSend = vi.mocked((await import('web-push')).default.sendNotification) as unknown as Mock
})
beforeEach(() => {
vi.clearAllMocks()
})
const oneDeadline = () => [
{ id: 'dl-1', user_id: USER_ID, title: 'Momsdeklaration', due_date: todayStr() },
]
describe('tax deadline gate polarity', () => {
it('does NOT send when the settings read fails (unreadable means no consent)', async () => {
const supabase = makeSupabaseStub({ settings: 'unreadable', deadlines: oneDeadline() })
const result = await sendTaxDeadlineNotifications(supabase)
expect(webpushSend).not.toHaveBeenCalled()
expect(result.sent).toBe(0)
expect(result.skipped).toBe(1)
})
it('sends with defaults when no settings row exists', async () => {
const supabase = makeSupabaseStub({ settings: [], deadlines: oneDeadline() })
const result = await sendTaxDeadlineNotifications(supabase)
expect(webpushSend).toHaveBeenCalledTimes(1)
expect(result.sent).toBe(1)
})
it('honours an explicit opt-out', async () => {
const supabase = makeSupabaseStub({
settings: [settingsRow({ tax_deadlines_enabled: false })],
deadlines: oneDeadline(),
})
const result = await sendTaxDeadlineNotifications(supabase)
expect(webpushSend).not.toHaveBeenCalled()
expect(result.sent).toBe(0)
expect(result.skipped).toBe(1)
})
it('honours push_enabled = false even when the category is on', async () => {
const supabase = makeSupabaseStub({
settings: [settingsRow({ push_enabled: false })],
deadlines: oneDeadline(),
})
const result = await sendTaxDeadlineNotifications(supabase)
expect(webpushSend).not.toHaveBeenCalled()
expect(result.sent).toBe(0)
})
it('still sends for an opted-in row', async () => {
const supabase = makeSupabaseStub({
settings: [settingsRow()],
deadlines: oneDeadline(),
})
const result = await sendTaxDeadlineNotifications(supabase)
expect(webpushSend).toHaveBeenCalledTimes(1)
expect(result.sent).toBe(1)
})
it('does NOT send when the dedup log read fails (cannot prove not-a-duplicate)', async () => {
const supabase = makeSupabaseStub({
settings: [settingsRow()],
notificationLog: 'unreadable',
deadlines: oneDeadline(),
})
const result = await sendTaxDeadlineNotifications(supabase)
expect(webpushSend).not.toHaveBeenCalled()
expect(result.sent).toBe(0)
})
})
describe('invoice gate polarity', () => {
it('does NOT send when the settings read fails', async () => {
const supabase = makeSupabaseStub({
settings: 'unreadable',
invoices: [
{
id: 'inv-1',
user_id: USER_ID,
invoice_number: 1001,
total: 1250,
currency: 'SEK',
due_date: todayStr(),
customer: { name: 'Synthetic Kund AB' },
},
],
})
const result = await sendInvoiceNotifications(supabase)
expect(webpushSend).not.toHaveBeenCalled()
expect(result.sent).toBe(0)
expect(result.skipped).toBe(1)
})
})
describe('missing underlag gate polarity', () => {
it('does NOT send when the settings read fails', async () => {
const supabase = makeSupabaseStub({
settings: 'unreadable',
journalEntries: [{ id: 'je-1', user_id: USER_ID }],
})
const result = await sendMissingUnderlagNotifications(supabase)
expect(webpushSend).not.toHaveBeenCalled()
expect(result.sent).toBe(0)
expect(result.skipped).toBe(1)
})
})
@@ -0,0 +1,169 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import type { ExtensionContext } from '@/lib/extensions/types'
import { pushNotificationsApiRoutes } from '../api-routes'
/**
* Consent polarity on the settings routes themselves.
*
* getSettings() returns null when the notification_settings row exists but
* cannot be read; both routes must surface that as a 500 instead of showing
* or merging into the mostly-enabled defaults. A genuinely absent row
* (maybeSingle, no error) still yields the defaults.
*/
type Behavior = 'row' | 'absent' | 'unreadable'
let behavior: Behavior = 'absent'
const upserts: { payload: Record<string, unknown>; options?: Record<string, unknown> }[] = []
// A stored row that deviates from every default it can, so a response built
// from the row is distinguishable from one built from the defaults.
const STORED_ROW = {
period_locked_enabled: false,
period_year_closed_enabled: true,
invoice_sent_enabled: true,
receipt_extracted_enabled: true,
receipt_matched_enabled: true,
missing_underlag_enabled: false,
}
vi.mock('@/lib/supabase/server', () => ({
createClient: async () => ({
from: (_table: string) => {
/* eslint-disable @typescript-eslint/no-explicit-any */
const chain: any = {
select: () => chain,
eq: () => chain,
maybeSingle: async () => {
if (behavior === 'unreadable') {
return {
data: null,
error: { code: '42501', message: 'permission denied for table notification_settings' },
}
}
if (behavior === 'absent') return { data: null, error: null }
return { data: { ...STORED_ROW }, error: null }
},
upsert: (payload: Record<string, unknown>, options?: Record<string, unknown>) => {
upserts.push({ payload, options })
return chain
},
then: (resolve: (v: unknown) => void) => resolve({ data: null, error: null }),
}
/* eslint-enable @typescript-eslint/no-explicit-any */
return chain
},
}),
}))
const USER_ID = '11111111-1111-4111-8111-111111111111'
const ctx = { userId: USER_ID } as unknown as ExtensionContext
const route = (method: string, path: string) =>
pushNotificationsApiRoutes.find((r) => r.method === method && r.path === path)!.handler
const getRequest = () =>
new Request('http://localhost/api/extensions/ext/push-notifications/settings')
const putRequest = (body: unknown) =>
new Request('http://localhost/api/extensions/ext/push-notifications/settings', {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
})
beforeEach(() => {
behavior = 'absent'
upserts.length = 0
vi.clearAllMocks()
})
describe('GET /settings', () => {
it('returns 500 when the settings row is unreadable (never the defaults)', async () => {
behavior = 'unreadable'
const response = await route('GET', '/settings')(getRequest(), ctx)
expect(response.status).toBe(500)
})
it('returns the defaults when no row exists', async () => {
behavior = 'absent'
const response = await route('GET', '/settings')(getRequest(), ctx)
const body = await response.json()
expect(response.status).toBe(200)
expect(body.data.invoiceSentEnabled).toBe(false)
expect(body.data.missingUnderlagEnabled).toBe(true)
})
it('returns the stored values when a row exists', async () => {
behavior = 'row'
const response = await route('GET', '/settings')(getRequest(), ctx)
const body = await response.json()
expect(response.status).toBe(200)
expect(body.data.periodLockedEnabled).toBe(false)
expect(body.data.invoiceSentEnabled).toBe(true)
expect(body.data.missingUnderlagEnabled).toBe(false)
})
})
describe('PUT /settings', () => {
it('returns 500 and writes NOTHING when the current row is unreadable', async () => {
// Saving would merge the partial into the defaults and overwrite the
// user's stored opt-outs with mostly-enabled values.
behavior = 'unreadable'
const response = await route('PUT', '/settings')(
putRequest({ receiptMatchedEnabled: false }),
ctx
)
expect(response.status).toBe(500)
expect(upserts).toEqual([])
})
it('merges the partial into the STORED row, preserving existing opt-outs', async () => {
behavior = 'row'
const response = await route('PUT', '/settings')(
putRequest({ receiptMatchedEnabled: false }),
ctx
)
const body = await response.json()
expect(response.status).toBe(200)
expect(body.data.receiptMatchedEnabled).toBe(false)
expect(upserts).toHaveLength(1)
const written = upserts[0].payload
// Untouched fields come from the stored row, not the defaults.
expect(written.period_locked_enabled).toBe(false)
expect(written.invoice_sent_enabled).toBe(true)
expect(written.missing_underlag_enabled).toBe(false)
expect(written.receipt_matched_enabled).toBe(false)
expect(upserts[0].options?.onConflict).toBe('user_id')
})
it('accepts missingUnderlagEnabled: the mute switch for the weekly notification', async () => {
behavior = 'row'
const response = await route('PUT', '/settings')(
putRequest({ missingUnderlagEnabled: true }),
ctx
)
const body = await response.json()
expect(response.status).toBe(200)
expect(body.data.missingUnderlagEnabled).toBe(true)
expect(upserts[0].payload.missing_underlag_enabled).toBe(true)
})
it('rejects a body with no recognised keys', async () => {
const response = await route('PUT', '/settings')(putRequest({ nonsense: true }), ctx)
expect(response.status).toBe(400)
expect(upserts).toEqual([])
})
})
@@ -48,7 +48,10 @@ async function handlePostSubscribe(
// Get user agent for debugging
const userAgent = request.headers.get('user-agent') || null
// Upsert subscription (update if endpoint exists)
// Upsert subscription. The unique constraint is on `endpoint` alone (a push
// endpoint is issued per browser profile and origin, so it is globally
// unique), which is the only legal conflict target here: naming
// `user_id,endpoint` raised 42P10 on every subscribe call.
const { data, error } = await supabase
.from('push_subscriptions')
.upsert(
@@ -62,7 +65,7 @@ async function handlePostSubscribe(
last_used_at: new Date().toISOString(),
},
{
onConflict: 'user_id,endpoint',
onConflict: 'endpoint',
}
)
.select()
@@ -123,7 +126,7 @@ async function handleDeleteSubscribe(
const { error } = await supabase
.from('push_subscriptions')
.delete()
.eq('company_id', userId)
.eq('user_id', userId)
.eq('endpoint', endpoint)
if (error) {
@@ -146,6 +149,14 @@ async function handleGetSettings(
): Promise<Response> {
const userId = ctx!.userId
const settings = await getSettings(userId)
// null means the row exists but could not be read: surfacing the defaults
// here would show toggles in a state the user may have switched off.
if (!settings) {
return NextResponse.json(
{ error: 'Failed to load notification settings' },
{ status: 500 }
)
}
return NextResponse.json({ data: settings })
}
@@ -166,6 +177,7 @@ async function handleUpdateSettings(
'invoiceSentEnabled',
'receiptExtractedEnabled',
'receiptMatchedEnabled',
'missingUnderlagEnabled',
]
const filtered: Record<string, unknown> = {}
for (const key of allowedKeys) {
@@ -179,6 +191,14 @@ async function handleUpdateSettings(
}
const settings = await saveSettings(userId, filtered)
// null means the current settings could not be read: saving would merge the
// partial into defaults and overwrite the user's stored opt-outs.
if (!settings) {
return NextResponse.json(
{ error: 'Failed to save notification settings' },
{ status: 500 }
)
}
return NextResponse.json({ data: settings })
}
+24 -11
View File
@@ -50,19 +50,28 @@ async function getSettingsViaCtx(ctx: ExtensionContext): Promise<PushNotificatio
return { ...DEFAULT_SETTINGS, ...(stored || {}) }
}
/** Get settings for external callers (settings routes, cron jobs) */
export async function getSettings(userId: string): Promise<PushNotificationSettings> {
/**
* Get settings for external callers (settings routes, event handlers).
*
* Returns null when the settings row exists but CANNOT BE READ: consent
* polarity requires every caller to treat that as "do not notify" (and the
* settings routes to surface an error) instead of falling back to the
* defaults, which are mostly enabled. A genuinely absent row (maybeSingle
* with no error) means the user never touched the toggles: defaults apply.
*/
export async function getSettings(userId: string): Promise<PushNotificationSettings | null> {
const { createClient } = await import('@/lib/supabase/server')
const supabase = await createClient()
const { data } = await supabase
const { data, error } = await supabase
.from('notification_settings')
.select(
'period_locked_enabled, period_year_closed_enabled, invoice_sent_enabled, receipt_extracted_enabled, receipt_matched_enabled, missing_underlag_enabled'
)
.eq('company_id', userId)
.single()
.eq('user_id', userId)
.maybeSingle()
if (error) return null
if (!data) return { ...DEFAULT_SETTINGS }
return {
periodLockedEnabled: data.period_locked_enabled ?? DEFAULT_SETTINGS.periodLockedEnabled,
@@ -77,8 +86,11 @@ export async function getSettings(userId: string): Promise<PushNotificationSetti
export async function saveSettings(
userId: string,
partial: Partial<PushNotificationSettings>
): Promise<PushNotificationSettings> {
): Promise<PushNotificationSettings | null> {
const current = await getSettings(userId)
// Unreadable current settings: merging the partial into defaults would
// silently overwrite the user's stored opt-outs. Refuse the save.
if (!current) return null
const merged = { ...current, ...partial }
const { createClient } = await import('@/lib/supabase/server')
@@ -112,8 +124,9 @@ async function handlePeriodLocked(
): Promise<void> {
const { period, userId } = payload
// getSettings() returns null when the row is unreadable: do not notify.
const settings = ctx ? await getSettingsViaCtx(ctx) : await getSettings(userId)
if (!settings.periodLockedEnabled) return
if (!settings?.periodLockedEnabled) return
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
const notificationPayload = createPeriodLockedPayload(period.name, period.id)
@@ -138,7 +151,7 @@ async function handleYearClosed(
const { period, userId } = payload
const settings = ctx ? await getSettingsViaCtx(ctx) : await getSettings(userId)
if (!settings.periodYearClosedEnabled) return
if (!settings?.periodYearClosedEnabled) return
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
const notificationPayload = createYearClosedPayload(period.name, period.id)
@@ -163,7 +176,7 @@ async function handleInvoiceSent(
const { invoice, userId } = payload
const settings = ctx ? await getSettingsViaCtx(ctx) : await getSettings(userId)
if (!settings.invoiceSentEnabled) return
if (!settings?.invoiceSentEnabled) return
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
const notificationPayload = createInvoiceSentPayload(
@@ -191,7 +204,7 @@ async function handleReceiptExtracted(
const { receipt, userId } = payload
const settings = ctx ? await getSettingsViaCtx(ctx) : await getSettings(userId)
if (!settings.receiptExtractedEnabled) return
if (!settings?.receiptExtractedEnabled) return
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
const notificationPayload = createReceiptExtractedPayload(
@@ -219,7 +232,7 @@ async function handleReceiptMatched(
const { receipt, transaction, userId } = payload
const settings = ctx ? await getSettingsViaCtx(ctx) : await getSettings(userId)
if (!settings.receiptMatchedEnabled) return
if (!settings?.receiptMatchedEnabled) return
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
const notificationPayload = createReceiptMatchedPayload(receipt.id, transaction.id)
@@ -12,7 +12,7 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import type { NotificationType } from '@/types'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { sendNotificationToUser } from './notification-sender'
import { sendNotificationToUser, readNotificationSettings } from './notification-sender'
import {
createTaxDeadlinePayload,
createInvoiceOverduePayload,
@@ -63,14 +63,14 @@ export async function sendTaxDeadlineNotifications(
}
for (const [userId, userDls] of userDeadlines) {
// Check user-level tax_deadlines_enabled
const { data: settings } = await supabase
.from('notification_settings')
.select('tax_deadlines_enabled')
.eq('company_id', userId)
.single()
if (settings && !settings.tax_deadlines_enabled) {
// Check user-level tax_deadlines_enabled. Unreadable settings mean no
// send; a missing row means the defaults (enabled) apply.
const settingsRead = await readNotificationSettings(supabase, userId)
if (!settingsRead.readable) {
skipped += userDls.length
continue
}
if (settingsRead.settings && !settingsRead.settings.tax_deadlines_enabled) {
skipped += userDls.length
continue
}
@@ -152,14 +152,14 @@ export async function sendInvoiceNotifications(
}
for (const [userId, userInvs] of userInvoices) {
// Check user-level invoice_reminders_enabled
const { data: settings } = await supabase
.from('notification_settings')
.select('invoice_reminders_enabled')
.eq('company_id', userId)
.single()
if (settings && !settings.invoice_reminders_enabled) {
// Check user-level invoice_reminders_enabled. Unreadable settings mean no
// send; a missing row means the defaults (enabled) apply.
const settingsRead = await readNotificationSettings(supabase, userId)
if (!settingsRead.readable) {
skipped += userInvs.length
continue
}
if (settingsRead.settings && !settingsRead.settings.invoice_reminders_enabled) {
skipped += userInvs.length
continue
}
@@ -357,14 +357,14 @@ export async function sendMissingUnderlagNotifications(
}
for (const [userId, count] of userMissingCounts) {
// Check user setting
const { data: settings } = await supabase
.from('notification_settings')
.select('missing_underlag_enabled')
.eq('company_id', userId)
.single()
if (settings && settings.missing_underlag_enabled === false) {
// Check user setting. Unreadable settings mean no send; a missing row
// means the defaults (enabled) apply.
const settingsRead = await readNotificationSettings(supabase, userId)
if (!settingsRead.readable) {
skipped++
continue
}
if (settingsRead.settings && settingsRead.settings.missing_underlag_enabled === false) {
skipped++
continue
}
@@ -65,6 +65,35 @@ export function getVapidPublicKey(): string | null {
return vapidPublicKey || null
}
/**
* The single consent read every gate shares.
*
* Consent polarity: PostgREST distinguishes "no row" from "read failed", and
* the two must never collapse into one value. A missing row means the user
* never touched the toggles, so the defaults apply (`settings: null`). A
* failed read (`readable: false`) means we cannot prove consent, and every
* caller MUST treat it as DO NOT SEND: the old shape (`data ?? null` +
* `if (settings && !settings.x_enabled)`) failed open and notified users who
* had opted out whenever the read errored.
*/
export type NotificationSettingsRead =
| { readable: true; settings: NotificationSettings | null }
| { readable: false }
export async function readNotificationSettings(
supabase: SupabaseClient,
userId: string
): Promise<NotificationSettingsRead> {
const { data, error } = await supabase
.from('notification_settings')
.select('*')
.eq('user_id', userId)
.maybeSingle()
if (error) return { readable: false }
return { readable: true, settings: (data as NotificationSettings | null) ?? null }
}
/**
* Send a notification to a specific user.
*
@@ -88,8 +117,12 @@ export async function sendNotificationToUser(
daysBefore: number = 0
): Promise<{ sent: boolean; reason?: string }> {
try {
// 1. Check notification settings
const settings = await getUserNotificationSettings(supabase, userId)
// 1. Check notification settings. Unreadable settings mean no send.
const settingsRead = await readNotificationSettings(supabase, userId)
if (!settingsRead.readable) {
return { sent: false, reason: 'settings_unreadable' }
}
const settings = settingsRead.settings
if (settings && !settings.push_enabled) {
return { sent: false, reason: 'push_disabled' }
@@ -235,14 +268,26 @@ async function wasNotificationSent(
referenceId: string,
daysBefore: number
): Promise<boolean> {
const { data } = await supabase
// `logNotification()` below writes user_id and leaves company_id null, so
// filtering this check on company_id could never match a row it had written:
// every cron pass re-sent the same notification. The dedup key is user_id
// (mirrored by idx_notification_log_skv_kvittens_dedup, unique on
// (user_id, reference_id)). limit(1) because rows written while the check was
// broken can legitimately be duplicated, and .single() would error on them.
const { data, error } = await supabase
.from('notification_log')
.select('id')
.eq('company_id', userId)
.eq('user_id', userId)
.eq('notification_type', notificationType)
.eq('reference_id', referenceId)
.eq('days_before', daysBefore)
.single()
.limit(1)
.maybeSingle()
// Same polarity as the settings gate: an unreadable log cannot prove the
// notification was NOT already sent, so treat it as sent and skip. Failing
// open here re-spams every user on every cron pass whenever the read errors.
if (error) return true
return !!data
}
@@ -270,25 +315,12 @@ export async function getUserSubscriptions(
const { data } = await supabase
.from('push_subscriptions')
.select('endpoint, p256dh, auth')
.eq('company_id', userId)
.eq('user_id', userId)
.eq('is_active', true)
return data || []
}
export async function getUserNotificationSettings(
supabase: SupabaseClient,
userId: string
): Promise<NotificationSettings | null> {
const { data } = await supabase
.from('notification_settings')
.select('*')
.eq('company_id', userId)
.single()
return data
}
async function disableGoneSubscriptions(
supabase: SupabaseClient,
endpoints: string[]
@@ -0,0 +1,278 @@
/**
* Recipient resolution for the skattekonto drift alert.
*
* The alert must reach the address the company actually configured
* (company_settings.tax_contact_email, the "Kontaktperson för skatteärenden"
* field in Inställningar > Skatt), and every path that silently downgrades to
* the syncing user has to leave a log line: an alert delivered to the wrong
* inbox is indistinguishable from no alert at all.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
import type { ExtensionContext } from '@/lib/extensions/types'
import type { EventPayload } from '@/lib/events/types'
const { warnRecorder } = vi.hoisted(() => ({ warnRecorder: vi.fn() }))
// log.warn is suppressed under NODE_ENV=test, so the failure-path tests
// observe it through this mock instead of a console spy.
vi.mock('@/lib/logger', () => ({
createLogger: () => ({
info: vi.fn(),
warn: warnRecorder,
error: vi.fn(),
child() {
return this
},
}),
}))
const mockIsConfigured = vi.fn()
const mockSendEmail = vi.fn()
vi.mock('@/lib/email/service', () => ({
getEmailService: () => ({ isConfigured: mockIsConfigured, sendEmail: mockSendEmail }),
}))
import { handleSkattekontoDriftDetected } from '../lib/skattekonto-drift-email'
interface QueryRecord {
table: string
columns?: string
filters: Record<string, unknown>
}
type MemberRow = { user_id: string; profiles: { email: string } }
const OWNER: MemberRow = { user_id: 'user-1', profiles: { email: 'owner@example.com' } }
const ACCOUNTANT: MemberRow = { user_id: 'user-2', profiles: { email: 'revisor@byra.se' } }
/**
* Hand-rolled mock (instead of createQueuedMockSupabase) because the
* assertions need the selected COLUMN list per table: the bug this covers was
* a select of a column that does not exist on company_settings.
*/
function makeSupabase(
opts: {
members?: MemberRow[] | null
membersError?: { message: string } | null
settings?: Record<string, unknown> | null
settingsError?: { message: string } | null
profile?: { email: string } | null
profileError?: { message: string } | null
} = {},
) {
const queries: QueryRecord[] = []
const from = (table: string) => {
const record: QueryRecord = { table, filters: {} }
queries.push(record)
const result = () => {
if (table === 'company_members') {
return {
data: opts.members === undefined ? [OWNER] : opts.members,
error: opts.membersError ?? null,
}
}
if (table === 'company_settings') {
if (opts.settingsError) return { data: null, error: opts.settingsError }
const row = opts.settings
if (!row) return { data: null, error: null }
// Project to the selected columns, like PostgREST does: reading a
// column the handler did not ask for must not appear to work.
const projected: Record<string, unknown> = {}
for (const col of (record.columns ?? '').split(',').map((c) => c.trim())) {
if (col in row) projected[col] = row[col]
}
return { data: projected, error: null }
}
if (table === 'profiles') {
return {
data: opts.profile === undefined ? { email: OWNER.profiles.email } : opts.profile,
error: opts.profileError ?? null,
}
}
return { data: null, error: null }
}
const builder: Record<string, unknown> = {}
Object.assign(builder, {
select: (columns: string) => {
record.columns = columns
return builder
},
eq: (key: string, value: unknown) => {
record.filters[key] = value
return builder
},
maybeSingle: async () => result(),
then: (resolve: (v: unknown) => void) => resolve(result()),
})
return builder
}
return { supabase: { from } as unknown as SupabaseClient, queries }
}
function makeCtx(supabase: SupabaseClient): ExtensionContext {
return {
userId: 'user-1',
companyId: 'company-1',
extensionId: 'skatteverket',
supabase,
} as unknown as ExtensionContext
}
const payload: EventPayload<'skattekonto.drift_detected'> = {
drift: -1250.5,
saldoSkatteverket: 10000,
glSum1630: 11250.5,
fetchedAt: Date.UTC(2026, 5, 30),
unbookedCount: 2,
userId: 'user-1',
companyId: 'company-1',
}
function sentTo(): string | undefined {
return mockSendEmail.mock.calls[0]?.[0]?.to as string | undefined
}
function warnedWith(fragment: string): boolean {
return warnRecorder.mock.calls.some(
(call) => typeof call[0] === 'string' && call[0].includes(fragment),
)
}
beforeEach(() => {
vi.clearAllMocks()
mockIsConfigured.mockReturnValue(true)
mockSendEmail.mockResolvedValue({ success: true })
})
describe('handleSkattekontoDriftDetected recipient resolution', () => {
it('sends to the configured tax contact when it belongs to an active member', async () => {
const { supabase } = makeSupabase({
members: [OWNER, ACCOUNTANT],
settings: { tax_contact_email: ACCOUNTANT.profiles.email },
})
await handleSkattekontoDriftDetected(payload, makeCtx(supabase))
expect(mockSendEmail).toHaveBeenCalledTimes(1)
expect(sentTo()).toBe(ACCOUNTANT.profiles.email)
})
it('reads tax_contact_email: the column the settings UI actually writes', async () => {
const { supabase, queries } = makeSupabase({
members: [OWNER, ACCOUNTANT],
settings: { tax_contact_email: ACCOUNTANT.profiles.email },
})
await handleSkattekontoDriftDetected(payload, makeCtx(supabase))
const settingsQuery = queries.find((q) => q.table === 'company_settings')
// Exact match, not `toContain`: 'tax_contact_email' contains the phantom
// 'contact_email' as a substring, so a loose assertion would pass on the bug.
expect(settingsQuery?.columns).toBe('tax_contact_email')
expect(settingsQuery?.filters).toMatchObject({ company_id: 'company-1' })
})
it('matches the configured contact against members case-insensitively', async () => {
const { supabase } = makeSupabase({
members: [OWNER, ACCOUNTANT],
settings: { tax_contact_email: 'Revisor@Byra.se' },
})
await handleSkattekontoDriftDetected(payload, makeCtx(supabase))
expect(sentTo()).toBe('Revisor@Byra.se')
})
it('falls back to the syncing user when no tax contact is configured', async () => {
const { supabase } = makeSupabase({ members: [OWNER], settings: null })
await handleSkattekontoDriftDetected(payload, makeCtx(supabase))
expect(sentTo()).toBe(OWNER.profiles.email)
})
it('refuses a tax contact that is not an active member, and says so', async () => {
const { supabase } = makeSupabase({
members: [OWNER],
settings: { tax_contact_email: 'ex-admin@example.com' },
})
await handleSkattekontoDriftDetected(payload, makeCtx(supabase))
expect(sentTo()).toBe(OWNER.profiles.email)
expect(warnedWith('not an active member')).toBe(true)
})
it('does not silently swallow a company_settings read error', async () => {
const { supabase } = makeSupabase({
members: [OWNER],
settings: null,
settingsError: { message: 'column company_settings.tax_contact_email does not exist' },
})
await handleSkattekontoDriftDetected(payload, makeCtx(supabase))
expect(warnedWith('could not read tax contact email')).toBe(true)
// Still delivers to the documented fallback rather than dropping the alert.
expect(sentTo()).toBe(OWNER.profiles.email)
})
it('does not silently swallow a member lookup error', async () => {
const { supabase } = makeSupabase({
members: null,
membersError: { message: 'permission denied for table company_members' },
})
await handleSkattekontoDriftDetected(payload, makeCtx(supabase))
expect(warnedWith('could not read company members')).toBe(true)
expect(mockSendEmail).not.toHaveBeenCalled()
})
it('does not silently swallow a profile lookup error on the fallback path', async () => {
const { supabase } = makeSupabase({
members: [OWNER],
settings: null,
profile: null,
profileError: { message: 'timeout' },
})
await handleSkattekontoDriftDetected(payload, makeCtx(supabase))
expect(warnedWith('could not read syncing user profile')).toBe(true)
expect(mockSendEmail).not.toHaveBeenCalled()
})
it('sends nothing when the company has no members at all', async () => {
const { supabase } = makeSupabase({ members: [] })
await handleSkattekontoDriftDetected(payload, makeCtx(supabase))
expect(mockSendEmail).not.toHaveBeenCalled()
expect(warnedWith('no authorised recipient')).toBe(true)
})
it('skips quietly when no email service is configured', async () => {
mockIsConfigured.mockReturnValue(false)
const { supabase, queries } = makeSupabase({ members: [OWNER, ACCOUNTANT] })
await handleSkattekontoDriftDetected(payload, makeCtx(supabase))
expect(mockSendEmail).not.toHaveBeenCalled()
expect(queries).toHaveLength(0)
})
it('keeps the drift figures out of the email body', async () => {
const { supabase } = makeSupabase({
members: [OWNER, ACCOUNTANT],
settings: { tax_contact_email: ACCOUNTANT.profiles.email },
})
await handleSkattekontoDriftDetected(payload, makeCtx(supabase))
const body = `${mockSendEmail.mock.calls[0][0].text}${mockSendEmail.mock.calls[0][0].html}`
expect(body).not.toContain('1250')
expect(body).not.toContain('10000')
})
})
@@ -27,6 +27,23 @@ function fakeCtx(overrides: {
} as unknown as Parameters<typeof computeSkattekontoDrift>[0]
}
/**
* Enqueue the two pages the 1630 sum reads through the two-step entry-lines
* fetch (lib/bookkeeping/entry-lines.ts): the parent entries first, then the
* bare lines keyed by journal_entry_id. Only the amounts are read downstream,
* so a single synthetic parent per line set is enough.
*/
function enqueueGlLines(
enqueue: (result: { data?: unknown; error?: unknown }) => void,
rows: Array<{ debit_amount: number; credit_amount: number }>,
) {
enqueue({ data: rows.length > 0 ? [{ id: 'entry-1' }] : [] })
if (rows.length === 0) return
enqueue({
data: rows.map((r, i) => ({ id: `line-${i}`, journal_entry_id: 'entry-1', ...r })),
})
}
describe('computeSkattekontoDrift', () => {
beforeEach(() => vi.clearAllMocks())
@@ -44,9 +61,7 @@ describe('computeSkattekontoDrift', () => {
it('computes drift = saldoSkatteverket - GL 1630 sum (positive when SKV ahead)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
// GL 1630 query: 5000 SEK debit
enqueue({
data: [{ debit_amount: 5000, credit_amount: 0 }],
})
enqueueGlLines(enqueue, [{ debit_amount: 5000, credit_amount: 0 }])
// Unbooked rows query
enqueue({ data: [] })
@@ -74,9 +89,37 @@ describe('computeSkattekontoDrift', () => {
expect(drift!.tolerance).toBe(1)
})
it('returns null (skips the drift pass) when the GL 1630 read fails', async () => {
// A transient read failure must NOT be treated as glSum1630 = 0: with a
// cached SKV saldo of 5500 that would compute drift = 5500 and (throttled)
// email a false "Skattekontot stämmer inte med bokföringen".
const { supabase, enqueue } = createQueuedMockSupabase()
// The entry-lines fetch errors -> fetchAllRows throws -> sumGl1630 fails.
enqueue({ error: { message: 'connection reset by peer' } })
const ctx = fakeCtx({
supabase,
settings: {
get: vi.fn().mockImplementation((key: string) => {
if (key === 'skattekonto_balance_snapshot') {
return Promise.resolve({
saldo: { saldoSkatteverket: 5500, saldoKronofogden: 0 },
fetchedAt: new Date('2026-06-12T04:00:00Z').getTime(),
})
}
return Promise.resolve(null)
}),
set: vi.fn(),
},
})
const drift = await computeSkattekontoDrift(ctx)
expect(drift).toBeNull()
})
it('honors a per-company override of the tolerance', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: [{ debit_amount: 1000, credit_amount: 0 }] })
enqueueGlLines(enqueue, [{ debit_amount: 1000, credit_amount: 0 }])
enqueue({ data: [] })
const ctx = fakeCtx({
@@ -119,17 +119,42 @@ function lineRow(opts: {
}
}
/**
* Enqueue the two pages the two-step entry-lines fetch reads
* (lib/bookkeeping/entry-lines.ts): the parent entries first, then the bare
* lines keyed by journal_entry_id. Fixtures stay embed-shaped; the helper
* reattaches the parent under `journal_entries`, which is exactly what the
* old `journal_entries!inner(...)` embed produced.
*/
function enqueueLines(
enqueue: (result: { data?: unknown; error?: unknown }) => void,
rows: ReturnType<typeof lineRow>[],
) {
const entries = [
...new Map(rows.map((r) => [r.journal_entries.id, r.journal_entries])).values(),
]
enqueue({ data: entries })
// No matching entry means the helper never queries the lines at all.
if (entries.length === 0) return
enqueue({
data: rows.map((r, i) => ({
id: `line-${String(i).padStart(4, '0')}`,
journal_entry_id: r.journal_entries.id,
debit_amount: r.debit_amount,
credit_amount: r.credit_amount,
})),
})
}
describe('findMatchSuggestionsBulk: AGI period disambiguation', () => {
it('picks the AGI-linked entry even when two amount-matches exist', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
// Two journal entries credit 1630 with the same amount: without period
// disambiguation, this would be ambiguous and return no suggestion.
enqueue({
data: [
lineRow({ entryId: 'je-other-month', credit: 12345, entryDate: '2026-06-10' }),
lineRow({ entryId: 'je-agi-may', credit: 12345, entryDate: '2026-06-12' }),
],
})
enqueueLines(enqueue, [
lineRow({ entryId: 'je-other-month', credit: 12345, entryDate: '2026-06-10' }),
lineRow({ entryId: 'je-agi-may', credit: 12345, entryDate: '2026-06-12' }),
])
enqueue({ data: [] }) // none already linked
// AGI declarations lookup
enqueue({
@@ -172,9 +197,8 @@ describe('findMatchSuggestionsBulk: AGI period disambiguation', () => {
it('falls back to amount-only matching when no AGI declaration exists for the period', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({
data: [lineRow({ entryId: 'je-unique', credit: 9999, entryDate: '2026-06-12' })],
})
enqueueLines(enqueue, [lineRow({ entryId: 'je-unique', credit: 9999, entryDate: '2026-06-12' })
])
enqueue({ data: [] })
// No AGI declaration for 2026-05
enqueue({ data: [] })
@@ -200,12 +224,10 @@ describe('findMatchSuggestionsBulk: AGI period disambiguation', () => {
it('returns no suggestion when AGI-linked entries do not match the amount', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
// Two amount-matching entries, neither is the AGI-linked one.
enqueue({
data: [
lineRow({ entryId: 'je-a', credit: 5000, entryDate: '2026-06-12' }),
lineRow({ entryId: 'je-b', credit: 5000, entryDate: '2026-06-13' }),
],
})
enqueueLines(enqueue, [
lineRow({ entryId: 'je-a', credit: 5000, entryDate: '2026-06-12' }),
lineRow({ entryId: 'je-b', credit: 5000, entryDate: '2026-06-13' }),
])
enqueue({ data: [] })
enqueue({
data: [
@@ -258,14 +280,12 @@ describe('findMatchCandidates: AGI period boost', () => {
it('moves the AGI-linked entry to position 0 even when a closer-by-date entry exists', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: txRow() })
enqueue({
data: [
// closer to SKV date but unrelated
lineRow({ entryId: 'je-close', credit: 12345, entryDate: '2026-06-12' }),
// further from SKV date but AGI-linked
lineRow({ entryId: 'je-agi', credit: 12345, entryDate: '2026-06-05' }),
],
})
enqueueLines(enqueue, [
// closer to SKV date but unrelated
lineRow({ entryId: 'je-close', credit: 12345, entryDate: '2026-06-12' }),
// further from SKV date but AGI-linked
lineRow({ entryId: 'je-agi', credit: 12345, entryDate: '2026-06-05' }),
])
enqueue({ data: [] }) // none linked
enqueue({
data: [
@@ -47,6 +47,33 @@ function lineRow(opts: {
}
}
/**
* Enqueue the two pages the two-step entry-lines fetch reads
* (lib/bookkeeping/entry-lines.ts): the parent entries first, then the bare
* lines keyed by journal_entry_id. Fixtures stay embed-shaped; the helper
* reattaches the parent under `journal_entries`, which is exactly what the
* old `journal_entries!inner(...)` embed produced.
*/
function enqueueLines(
enqueue: (result: { data?: unknown; error?: unknown }) => void,
rows: ReturnType<typeof lineRow>[],
) {
const entries = [
...new Map(rows.map((r) => [r.journal_entries.id, r.journal_entries])).values(),
]
enqueue({ data: entries })
// No matching entry means the helper never queries the lines at all.
if (entries.length === 0) return
enqueue({
data: rows.map((r, i) => ({
id: `line-${String(i).padStart(4, '0')}`,
journal_entry_id: r.journal_entries.id,
debit_amount: r.debit_amount,
credit_amount: r.credit_amount,
})),
})
}
// ──────────────────────────────────────────────────────────────────────
// findMatchCandidates
// ──────────────────────────────────────────────────────────────────────
@@ -55,7 +82,7 @@ describe('findMatchCandidates', () => {
it('returns candidate verifikat that debits 1630 with matching amount (positive SKV → looks for debit on 1630)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: txRow() })
enqueue({ data: [lineRow({ entryId: 'je-1', debit: 5000, credit: 0 })] })
enqueueLines(enqueue, [lineRow({ entryId: 'je-1', debit: 5000, credit: 0 })])
enqueue({ data: [] }) // no already-linked
const result = await findMatchCandidates(supabase as never, COMPANY, TX_ID)
@@ -70,7 +97,7 @@ describe('findMatchCandidates', () => {
it('uses credit 1630 lookup when SKV amount is negative (money leaving skattekontot)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: txRow({ belopp_skatteverket: -8333, transaktionstext: 'Debiterad F-skatt' }) })
enqueue({ data: [lineRow({ entryId: 'je-7', debit: 0, credit: 8333 })] })
enqueueLines(enqueue, [lineRow({ entryId: 'je-7', debit: 0, credit: 8333 })])
enqueue({ data: [] })
const result = await findMatchCandidates(supabase as never, COMPANY, TX_ID)
@@ -100,12 +127,10 @@ describe('findMatchCandidates', () => {
it('filters out entries already linked to another SKV row', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: txRow() })
enqueue({
data: [
lineRow({ entryId: 'je-1', debit: 5000, credit: 0 }),
lineRow({ entryId: 'je-2', debit: 5000, credit: 0 }),
],
})
enqueueLines(enqueue, [
lineRow({ entryId: 'je-1', debit: 5000, credit: 0 }),
lineRow({ entryId: 'je-2', debit: 5000, credit: 0 }),
])
enqueue({ data: [{ journal_entry_id: 'je-1' }] }) // je-1 already linked
const result = await findMatchCandidates(supabase as never, COMPANY, TX_ID)
@@ -133,13 +158,11 @@ describe('findMatchCandidates', () => {
it('orders candidates by date proximity to the SKV row', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: txRow({ transaktionsdatum: '2026-03-17' }) })
enqueue({
data: [
lineRow({ entryId: 'je-far', debit: 5000, entryDate: '2026-03-08' }), // 9 days
lineRow({ entryId: 'je-close', debit: 5000, entryDate: '2026-03-16' }), // 1 day
lineRow({ entryId: 'je-mid', debit: 5000, entryDate: '2026-03-12' }), // 5 days
],
})
enqueueLines(enqueue, [
lineRow({ entryId: 'je-far', debit: 5000, entryDate: '2026-03-08' }), // 9 days
lineRow({ entryId: 'je-close', debit: 5000, entryDate: '2026-03-16' }), // 1 day
lineRow({ entryId: 'je-mid', debit: 5000, entryDate: '2026-03-12' }), // 5 days
])
enqueue({ data: [] })
const result = await findMatchCandidates(supabase as never, COMPANY, TX_ID)
@@ -265,13 +288,11 @@ describe('matchSkattekontoToEntry', () => {
describe('findMatchSuggestionsBulk', () => {
it('returns a suggestion only when exactly one candidate matches per row', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({
data: [
lineRow({ entryId: 'je-unique', debit: 5000, entryDate: '2026-03-16' }),
// unrelated different-amount line that should not match
lineRow({ entryId: 'je-other', debit: 9999, entryDate: '2026-03-16' }),
],
})
enqueueLines(enqueue, [
lineRow({ entryId: 'je-unique', debit: 5000, entryDate: '2026-03-16' }),
// unrelated different-amount line that should not match
lineRow({ entryId: 'je-other', debit: 9999, entryDate: '2026-03-16' }),
])
enqueue({ data: [] }) // none linked
const suggestions = await findMatchSuggestionsBulk(supabase as never, COMPANY, [
@@ -289,12 +310,10 @@ describe('findMatchSuggestionsBulk', () => {
it('returns no suggestion when there are TWO candidates (ambiguous)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({
data: [
lineRow({ entryId: 'je-a', debit: 5000, entryDate: '2026-03-15' }),
lineRow({ entryId: 'je-b', debit: 5000, entryDate: '2026-03-16' }),
],
})
enqueueLines(enqueue, [
lineRow({ entryId: 'je-a', debit: 5000, entryDate: '2026-03-15' }),
lineRow({ entryId: 'je-b', debit: 5000, entryDate: '2026-03-16' }),
])
enqueue({ data: [] })
const suggestions = await findMatchSuggestionsBulk(supabase as never, COMPANY, [
@@ -341,12 +360,10 @@ describe('findMatchSuggestionsBulk', () => {
it('skips candidates whose entry_date is outside the per-row ±14 day window', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({
data: [
// 20 days before the SKV row: too far
lineRow({ entryId: 'je-far', debit: 5000, entryDate: '2026-02-25' }),
],
})
enqueueLines(enqueue, [
// 20 days before the SKV row: too far
lineRow({ entryId: 'je-far', debit: 5000, entryDate: '2026-02-25' }),
])
enqueue({ data: [] })
const suggestions = await findMatchSuggestionsBulk(supabase as never, COMPANY, [
@@ -362,9 +379,8 @@ describe('findMatchSuggestionsBulk', () => {
it('excludes entries that are already linked to a different SKV row', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({
data: [lineRow({ entryId: 'je-linked', debit: 5000, entryDate: '2026-03-16' })],
})
enqueueLines(enqueue, [lineRow({ entryId: 'je-linked', debit: 5000, entryDate: '2026-03-16' })
])
enqueue({ data: [{ journal_entry_id: 'je-linked' }] })
const suggestions = await findMatchSuggestionsBulk(supabase as never, COMPANY, [
@@ -380,12 +396,10 @@ describe('findMatchSuggestionsBulk', () => {
it('respects sign convention per row: negative SKV needs a credit on 1630', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({
data: [
// Debit-side line: wrong side for a -8333 SKV row
lineRow({ entryId: 'je-wrong-side', debit: 8333, entryDate: '2026-03-16' }),
],
})
enqueueLines(enqueue, [
// Debit-side line: wrong side for a -8333 SKV row
lineRow({ entryId: 'je-wrong-side', debit: 8333, entryDate: '2026-03-16' }),
])
enqueue({ data: [] })
const suggestions = await findMatchSuggestionsBulk(supabase as never, COMPANY, [
+3 -1
View File
@@ -112,7 +112,9 @@ const log = createLogger('skatteverket')
* SKATTEVERKET_SKATTEKONTO_API_BASE_URL=https://api.skatteverket.se/beskattning/skattekonto/v2
* SKATTEVERKET_OAUTH_BASE_URL=https://peroauth2.skatteverket.se/oauth2/v1/per
* (per-flow prod host, verified resolving; oauth2.skatteverket.se does not exist)
* 8. Verify Sentry alerts on /api/extensions/ext/skatteverket/* 5xx.
* 8. Wire an observability provider (lib/observability has the sink but no
* adapter is registered yet) and verify it alerts on
* /api/extensions/ext/skatteverket/* 5xx.
* 9. Verify 7-year retention of `agi_declarations.xml_content` +
* `kvittensnummer` (BFL 7 kap.).
* 10. Run a single AGI end-to-end against test on a real client before
@@ -15,7 +15,7 @@ const log = createLogger('skattekonto-drift-email')
* financial figures.
*
* Recipient resolution is restricted to active members of the company. A
* stale company_settings.contact_email that no longer corresponds to a
* stale company_settings.tax_contact_email that no longer corresponds to a
* member is never used. Falls back to the syncing user only if they're
* still an active member.
*
@@ -109,10 +109,15 @@ export async function handleSkattekontoDriftDetected(
/**
* Resolve the recipient address for the drift alert and verify it belongs to
* an active member of the company. A stale company_settings.contact_email
* an active member of the company. A stale company_settings.tax_contact_email
* (set when a now-revoked admin still owned the company) must never receive
* a drift notification because the bare existence of one is sensitive
* financial signal.
*
* `tax_contact_email` is the "Kontaktperson för skatteärenden" field in
* Inställningar > Skatt (components/settings/TaxSettingsForm.tsx): the only
* place a company routes Skatteverket correspondence to someone other than
* whoever happened to trigger the sync.
*/
async function resolveAuthorisedRecipient(
ctx: ExtensionContext,
@@ -120,11 +125,20 @@ async function resolveAuthorisedRecipient(
): Promise<string | null> {
// 1. Build the set of active member emails for this company. We accept
// only addresses that appear here.
const { data: members } = await ctx.supabase
const { data: members, error: membersError } = await ctx.supabase
.from('company_members')
.select('user_id, profiles!inner(email)')
.eq('company_id', ctx.companyId)
if (membersError) {
// Without the allowlist every candidate is rejected below, so a failed
// lookup silently cancels the alert. Say so out loud.
log.warn('could not read company members for drift alert', {
companyId: ctx.companyId,
error: membersError.message,
})
}
type MemberRow = { user_id: string; profiles: { email?: string | null } | { email?: string | null }[] | null }
const allowedEmails = new Set<string>()
for (const m of (members ?? []) as MemberRow[]) {
@@ -134,24 +148,46 @@ async function resolveAuthorisedRecipient(
if (allowedEmails.size === 0) return null
// 2. Prefer the configured contact email IF it matches an active member.
const { data: settings } = await ctx.supabase
// 2. Prefer the configured tax contact email IF it matches an active member.
const { data: settings, error: settingsError } = await ctx.supabase
.from('company_settings')
.select('contact_email')
.select('tax_contact_email')
.eq('company_id', ctx.companyId)
.maybeSingle()
const contactEmail = (settings as { contact_email?: string | null } | null)?.contact_email
if (contactEmail && allowedEmails.has(contactEmail.toLowerCase())) {
return contactEmail
if (settingsError) {
// Falling back to the syncing user is correct, but doing it without a
// trace is how a company silently stops getting alerts where it asked
// for them.
log.warn('could not read tax contact email: falling back to syncing user', {
companyId: ctx.companyId,
error: settingsError.message,
})
}
const contactEmail = (settings as { tax_contact_email?: string | null } | null)?.tax_contact_email
if (contactEmail) {
if (allowedEmails.has(contactEmail.toLowerCase())) {
return contactEmail
}
log.warn('configured tax contact is not an active member: falling back to syncing user', {
companyId: ctx.companyId,
})
}
// 3. Fall back to the syncing user's email if they're still a member.
const { data: profile } = await ctx.supabase
const { data: profile, error: profileError } = await ctx.supabase
.from('profiles')
.select('email')
.eq('id', userId)
.maybeSingle()
if (profileError) {
log.warn('could not read syncing user profile for drift alert', {
companyId: ctx.companyId,
userId,
error: profileError.message,
})
}
const userEmail = (profile as { email?: string | null } | null)?.email
if (userEmail && allowedEmails.has(userEmail.toLowerCase())) {
return userEmail
@@ -3,6 +3,7 @@ import type { ExtensionContext } from '@/lib/extensions/types'
import type { SkattekontoBalanceSnapshot } from '../types'
import { SKATTEKONTO_BALANCE_SNAPSHOT_KEY } from './skattekonto-sync'
import { createLogger } from '@/lib/logger'
import { fetchEntryLines, type EntryLinesQuery } from '@/lib/bookkeeping/entry-lines'
const log = createLogger('skattekonto-drift')
@@ -39,7 +40,9 @@ export interface DriftAlertState {
/**
* Compute the difference between Skatteverket's cached saldo and the GL sum on
* BAS 1630. Returns null when no snapshot exists yet (fresh company, never
* synced).
* synced), and null when the GL read fails: a transient read failure must
* skip the drift pass for this run, not compute drift = the full SKV saldo
* and email a false "Skattekontot stämmer inte med bokföringen".
*
* The comparison uses the snapshot's `fetchedAt` date as the GL cutoff: a
* skattekonto sync at 04:00 today should only count GL entries posted with
@@ -58,6 +61,16 @@ export async function computeSkattekontoDrift(
const saldoSkatteverket = Number(snapshot.saldo.saldoSkatteverket) || 0
const glSum1630 = await sumGl1630(ctx.supabase, ctx.companyId, fetchedDate)
if (glSum1630 === null) {
// The read failed (already warn-logged in sumGl1630 with the reason).
// Skip the drift pass for this company this run: a 0-substitute would
// make the "drift" equal the entire SKV saldo.
log.warn('skipping drift check for this run: GL 1630 read failed', {
companyId: ctx.companyId,
cutoffDate: fetchedDate,
})
return null
}
// SKV side and GL side use opposite sign conventions in this codebase:
// - SKV saldoSkatteverket > 0 means the taxpayer has a credit balance
// with SKV (money sitting at Skatteverket).
@@ -134,26 +147,44 @@ export async function maybeAlertDrift(
return true
}
/**
* Sum debit - credit on BAS 1630 up to the cutoff. Returns null (NOT 0) when
* the read fails: 0 is a real balance claim ("nothing booked on 1630"), and
* substituting it for a failed read turns every transient DB blip into a
* full-saldo drift alert.
*/
async function sumGl1630(
supabase: SupabaseClient,
companyId: string,
cutoffDate: string,
): Promise<number> {
const { data, error } = await supabase
.from('journal_entry_lines')
.select('debit_amount, credit_amount, journal_entries!inner(company_id, entry_date, status)')
.eq('account_number', SKATTEKONTO_BAS_ACCOUNT)
.eq('journal_entries.company_id', companyId)
.eq('journal_entries.status', 'posted')
.lte('journal_entries.entry_date', cutoffDate)
if (error || !data) {
log.warn('sumGl1630 failed', { companyId, cutoffDate, error: error?.message })
return 0
): Promise<number | null> {
// Driven from the journal_entries side (lib/bookkeeping/entry-lines.ts):
// the scope filters used to sit on a `journal_entries!inner` embed, which
// PostgREST compiles into a correlated LATERAL join that walks the ENTIRE
// journal_entry_lines table across all tenants. Both steps paginate, so a
// company with more than 1000 skattekonto lines is no longer silently
// truncated (which would have under-reported the drift).
let data: Array<{ debit_amount: number | string; credit_amount: number | string }>
try {
data = await fetchEntryLines({
supabase,
lineColumns: 'debit_amount, credit_amount',
filterEntries: (q: EntryLinesQuery) =>
q.eq('company_id', companyId).eq('status', 'posted').lte('entry_date', cutoffDate),
filterLines: (q: EntryLinesQuery) => q.eq('account_number', SKATTEKONTO_BAS_ACCOUNT),
attachEntriesAs: null,
})
} catch (err) {
log.warn('sumGl1630 failed', {
companyId,
cutoffDate,
error: err instanceof Error ? err.message : String(err),
})
return null
}
let sum = 0
for (const row of data as Array<{ debit_amount: number | string; credit_amount: number | string }>) {
for (const row of data) {
sum += Number(row.debit_amount || 0) - Number(row.credit_amount || 0)
}
return Math.round(sum * 100) / 100
@@ -1,5 +1,6 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import type { StoredSkattekontoTransaction } from '../types'
import { fetchEntryLines, type EntryLinesQuery } from '@/lib/bookkeeping/entry-lines'
/**
* "Matcha mot befintligt verifikat"-flöde för skattekonto-rader.
@@ -285,31 +286,6 @@ export async function findMatchSuggestionsBulk(
const from = addDays(dates[0], -DATE_WINDOW_DAYS)
const to = addDays(dates[dates.length - 1], DATE_WINDOW_DAYS)
const { data, error } = await supabase
.from('journal_entry_lines')
.select(
`
debit_amount,
credit_amount,
journal_entries!inner (
id,
voucher_number,
voucher_series,
entry_date,
description,
status,
company_id
)
`,
)
.eq('account_number', SKATTEKONTO_ACCOUNT)
.eq('journal_entries.company_id', companyId)
.gte('journal_entries.entry_date', from)
.lte('journal_entries.entry_date', to)
.neq('journal_entries.status', 'reversed')
if (error || !data) return new Map()
type Row = {
debit_amount: number
credit_amount: number
@@ -323,7 +299,30 @@ export async function findMatchSuggestionsBulk(
company_id: string
}
}
const lines = data as unknown as Row[]
// Driven from the journal_entries side (lib/bookkeeping/entry-lines.ts):
// the scope filters used to sit on a `journal_entries!inner` embed, which
// PostgREST compiles into a correlated LATERAL join that walks the ENTIRE
// journal_entry_lines table across all tenants. The parent is reattached
// under the same `journal_entries` key, so the candidate build is unchanged.
let lines: Row[]
try {
lines = await fetchEntryLines<Row>({
supabase,
entryColumns: 'id, voucher_number, voucher_series, entry_date, description, status, company_id',
lineColumns: 'debit_amount, credit_amount',
filterEntries: (q: EntryLinesQuery) =>
q
.eq('company_id', companyId)
.gte('entry_date', from)
.lte('entry_date', to)
.neq('status', 'reversed'),
filterLines: (q: EntryLinesQuery) => q.eq('account_number', SKATTEKONTO_ACCOUNT),
})
} catch {
// Unchanged posture: a candidate-search failure yields no suggestions.
return new Map()
}
// Filter out entries already linked to another SKV row.
const candidateEntryIds = Array.from(new Set(lines.map(l => l.journal_entries.id)))
@@ -468,43 +467,6 @@ export async function findMatchCandidates(
const from = addDays(tx.transaktionsdatum, -DATE_WINDOW_DAYS)
const to = addDays(tx.transaktionsdatum, DATE_WINDOW_DAYS)
// Query 1630-lines with the right amount + side, joined to entries in
// the date window. `!inner` filters out rows whose joined entry doesn't
// match (Supabase pg-rest convention).
let q = supabase
.from('journal_entry_lines')
.select(
`
debit_amount,
credit_amount,
journal_entries!inner (
id,
voucher_number,
voucher_series,
entry_date,
description,
status,
company_id
)
`,
)
.eq('account_number', SKATTEKONTO_ACCOUNT)
.eq('journal_entries.company_id', companyId)
.gte('journal_entries.entry_date', from)
.lte('journal_entries.entry_date', to)
.neq('journal_entries.status', 'reversed')
if (side === 'debit') {
q = q.eq('debit_amount', amount).eq('credit_amount', 0)
} else {
q = q.eq('credit_amount', amount).eq('debit_amount', 0)
}
const { data: rows, error: rowsError } = await q.limit(50)
if (rowsError) {
throw new Error(`Kunde inte söka kandidater: ${rowsError.message}`)
}
type Row = {
debit_amount: number
credit_amount: number
@@ -518,7 +480,37 @@ export async function findMatchCandidates(
company_id: string
}
}
const typedRows = (rows ?? []) as unknown as Row[]
// 1630-lines with the right amount + side, scoped to entries in the date
// window. The scope used to sit on a `journal_entries!inner` embed, which
// PostgREST compiles into a correlated LATERAL join that walks the ENTIRE
// journal_entry_lines table across all tenants (see
// lib/bookkeeping/entry-lines.ts). The old `.limit(50)` went with it: the
// amount+side match is exact, so the candidate set is already tiny.
let typedRows: Row[]
try {
typedRows = await fetchEntryLines<Row>({
supabase,
entryColumns: 'id, voucher_number, voucher_series, entry_date, description, status, company_id',
lineColumns: 'debit_amount, credit_amount',
filterEntries: (q: EntryLinesQuery) =>
q
.eq('company_id', companyId)
.gte('entry_date', from)
.lte('entry_date', to)
.neq('status', 'reversed'),
filterLines: (q: EntryLinesQuery) => {
const scoped = q.eq('account_number', SKATTEKONTO_ACCOUNT)
return side === 'debit'
? scoped.eq('debit_amount', amount).eq('credit_amount', 0)
: scoped.eq('credit_amount', amount).eq('debit_amount', 0)
},
})
} catch (err) {
throw new Error(
`Kunde inte söka kandidater: ${err instanceof Error ? err.message : String(err)}`
)
}
if (typedRows.length === 0) {
return { tx, candidates: [] }
@@ -0,0 +1,278 @@
/**
* Regression guards for the Stripe settings panel's silent failures.
*
* Every assertion below describes something the panel did NOT do before: its
* click handlers were `try { ... } finally { setSpinner(false) }` with no
* `catch`, so a rejected fetch produced no toast at all, and its success arm
* read `data.transactions?.fetched ?? 0`, so a 200 that carried a revoked
* connection (or a body that never parsed) was reported as a completed sync that
* "returned no transactions for the period".
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import {
stripeRequest,
syncSummary,
serverErrorMessage,
STRIPE_ACTION_TIMEOUT_MS,
STRIPE_SYNC_TIMEOUT_MS,
type StripeSyncPayload,
} from '../lib/settings-actions'
const originalFetch = globalThis.fetch
const SYNC_URL = '/api/extensions/ext/stripe/sync'
const DISCONNECT_URL = '/api/extensions/ext/stripe/disconnect'
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
}
/** A fetch that never resolves and rejects the way the platform does on abort. */
function hangingFetch() {
return vi.fn((_url: unknown, init?: RequestInit) => {
return new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener('abort', () => {
const err = new Error('The operation was aborted due to timeout')
err.name = 'TimeoutError'
reject(err)
})
})
})
}
/** The shape the sync route returns on a healthy run. */
function syncPayload(
transactions: Partial<NonNullable<StripeSyncPayload['transactions']>>,
): StripeSyncPayload {
return {
success: true,
transactions: {
fetched: 0,
imported: 0,
duplicates: 0,
linked: 0,
errors: 0,
...transactions,
},
}
}
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
globalThis.fetch = originalFetch
})
describe('stripeRequest', () => {
it('reports a rejected request as a network failure instead of saying nothing', async () => {
// The finding: with try/finally and no catch this rejection escaped the
// handler entirely. The spinner spun, stopped, and no toast was ever shown.
globalThis.fetch = vi.fn().mockRejectedValue(new TypeError('fetch failed'))
const result = await stripeRequest({ url: SYNC_URL })
expect(result).toEqual({
ok: false,
reason: 'network',
message: 'Något gick fel. Försök igen.',
})
})
it('reports a hung request as a timeout, not as a generic failure', async () => {
globalThis.fetch = hangingFetch()
const result = await stripeRequest({ url: SYNC_URL, timeoutMs: 20 })
// A timeout on a mutation is ambiguous (the write may have landed), so the
// panel needs it apart from a network failure to say "reload and check".
expect(result).toEqual({ ok: false, reason: 'timeout' })
})
it('keeps the route own sentence on a 404 rather than the status map', async () => {
// getErrorMessage alone answers 'Resursen kunde inte hittas.' here, because
// this sentence carries none of its Swedish trigger words. That is strictly
// less useful than the route's own copy, which names what to fix.
globalThis.fetch = vi
.fn()
.mockResolvedValue(jsonResponse({ error: 'Inget anslutet Stripe-konto.' }, 404))
const result = await stripeRequest({ url: SYNC_URL })
expect(result).toEqual({
ok: false,
reason: 'server',
status: 404,
message: 'Inget anslutet Stripe-konto.',
})
})
it('falls back to the status message when the error body carries no sentence', async () => {
// Previously this branch showed the hardcoded 'Något gick fel. Försök igen.'
// for every status, so a 429 from the rate limiter and a 502 from a dead
// upstream read identically.
globalThis.fetch = vi.fn().mockResolvedValue(
new Response('<html>502 Bad Gateway</html>', {
status: 502,
headers: { 'Content-Type': 'text/html' },
}),
)
const result = await stripeRequest({ url: SYNC_URL })
expect(result).toMatchObject({
reason: 'server',
status: 502,
message: 'Servern är tillfälligt otillgänglig. Försök igen om en stund.',
})
})
it('prefers error_en for an English UI', async () => {
// capabilityBlockedResponse emits both halves; getErrorMessage only reads
// message_en inside a structured envelope, so this top-level pair needs
// handling here or English users get the Swedish sentence.
globalThis.fetch = vi.fn().mockResolvedValue(
jsonResponse(
{
error: 'Funktionen kräver en aktiv prenumeration.',
error_en: 'This feature requires an active subscription.',
capability_blocked: true,
},
403,
),
)
const result = await stripeRequest({ url: SYNC_URL, locale: 'en' })
expect(result).toMatchObject({
reason: 'server',
message: 'This feature requires an active subscription.',
})
})
it('returns the parsed success body', async () => {
globalThis.fetch = vi.fn().mockResolvedValue(jsonResponse(syncPayload({ fetched: 3, imported: 2 })))
const result = await stripeRequest<StripeSyncPayload>({ url: SYNC_URL })
expect(result.ok).toBe(true)
expect(result.ok && result.data?.transactions?.fetched).toBe(3)
})
it('reports success with null data when a 2xx body is not readable JSON', async () => {
globalThis.fetch = vi.fn().mockResolvedValue(
new Response('', { status: 200, headers: { 'Content-Type': 'application/json' } }),
)
const result = await stripeRequest<StripeSyncPayload>({ url: SYNC_URL })
// Not a failure: the server committed before it flushed headers.
expect(result).toEqual({ ok: true, data: null })
})
it('sends the JSON body and method it is given', async () => {
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ success: true }))
globalThis.fetch = fetchMock
await stripeRequest({
url: DISCONNECT_URL,
method: 'DELETE',
body: { connection_id: 'c0ffee00-0000-4000-8000-000000000001' },
})
const init = fetchMock.mock.calls[0][1] as RequestInit
expect(init.method).toBe('DELETE')
expect(init.body).toBe('{"connection_id":"c0ffee00-0000-4000-8000-000000000001"}')
})
it('omits the body entirely for the routes that ignore it', async () => {
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ success: true }))
globalThis.fetch = fetchMock
await stripeRequest({ url: SYNC_URL })
const init = fetchMock.mock.calls[0][1] as RequestInit
expect(init.method).toBe('POST')
expect(init.body).toBeUndefined()
})
it('bounds every request with an abort signal', async () => {
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ success: true }))
globalThis.fetch = fetchMock
await stripeRequest({ url: SYNC_URL })
const init = fetchMock.mock.calls[0][1] as RequestInit
expect(init.signal).toBeInstanceOf(AbortSignal)
})
it('bounds the sync at the route ceiling, not at the quick-write deadline', async () => {
// maxDuration is 300 on app/api/extensions/ext/[...path]/route.ts and a first
// sync backfills 90 days: a 15s abort would report a failure for a run that
// keeps going server-side and advances the cursor.
expect(STRIPE_ACTION_TIMEOUT_MS).toBe(15_000)
expect(STRIPE_SYNC_TIMEOUT_MS).toBeGreaterThan(300_000)
})
})
describe('serverErrorMessage', () => {
it('keeps the Swedish sentence for a Swedish UI even when error_en exists', () => {
expect(
serverErrorMessage(
{ error: 'Synkroniseringen misslyckades. Försök igen.', error_en: 'Sync failed.' },
502,
'sv',
),
).toBe('Synkroniseringen misslyckades. Försök igen.')
})
it('ignores a blank error field', () => {
expect(serverErrorMessage({ error: ' ' }, 500, 'sv')).toBe(
'Ett oväntat serverfel uppstod. Försök igen senare.',
)
})
})
describe('syncSummary', () => {
it('reports a revoked connection as a failure, not as an empty window', () => {
// The regression this exists for: Stripe revoking the connection upstream
// comes back as a 200 with { revoked: true } and every count at zero, and the
// panel told the user "Stripe returnerade inga transaktioner för perioden.
// Kontrollera att rätt konto är anslutet" under a green "Synkronisering
// klar". The account was right; the connection was gone.
expect(syncSummary(syncPayload({ revoked: true }))).toEqual({ reason: 'revoked' })
})
it('says how many rows failed instead of reading as "all duplicates"', () => {
// 200 fetched and 0 imported reads as a window full of already-known rows.
// With errors > 0 it means the opposite: the rows are missing from the inbox.
expect(syncSummary(syncPayload({ fetched: 200, imported: 0, errors: 200 }))).toEqual({
reason: 'errors',
values: { fetched: 200, imported: 0, linked: 0, errors: 200 },
})
})
it('does not call an unreadable body an empty window', () => {
// `data.transactions?.fetched ?? 0` turned a truncated 2xx into a confident
// "Stripe returned no transactions", which is a claim about Stripe that the
// response never made.
expect(syncSummary(null)).toEqual({ reason: 'unknown' })
expect(syncSummary({ success: true })).toEqual({ reason: 'unknown' })
expect(syncSummary({ success: true, transactions: {} })).toEqual({ reason: 'unknown' })
})
it('reports a genuinely empty window as empty', () => {
expect(syncSummary(syncPayload({ fetched: 0 }))).toEqual({ reason: 'empty' })
})
it('reports the counts of a healthy run', () => {
expect(
syncSummary(syncPayload({ fetched: 12, imported: 9, duplicates: 3, linked: 4 })),
).toEqual({ reason: 'feed', values: { fetched: 12, imported: 9, linked: 4 } })
})
})
@@ -1,7 +1,7 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { useTranslations } from 'next-intl'
import { useLocale, useTranslations } from 'next-intl'
import { useRouter, useSearchParams } from 'next/navigation'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
@@ -10,7 +10,15 @@ import { Switch } from '@/components/ui/switch'
import { Skeleton } from '@/components/ui/skeleton'
import { useToast } from '@/components/ui/use-toast'
import { useFormat } from '@/lib/hooks/use-format'
import { failureDescription } from '@/lib/browser/action-failure'
import type { ErrorLocale } from '@/lib/errors/get-error-message'
import { CreditCard, Link2, Loader2, RefreshCw, Unlink } from 'lucide-react'
import {
stripeRequest,
syncSummary,
STRIPE_SYNC_TIMEOUT_MS,
type StripeSyncPayload,
} from '../lib/settings-actions'
import type { StripeStatusResponse } from '../types'
type ConnectionInfo = NonNullable<StripeStatusResponse['connection']>
@@ -24,12 +32,15 @@ const STATUS_VARIANT: Record<ConnectionInfo['status'], 'success' | 'secondary' |
export default function StripeSettingsPanel() {
const t = useTranslations('settings_payments')
const tCommon = useTranslations('common')
const locale = useLocale() as ErrorLocale
const { toast } = useToast()
const router = useRouter()
const searchParams = useSearchParams()
const { formatDateLong } = useFormat()
const [loading, setLoading] = useState(true)
const [loadFailed, setLoadFailed] = useState(false)
const [configured, setConfigured] = useState(false)
const [connection, setConnection] = useState<ConnectionInfo | null>(null)
const [connecting, setConnecting] = useState(false)
@@ -38,22 +49,40 @@ export default function StripeSettingsPanel() {
const [syncing, setSyncing] = useState(false)
const [togglingTransactionSync, setTogglingTransactionSync] = useState(false)
// The failure copy is the same sentence for every action here: all four calls
// are quick writes whose outcome the panel itself reveals once reloaded, so a
// timeout says "reload and check" and a dead connection says "check the
// connection", regardless of which button was pressed.
const failureCopy = { timeout: t('action_timeout'), network: t('action_network') }
const loadStatus = useCallback(async () => {
try {
const res = await fetch('/api/extensions/ext/stripe/status')
if (!res.ok) return
const data = (await res.json()) as StripeStatusResponse
setConfigured(data.configured)
setConnection(data.connection)
} finally {
setLoading(false)
// A failed status read must never render as "not configured": that message
// tells the user to contact an administrator about an installation that is
// in fact configured, and it is what a swallowed error produced here.
const result = await stripeRequest<StripeStatusResponse>({
url: '/api/extensions/ext/stripe/status',
method: 'GET',
locale,
})
setLoading(false)
if (!result.ok || !result.data) {
setLoadFailed(true)
return
}
}, [])
setLoadFailed(false)
setConfigured(result.data.configured)
setConnection(result.data.connection)
}, [locale])
useEffect(() => {
void loadStatus()
}, [loadStatus])
function retryLoadStatus() {
setLoading(true)
void loadStatus()
}
// Consume the one-shot OAuth bounce-back params (?stripe_connected=true /
// ?stripe_error=...) off the render path, mirroring the banking section.
useEffect(() => {
@@ -82,65 +111,73 @@ export default function StripeSettingsPanel() {
}, [searchParams, router, toast, t])
async function handleConnect() {
if (connecting) return
setConnecting(true)
try {
const res = await fetch('/api/extensions/ext/stripe/connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
// The route ignores the request body, so none is sent.
const result = await stripeRequest<{ url?: string }>({
url: '/api/extensions/ext/stripe/connect',
locale,
})
const data = (await res.json()) as { url?: string; error?: string }
if (!res.ok || !data.url) {
if (!result.ok || !result.data?.url) {
toast({
title: t('connect_failed_title'),
description: data.error || t('error_generic'),
description: result.ok
// A 2xx with no url is a route bug: nothing more specific is known.
? t('error_generic')
: failureDescription(result, failureCopy),
variant: 'destructive',
})
return
}
window.location.href = data.url
} catch {
toast({ title: t('connect_failed_title'), description: t('error_generic'), variant: 'destructive' })
window.location.href = result.data.url
} finally {
setConnecting(false)
}
}
async function handleSyncNow() {
if (syncing) return
setSyncing(true)
try {
const res = await fetch('/api/extensions/ext/stripe/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
const result = await stripeRequest<StripeSyncPayload>({
url: '/api/extensions/ext/stripe/sync',
locale,
timeoutMs: STRIPE_SYNC_TIMEOUT_MS,
})
const data = (await res.json().catch(() => ({}))) as {
transactions?: { fetched?: number; imported?: number; linked?: number }
error?: string
}
if (!res.ok) {
// Exactly one toast per outcome: TOAST_LIMIT is 1, so a second toast in
// the same tick evicts the first and only the last one renders.
if (!result.ok) {
toast({
title: t('sync_failed_title'),
description: data.error || t('error_generic'),
description: failureDescription(result, failureCopy),
variant: 'destructive',
})
return
}
// Honest summary: report what Stripe actually returned. An all-zero
// run is a real answer ("the account had nothing in the window"), not
// a silent success.
const fetched = data.transactions?.fetched ?? 0
toast({
title: t('sync_done_title'),
description:
fetched === 0
? t('sync_done_empty')
: t('sync_done_feed', {
fetched,
imported: data.transactions?.imported ?? 0,
linked: data.transactions?.linked ?? 0,
}),
})
// Honest summary: report what Stripe actually returned. An all-zero run is
// a real answer ("the account had nothing in the window"), a revoked
// connection is not, and a body that never parsed is neither.
const summary = syncSummary(result.data)
if (summary.reason === 'revoked') {
toast({
title: t('sync_failed_title'),
description: t('sync_revoked'),
variant: 'destructive',
})
} else if (summary.reason === 'empty') {
toast({ title: t('sync_done_title'), description: t('sync_done_empty') })
} else if (summary.reason === 'errors') {
toast({
title: t('sync_done_title'),
description: t('sync_done_feed_errors', summary.values),
})
} else if (summary.reason === 'feed') {
toast({ title: t('sync_done_title'), description: t('sync_done_feed', summary.values) })
} else {
// The sync ran, but the response never said what it did. Claim only that.
toast({ title: t('sync_done_title') })
}
await loadStatus()
} finally {
setSyncing(false)
@@ -148,18 +185,18 @@ export default function StripeSettingsPanel() {
}
async function handleToggleTransactionSync(enabled: boolean) {
if (togglingTransactionSync) return
setTogglingTransactionSync(true)
try {
const res = await fetch('/api/extensions/ext/stripe/transaction-sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled }),
const result = await stripeRequest({
url: '/api/extensions/ext/stripe/transaction-sync',
body: { enabled },
locale,
})
if (!res.ok) {
const data = (await res.json().catch(() => ({}))) as { error?: string }
if (!result.ok) {
toast({
title: t('transaction_sync_toggle_failed'),
description: data.error,
description: failureDescription(result, failureCopy),
variant: 'destructive',
})
return
@@ -170,27 +207,25 @@ export default function StripeSettingsPanel() {
: 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
if (!connection || disconnecting) return
setDisconnecting(true)
try {
const res = await fetch('/api/extensions/ext/stripe/disconnect', {
const result = await stripeRequest({
url: '/api/extensions/ext/stripe/disconnect',
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ connection_id: connection.id }),
body: { connection_id: connection.id },
locale,
})
if (!res.ok) {
const data = (await res.json().catch(() => ({}))) as { error?: string }
if (!result.ok) {
toast({
title: t('disconnect_failed_title'),
description: data.error || t('error_generic'),
description: failureDescription(result, failureCopy),
variant: 'destructive',
})
return
@@ -215,6 +250,26 @@ export default function StripeSettingsPanel() {
)
}
if (loadFailed) {
// Kept strictly apart from `!configured` below: a status read that never
// answered says nothing about whether Stripe is configured, and the user can
// fix a dropped connection themselves. Recoverable, so it offers the retry.
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{t('title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4 pt-0">
<p className="text-sm text-destructive">{t('load_failed')}</p>
<Button variant="outline" size="sm" onClick={retryLoadStatus}>
<RefreshCw className="mr-2 h-4 w-4" />
{tCommon('retry')}
</Button>
</CardContent>
</Card>
)
}
if (!configured) {
// Self-hosted without STRIPE_CONNECT_CLIENT_ID: honest configuration
// message, for these admins it's a setup task, not a launch.
@@ -0,0 +1,225 @@
/**
* The Stripe settings panel's server calls, each classified into exactly one
* outcome.
*
* Why this exists: the panel's click handlers were `try { ... } finally {
* setSyncing(false) }` with no `catch`. A fetch that rejected (offline, DNS,
* connection reset, a serverless instance that never answers) skipped every
* toast line and landed in `finally`, so the spinner started, stopped, and the
* user was told nothing at all: not that it failed, not to retry, not to check
* the connection.
*
* The classification lives here rather than inside the component because this
* repo has no component tests (vitest runs in `node`, scoped to `lib/` and
* `app/api/`): logic left in the panel is logic nothing can check.
*
* The failure union is `@/lib/browser/action-failure`'s, so the panel maps a
* failure through the same `failureDescription` as the salary payment panels and
* the three reasons mean the same thing everywhere in the app.
*
* Exactly one toast per click: TOAST_LIMIT is 1 (components/ui/use-toast.tsx),
* so a second toast emitted in the same tick evicts the first and only the last
* one is ever rendered. Every outcome below therefore resolves to one sentence.
*/
import { fetchWithTimeout, isTimeoutError } from '@/lib/http/fetch-with-timeout'
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
import type { ActionFailure } from '@/lib/browser/action-failure'
/**
* Deadline for the panel's quick calls: the status read, the connect handshake,
* disconnect, and the transaction-sync toggle. Each is a single indexed row plus
* at most one outward Stripe call, so the realistic worst case is a cold start
* and one eu-north-1 round trip. Same value as POST_ACTION_TIMEOUT_MS: a shorter
* deadline would report a failure for a write that may well have landed.
*/
export const STRIPE_ACTION_TIMEOUT_MS = 15_000
/**
* Deadline for "Synka nu".
*
* Deliberately far longer than the writes. A first sync backfills 90 days and up
* to 10 000 balance transactions (BACKFILL_DAYS / MAX_TXNS_PER_RUN in
* transaction-sync.ts), which legitimately takes minutes, and aborting early
* would be worse than the missing catch this replaces: the server keeps working
* and advances `last_balance_txn_synced_at`, so the user would be told the sync
* failed and then find nothing on the retry. The only honest bound is the
* route's own ceiling (`export const maxDuration = 300` in
* app/api/extensions/ext/[...path]/route.ts) plus a margin for the platform's
* own error response: past that, no answer is ever coming.
*/
export const STRIPE_SYNC_TIMEOUT_MS = 310_000
export type StripeRequestResult<T> =
/** 2xx. `data` is null when the body was not readable JSON. */
| { ok: true; data: T | null }
| ActionFailure
export interface StripeRequestOptions {
url: string
/** Defaults to POST: three of the four calls are mutations. */
method?: 'GET' | 'POST' | 'DELETE'
/** JSON request body. Omitted entirely for the routes that ignore it. */
body?: unknown
/** UI locale, so a server error is reported in the language the user reads. */
locale?: ErrorLocale
timeoutMs?: number
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
/**
* The one sentence for a non-2xx.
*
* The Stripe extension routes (extensions/general/stripe/index.ts) refuse with a
* hand-written Swedish sentence in `{ error }`, and that sentence is the most
* specific thing anyone can say: "Inget anslutet Stripe-konto." tells the user
* to reconnect, where the status map's "Resursen kunde inte hittas." tells them
* nothing. `getErrorMessage` keeps such a sentence only when it happens to carry
* one of its Swedish trigger words, which this route copy does not, so the
* string is preferred explicitly.
*
* `error_en` is honoured first for an English UI because the shared capability
* guard emits both (`capabilityBlockedResponse` in
* lib/entitlements/has-capability.ts), and `getErrorMessage` only reads
* `message_en` inside a structured envelope, not a top-level `error_en`.
*
* Everything else is `getErrorMessage`'s: a structured envelope, an HTML 502
* from the platform, a body that never parsed.
*/
export function serverErrorMessage(
body: unknown,
status: number,
locale: ErrorLocale,
): string {
if (isRecord(body)) {
if (locale === 'en' && typeof body.error_en === 'string' && body.error_en.trim()) {
return body.error_en.trim()
}
if (typeof body.error === 'string' && body.error.trim()) {
return body.error.trim()
}
}
return getErrorMessage(body, { statusCode: status, locale })
}
/**
* Call one of the panel's endpoints and report exactly why it failed.
*
* Never throws: every arm resolves to a member of the union, so the call site
* cannot have a silent path again by forgetting a `catch`.
*/
export async function stripeRequest<T>({
url,
method = 'POST',
body,
locale = 'sv',
timeoutMs = STRIPE_ACTION_TIMEOUT_MS,
}: StripeRequestOptions): Promise<StripeRequestResult<T>> {
try {
const res = await fetchWithTimeout(
url,
body === undefined
? { method }
: {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
},
{ timeoutMs, description: `${method} ${url}` },
)
// Read the body on both arms: the failure arm needs the route's own
// sentence, the success arm needs the sync counts. A body that is not JSON
// (an HTML error page, an empty 502, a response truncated mid-stream) leaves
// null, and neither arm then claims anything it cannot support.
const payload = await res.json().catch(() => null)
if (!res.ok) {
return {
ok: false,
reason: 'server',
status: res.status,
message: serverErrorMessage(payload, res.status, locale),
}
}
return { ok: true, data: payload as T | null }
} catch (err) {
if (isTimeoutError(err)) return { ok: false, reason: 'timeout' }
return { ok: false, reason: 'network', message: getErrorMessage(err, { locale }) }
}
}
/** Success body of POST /api/extensions/ext/stripe/sync. */
export interface StripeSyncPayload {
success?: boolean
/** `StripeTransactionSyncSummary` from lib/transaction-sync.ts, over the wire. */
transactions?: {
fetched?: number
imported?: number
duplicates?: number
linked?: number
errors?: number
revoked?: boolean
/**
* Only set when the caller passes a time budget. The manual button's route
* passes none, so it cannot appear here; the nightly cron owns that case.
*/
deadlineReached?: boolean
}
}
/**
* A type alias, not an interface: next-intl's `t(key, values)` takes a
* `Record<string, string | number | Date>`, and only anonymous object types get
* the implicit index signature that makes them assignable to it.
*/
type SyncCounts = {
fetched: number
imported: number
linked: number
}
export type StripeSyncSummary =
/**
* Stripe revoked the connection upstream. A 200, but not a completed sync:
* `syncStripeBalanceTransactions` returns `{ revoked: true }` with every count
* at zero, which the panel used to report as a finished sync that "returned no
* transactions for the period", sending the user to check the wrong thing.
*/
| { reason: 'revoked' }
/** The window genuinely held nothing. A real answer, not a silent success. */
| { reason: 'empty' }
/** Rows landed, and some rows did not. Both halves get said. */
| { reason: 'errors'; values: SyncCounts & { errors: number } }
/** Rows landed. */
| { reason: 'feed'; values: SyncCounts }
/**
* 2xx whose body could not be read. The sync ran (the server had already
* committed by the time it flushed headers), but the counts are unknown, so
* the panel says the sync finished and stops there rather than reporting the
* missing numbers as zero and calling the run empty.
*/
| { reason: 'unknown' }
/**
* Turn the sync route's success body into the single sentence the user gets.
*/
export function syncSummary(payload: StripeSyncPayload | null): StripeSyncSummary {
const summary = payload?.transactions
if (!summary) return { reason: 'unknown' }
if (summary.revoked === true) return { reason: 'revoked' }
if (typeof summary.fetched !== 'number') return { reason: 'unknown' }
const fetched = summary.fetched
const imported = typeof summary.imported === 'number' ? summary.imported : 0
const linked = typeof summary.linked === 'number' ? summary.linked : 0
const errors = typeof summary.errors === 'number' ? summary.errors : 0
if (fetched === 0) return { reason: 'empty' }
if (errors > 0) return { reason: 'errors', values: { fetched, imported, linked, errors } }
return { reason: 'feed', values: { fetched, imported, linked } }
}