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:
co-authored by
Claude Fable 5
parent
a43a8b03cf
commit
f24b26a139
@@ -1,6 +1,7 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { sparsePatchBody } from '@/lib/api/sparse-patch'
|
||||
import { UpdateAccountSchema } from '@/lib/api/schemas'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
|
||||
@@ -35,14 +36,31 @@ export const DELETE = withRouteContext(
|
||||
}
|
||||
|
||||
// Check if the account is referenced in THIS company's journal entries.
|
||||
// journal_entry_lines has no company_id column, so scope via the parent
|
||||
// entry — a user can be a member of several companies, and another
|
||||
// company's usage of the same BAS number must not block deletion here.
|
||||
const { count } = await supabase
|
||||
.from('journal_entry_lines')
|
||||
.select('id, journal_entries!inner(company_id)', { count: 'exact', head: true })
|
||||
.eq('journal_entries.company_id', companyId)
|
||||
.eq('account_number', number)
|
||||
// journal_entry_lines has no company_id column, so the scope has to come
|
||||
// from the parent entry: a user can be a member of several companies, and
|
||||
// another company's usage of the same BAS number must not block deletion
|
||||
// here.
|
||||
//
|
||||
// This used to be a `journal_entries!inner(company_id)` head+count query.
|
||||
// PostgREST compiles that embed into a correlated LATERAL join, so the
|
||||
// count walked the ENTIRE journal_entry_lines table across all tenants to
|
||||
// answer a single-account question (see lib/bookkeeping/entry-lines.ts).
|
||||
// fetchEntryLines cannot replace it (it returns rows, not a count), so the
|
||||
// count moves into SQL: get_account_usage_counts is the same
|
||||
// company-scoped aggregate the kontoplan usage column and the prune dialog
|
||||
// already run (migration 20260704110000, covering index 20260706120000),
|
||||
// and it counts lines on ALL entry statuses, exactly like the old query.
|
||||
const { data: usage, error: usageError } = await supabase.rpc('get_account_usage_counts', {
|
||||
p_company_id: companyId,
|
||||
})
|
||||
|
||||
if (usageError) {
|
||||
return NextResponse.json({ error: getUserErrorMessage(usageError) }, { status: 500 })
|
||||
}
|
||||
|
||||
const count = ((usage ?? []) as { account_number: string; usage_count: number }[]).find(
|
||||
(row) => row.account_number === number,
|
||||
)?.usage_count
|
||||
|
||||
if (count && count > 0) {
|
||||
return NextResponse.json(
|
||||
@@ -72,7 +90,14 @@ export const PUT = withRouteContext(
|
||||
const { number } = await params
|
||||
const { supabase, companyId, log } = ctx
|
||||
|
||||
const validation = await validateBody(request, UpdateAccountSchema, {
|
||||
// The body is spread straight into .update(), so only the fields the
|
||||
// caller actually named may reach it. UpdateAccountSchema carries no
|
||||
// .default() today, so sparsePatchBody is a no-op here: it is the
|
||||
// structural guarantee that adding one later cannot make a PUT that
|
||||
// renames an account also rewrite its VAT code or SRU mapping. An
|
||||
// explicit null (clearing sru_code, default_vat_code, default_vat_rate)
|
||||
// still survives.
|
||||
const validation = await validateBody(request, sparsePatchBody(UpdateAccountSchema), {
|
||||
log,
|
||||
operation: 'bookkeeping.accounts.update',
|
||||
})
|
||||
|
||||
@@ -218,10 +218,10 @@ describe('POST /api/bookkeeping/accounts', () => {
|
||||
})
|
||||
|
||||
describe('DELETE /api/bookkeeping/accounts/[number]', () => {
|
||||
it('scopes the usage check to the company via the journal_entries join', async () => {
|
||||
it('scopes the usage check to the company via the usage-counts RPC', async () => {
|
||||
const { supabase, calls } = createCapturingSupabase([
|
||||
{ data: { id: 'acc-1', is_system_account: false } }, // account fetch
|
||||
{ count: 0 }, // usage count
|
||||
{ data: [{ account_number: '4010', usage_count: 7 }] }, // usage counts (not 5010)
|
||||
{ data: null }, // delete
|
||||
])
|
||||
auth(supabase)
|
||||
@@ -231,16 +231,18 @@ describe('DELETE /api/bookkeeping/accounts/[number]', () => {
|
||||
)
|
||||
|
||||
expect(status).toBe(200)
|
||||
// The company scope is an RPC argument now, not a filter on an embed:
|
||||
// another company's use of the same BAS number can never be counted here.
|
||||
const rpcCalls = calls.filter((c) => c.method === 'rpc').map((c) => c.args)
|
||||
expect(rpcCalls).toContainEqual(['get_account_usage_counts', { p_company_id: 'company-1' }])
|
||||
const selectArgs = calls.filter((c) => c.method === 'select').map((c) => c.args[0])
|
||||
expect(selectArgs).toContain('id, journal_entries!inner(company_id)')
|
||||
const eqCalls = calls.filter((c) => c.method === 'eq').map((c) => c.args)
|
||||
expect(eqCalls).toContainEqual(['journal_entries.company_id', 'company-1'])
|
||||
expect(selectArgs).not.toContain('id, journal_entries!inner(company_id)')
|
||||
})
|
||||
|
||||
it('refuses deleting an account used in this company with 400', async () => {
|
||||
const { supabase } = createCapturingSupabase([
|
||||
{ data: { id: 'acc-1', is_system_account: false } },
|
||||
{ count: 3 },
|
||||
{ data: [{ account_number: '5010', usage_count: 3 }] },
|
||||
])
|
||||
auth(supabase)
|
||||
|
||||
@@ -321,6 +323,55 @@ describe('PUT /api/bookkeeping/accounts/[number]', () => {
|
||||
}
|
||||
expect(updateArg?.default_vat_rate).toBe(0)
|
||||
})
|
||||
|
||||
// The body is spread straight into .update(), so the write set must be
|
||||
// exactly what the caller named. UpdateAccountSchema carries no .default()
|
||||
// today; these two lock the property in so adding one cannot turn a rename
|
||||
// into a silent rewrite of the VAT code and SRU mapping.
|
||||
it('writes only the field the caller named', async () => {
|
||||
const { supabase, calls } = createCapturingSupabase([
|
||||
{ data: { account_number: '5010', account_name: 'Nytt namn' } },
|
||||
])
|
||||
auth(supabase)
|
||||
const req = createMockRequest('/api/bookkeeping/accounts/5010', {
|
||||
method: 'PUT',
|
||||
body: { account_name: 'Nytt namn' },
|
||||
})
|
||||
expect((await parseJsonResponse(await PUT(req, numberParams))).status).toBe(200)
|
||||
|
||||
const updateArg = calls.find((c) => c.method === 'update')?.args[0] as Record<string, unknown>
|
||||
expect(Object.keys(updateArg)).toEqual(['account_name'])
|
||||
})
|
||||
|
||||
it('keeps an explicit null so sru_code can be cleared', async () => {
|
||||
const { supabase, calls } = createCapturingSupabase([
|
||||
{ data: { account_number: '5010', sru_code: null } },
|
||||
])
|
||||
auth(supabase)
|
||||
const req = createMockRequest('/api/bookkeeping/accounts/5010', {
|
||||
method: 'PUT',
|
||||
body: { sru_code: null },
|
||||
})
|
||||
expect((await parseJsonResponse(await PUT(req, numberParams))).status).toBe(200)
|
||||
|
||||
const updateArg = calls.find((c) => c.method === 'update')?.args[0] as Record<string, unknown>
|
||||
expect(updateArg).toEqual({ sru_code: null })
|
||||
})
|
||||
|
||||
it('drops unknown keys instead of forwarding them to the update', async () => {
|
||||
const { supabase, calls } = createCapturingSupabase([
|
||||
{ data: { account_number: '5010' } },
|
||||
])
|
||||
auth(supabase)
|
||||
const req = createMockRequest('/api/bookkeeping/accounts/5010', {
|
||||
method: 'PUT',
|
||||
body: { account_name: 'Nytt namn', is_system_account: true, company_id: 'other' },
|
||||
})
|
||||
expect((await parseJsonResponse(await PUT(req, numberParams))).status).toBe(200)
|
||||
|
||||
const updateArg = calls.find((c) => c.method === 'update')?.args[0] as Record<string, unknown>
|
||||
expect(Object.keys(updateArg)).toEqual(['account_name'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/bookkeeping/accounts/activate', () => {
|
||||
|
||||
@@ -153,6 +153,8 @@ describe('POST /api/bookkeeping/fiscal-periods', () => {
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data).toBeDefined()
|
||||
// Nothing precedes the first period, so there is nothing to advise about.
|
||||
expect(body.warnings).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects overlapping periods', async () => {
|
||||
@@ -168,6 +170,22 @@ describe('POST /api/bookkeeping/fiscal-periods', () => {
|
||||
expect(body.error).toMatch(/Overlaps/)
|
||||
})
|
||||
|
||||
// Dropping the "prior year must be locked" gate must not open an overlap
|
||||
// hole: a period colliding with a still-open prior year is still a 409.
|
||||
it('rejects an overlapping period even when the prior year is still open', async () => {
|
||||
buildMockSupabase({
|
||||
allPeriods: [{ id: 'p1', period_start: '2025-01-01', period_end: '2025-12-31', is_closed: false }],
|
||||
openPeriods: [{ id: 'p1', name: 'FY 2025', period_start: '2025-01-01', period_end: '2025-12-31' }],
|
||||
overlapping: [{ id: 'p1', name: 'FY 2025' }],
|
||||
})
|
||||
// Starts inside FY 2025.
|
||||
const req = createMockRequest({ name: 'FY 2025 dup', period_start: '2025-07-01', period_end: '2026-06-30' })
|
||||
const res = await POST(req)
|
||||
expect(res.status).toBe(409)
|
||||
const body = await res.json()
|
||||
expect(body.error).toMatch(/Overlaps/)
|
||||
})
|
||||
|
||||
it('rejects forward period with wrong start date', async () => {
|
||||
buildMockSupabase({
|
||||
allPeriods: [{ id: 'p1', period_start: '2025-01-01', period_end: '2025-12-31', is_closed: true }],
|
||||
@@ -179,22 +197,81 @@ describe('POST /api/bookkeeping/fiscal-periods', () => {
|
||||
expect(body.error).toMatch(/must start on 2026-01-01/)
|
||||
})
|
||||
|
||||
it('rejects forward period when an unlocked open period exists and returns it as a blocking period', async () => {
|
||||
// Regression (BFL 5 kap 2 §): this used to be a 409. One unbooked December
|
||||
// bank transaction makes FY 2025 unlockable (lockPeriod refuses, correctly),
|
||||
// and the old guard then refused to create FY 2026, so ALL bookkeeping in the
|
||||
// new year stopped, while BFL 5 kap 2 § requires the new year's
|
||||
// affärshändelser to be booked "så snart det kan ske" (senast månaden efter)
|
||||
// and BFL 6 kap gives the bokslut 6 months. Both bind at once: the new
|
||||
// räkenskapsår must be creatable with the prior one still fully open.
|
||||
it('creates the new räkenskapsår while the prior year is still fully open', async () => {
|
||||
buildMockSupabase({
|
||||
allPeriods: [{ id: 'p1', period_start: '2025-01-01', period_end: '2025-12-31', is_closed: false }],
|
||||
openPeriods: [{ id: 'p1', name: 'FY 2025', period_start: '2025-01-01', period_end: '2025-12-31' }],
|
||||
openPeriods: [{ id: 'p1', name: 'Räkenskapsår 2025', period_start: '2025-01-01', period_end: '2025-12-31' }],
|
||||
overlapping: [],
|
||||
})
|
||||
const req = createMockRequest({
|
||||
name: 'Räkenskapsår 2026',
|
||||
period_start: '2026-01-01',
|
||||
period_end: '2026-12-31',
|
||||
})
|
||||
const req = createMockRequest({ name: 'FY 2026', period_start: '2026-01-01', period_end: '2026-12-31' })
|
||||
const res = await POST(req)
|
||||
expect(res.status).toBe(409)
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
// Canonical envelope with a machine code + the blocking periods so the
|
||||
// dialog can offer to lock them inline.
|
||||
expect(body.error.code).toBe('PERIOD_CREATE_BLOCKED_BY_OPEN_PERIODS')
|
||||
expect(body.error.message).toMatch(/låsa föregående räkenskapsår/)
|
||||
expect(body.error.details.blockingPeriods).toEqual([
|
||||
{ id: 'p1', name: 'FY 2025', period_start: '2025-01-01', period_end: '2025-12-31' },
|
||||
])
|
||||
expect(body.data).toBeDefined()
|
||||
// The open prior year survives only as a non-blocking advisory.
|
||||
expect(body.warnings).toHaveLength(1)
|
||||
expect(body.warnings[0].code).toBe('PRIOR_FISCAL_YEAR_STILL_OPEN')
|
||||
expect(body.warnings[0].message).toContain('Räkenskapsår 2025')
|
||||
})
|
||||
|
||||
// The advisory must be actionable and must not dead-end: it states that the
|
||||
// new year is bookable now and that the IB arrives with the bokslut, rather
|
||||
// than demanding a lock the user may be unable to perform.
|
||||
it('advisory tells the user booking may continue and that IB follows the bokslut', async () => {
|
||||
buildMockSupabase({
|
||||
allPeriods: [{ id: 'p1', period_start: '2025-01-01', period_end: '2025-12-31', is_closed: false }],
|
||||
openPeriods: [{ id: 'p1', name: 'Räkenskapsår 2025', period_start: '2025-01-01', period_end: '2025-12-31' }],
|
||||
overlapping: [],
|
||||
})
|
||||
const req = createMockRequest({
|
||||
name: 'Räkenskapsår 2026',
|
||||
period_start: '2026-01-01',
|
||||
period_end: '2026-12-31',
|
||||
})
|
||||
const res = await POST(req)
|
||||
const body = await res.json()
|
||||
expect(body.warnings[0].message).toMatch(/bokföra i det direkt/)
|
||||
expect(body.warnings[0].message).toMatch(/Ingående balanser bokförs automatiskt/)
|
||||
// No wording that orders the user to lock the prior year first.
|
||||
expect(body.warnings[0].message).not.toMatch(/måste låsa/)
|
||||
})
|
||||
|
||||
// Every still-open prior year is named, not just the immediate predecessor:
|
||||
// the user needs to know the full set whose UB is still provisional.
|
||||
it('names every still-open prior räkenskapsår in the single advisory', async () => {
|
||||
buildMockSupabase({
|
||||
allPeriods: [
|
||||
{ id: 'p1', period_start: '2024-01-01', period_end: '2024-12-31', is_closed: false },
|
||||
{ id: 'p2', period_start: '2025-01-01', period_end: '2025-12-31', is_closed: false },
|
||||
],
|
||||
openPeriods: [
|
||||
{ id: 'p1', name: 'Räkenskapsår 2024', period_start: '2024-01-01', period_end: '2024-12-31' },
|
||||
{ id: 'p2', name: 'Räkenskapsår 2025', period_start: '2025-01-01', period_end: '2025-12-31' },
|
||||
],
|
||||
overlapping: [],
|
||||
})
|
||||
const req = createMockRequest({
|
||||
name: 'Räkenskapsår 2026',
|
||||
period_start: '2026-01-01',
|
||||
period_end: '2026-12-31',
|
||||
})
|
||||
const res = await POST(req)
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.warnings).toHaveLength(1)
|
||||
expect(body.warnings[0].message).toContain('Räkenskapsår 2024')
|
||||
expect(body.warnings[0].message).toContain('Räkenskapsår 2025')
|
||||
})
|
||||
|
||||
// Regression: BFL 6 kap allows löpande bokföring of the new year in parallel
|
||||
@@ -232,10 +309,14 @@ describe('POST /api/bookkeeping/fiscal-periods', () => {
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data).toBeDefined()
|
||||
// Effectively locked: nothing to advise about.
|
||||
expect(body.warnings).toBeUndefined()
|
||||
})
|
||||
|
||||
// Partial coverage: lock-through covers only part of the period: must still block.
|
||||
it('rejects forward period creation when company-wide lock only partially covers prior period', async () => {
|
||||
// Partial coverage: lock-through covers only part of the period, so the prior
|
||||
// year still counts as open. Creation proceeds either way; only the advisory
|
||||
// distinguishes the two states.
|
||||
it('advises but does not block when company-wide lock only partially covers prior period', async () => {
|
||||
buildMockSupabase({
|
||||
allPeriods: [{ id: 'p1', period_start: '2024-01-01', period_end: '2024-12-31', is_closed: false }],
|
||||
openPeriods: [{ id: 'p1', name: 'FY 2024', period_start: '2024-01-01', period_end: '2024-12-31' }],
|
||||
@@ -244,12 +325,11 @@ describe('POST /api/bookkeeping/fiscal-periods', () => {
|
||||
})
|
||||
const req = createMockRequest({ name: 'FY 2025', period_start: '2025-01-01', period_end: '2025-12-31' })
|
||||
const res = await POST(req)
|
||||
expect(res.status).toBe(409)
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('PERIOD_CREATE_BLOCKED_BY_OPEN_PERIODS')
|
||||
expect(body.error.details.blockingPeriods).toEqual([
|
||||
{ id: 'p1', name: 'FY 2024', period_start: '2024-01-01', period_end: '2024-12-31' },
|
||||
])
|
||||
expect(body.warnings).toHaveLength(1)
|
||||
expect(body.warnings[0].code).toBe('PRIOR_FISCAL_YEAR_STILL_OPEN')
|
||||
expect(body.warnings[0].message).toContain('FY 2024')
|
||||
})
|
||||
|
||||
it('allows backward period creation', async () => {
|
||||
@@ -283,6 +363,10 @@ describe('POST /api/bookkeeping/fiscal-periods', () => {
|
||||
const req = createMockRequest({ name: 'FY 2025', period_start: '2025-01-01', period_end: '2025-12-31' })
|
||||
const res = await POST(req)
|
||||
expect(res.status).toBe(200)
|
||||
// The advisory is about the NEXT year's ingående balanser, so it belongs to
|
||||
// appends only. Backfilling an earlier year says nothing about IB.
|
||||
const body = await res.json()
|
||||
expect(body.warnings).toBeUndefined()
|
||||
})
|
||||
|
||||
// Regression (2026-06-16): a company with FY 2024 + FY 2026 but no
|
||||
@@ -356,6 +440,9 @@ describe('POST /api/bookkeeping/fiscal-periods', () => {
|
||||
const req = createMockRequest({ name: 'FY 2025', period_start: '2025-01-01', period_end: '2025-12-31' })
|
||||
const res = await POST(req)
|
||||
expect(res.status).toBe(200)
|
||||
// Gap fill is a backfill too: no IB advisory.
|
||||
const body = await res.json()
|
||||
expect(body.warnings).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects invalid period duration (> 18 months)', async () => {
|
||||
|
||||
@@ -3,11 +3,19 @@ import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validatePeriodDuration } from '@/lib/bookkeeping/validate-period-duration'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { CreateFiscalPeriodSchema } from '@/lib/api/schemas'
|
||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
|
||||
// Response shapes are legacy `{ error: string }` (plus one envelope code for
|
||||
// the blocked-by-open-periods dialog) — kept for the räkenskapsår UI.
|
||||
// Response shapes are legacy `{ error: string }`, kept for the räkenskapsår UI.
|
||||
// Success may carry a non-blocking `warnings` array (same shape as the invoice
|
||||
// booking routes: `{ code, message }`).
|
||||
|
||||
/** A prior räkenskapsår that is still fully open when the next one is created. */
|
||||
interface OpenPriorPeriod {
|
||||
id: string
|
||||
name: string
|
||||
period_start: string
|
||||
period_end: string
|
||||
}
|
||||
|
||||
export const GET = withRouteContext('period.list', async (_request, ctx) => {
|
||||
const { supabase, companyId } = ctx
|
||||
@@ -58,6 +66,11 @@ export const POST = withRouteContext(
|
||||
const predecessor = [...sortedPeriods].reverse().find((p) => p.period_end < body.period_start) ?? null
|
||||
const successor = sortedPeriods.find((p) => p.period_start > body.period_end) ?? null
|
||||
|
||||
// Prior räkenskapsår that are still fully open at the moment the next one is
|
||||
// appended. Advisory only (see the isAppend block below), attached to the
|
||||
// success response so the UI can nudge without gating.
|
||||
let openPriorPeriods: OpenPriorPeriod[] = []
|
||||
|
||||
if (sortedPeriods.length > 0) {
|
||||
const earliest = sortedPeriods[0]
|
||||
const latest = sortedPeriods[sortedPeriods.length - 1]
|
||||
@@ -118,17 +131,38 @@ export const POST = withRouteContext(
|
||||
}
|
||||
}
|
||||
|
||||
// The "prior year must be locked" guard applies only when appending a new
|
||||
// latest räkenskapsår, not when backfilling a gap between existing years.
|
||||
// A gap fill is a backfill (like prepend) and must not be blocked by an
|
||||
// open neighbouring year.
|
||||
// An open prior räkenskapsår is INFORMATION, never a gate.
|
||||
//
|
||||
// This used to hard-refuse (409) a new latest räkenskapsår while any
|
||||
// prior period was still fully open, on the theory that the prior year
|
||||
// "must at least be locked so nothing is back-posted into a year you've
|
||||
// moved on from". That theory has no support in BFL and inverts two rules
|
||||
// that bind simultaneously:
|
||||
// - BFL 5 kap 2 §: kontanta in-/utbetalningar bokförs senast påföljande
|
||||
// arbetsdag, övriga affärshändelser "så snart det kan ske" (per BFNAR
|
||||
// 2013:2 senast månaden efter). Booking January REQUIRES a
|
||||
// räkenskapsår covering January, within weeks.
|
||||
// - BFL 6 kap: årsbokslut/årsredovisning ska upprättas inom 6 månader
|
||||
// efter räkenskapsårets utgång (AB filing 7 månader). The prior year
|
||||
// is therefore legitimately unfinished, and must stay unlocked so
|
||||
// bokslutsposter (periodiseringar, avskrivningar, skatt) can be
|
||||
// posted into it, for months into the new year.
|
||||
// Running the two years in parallel is not a tolerated edge case, it is
|
||||
// the normal and legally required state during that window. The old guard
|
||||
// made one unbooked December bank transaction (which blocks lockPeriod,
|
||||
// correctly, per BFL 5 kap 2 §) freeze ALL bookkeeping in the new year.
|
||||
//
|
||||
// What genuinely protects the prior year is unchanged and lives
|
||||
// elsewhere: period locked_at (enforce_period_lock) and
|
||||
// company_settings.bookkeeping_locked_through (enforce_company_lock_date),
|
||||
// both set deliberately by the user. Creating the next räkenskapsår does
|
||||
// not write a single row into the prior one.
|
||||
//
|
||||
// What IS kept from the old check is its detection, downgraded to an
|
||||
// advisory on the success response: an open prior year means its UB is
|
||||
// not final, so the new year's ingående balanser are not posted yet.
|
||||
// A period counts as "effectively locked" if its own locked_at is set, OR
|
||||
// company_settings.bookkeeping_locked_through covers its end date (the
|
||||
// enforce_company_lock_date trigger blocks any entry on/before that date).
|
||||
// BFL 6 kap allows löpande bokföring of the new year in parallel with
|
||||
// bokslut work on the prior year, so locked-but-not-closed prior periods
|
||||
// must not block creating the next räkenskapsår.
|
||||
// company_settings.bookkeeping_locked_through covers its end date.
|
||||
if (isAppend) {
|
||||
const { data: openPeriods } = await supabase
|
||||
.from('fiscal_periods')
|
||||
@@ -149,20 +183,12 @@ export const POST = withRouteContext(
|
||||
(p) => !(lockThrough && p.period_end <= lockThrough)
|
||||
)
|
||||
|
||||
if (trulyOpen.length > 0) {
|
||||
// Hand the blocking periods (id + name + dates) to the client so the
|
||||
// "Skapa räkenskapsår" dialog can offer to lock them inline and retry,
|
||||
// instead of dead-ending the user on a message they can't act on.
|
||||
const blockingPeriods = trulyOpen.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
period_start: p.period_start,
|
||||
period_end: p.period_end,
|
||||
}))
|
||||
return errorResponseFromCode('PERIOD_CREATE_BLOCKED_BY_OPEN_PERIODS', log, {
|
||||
details: { blockingPeriods },
|
||||
})
|
||||
}
|
||||
openPriorPeriods = trulyOpen.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
period_start: p.period_start,
|
||||
period_end: p.period_end,
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -231,7 +257,25 @@ export const POST = withRouteContext(
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
// Non-blocking advisory: the new year exists and is bookable right now, but
|
||||
// its ingående balanser are still pending because the prior year's bokslut
|
||||
// has not run. Every action named here is reachable, so this never dead-ends:
|
||||
// the user can book in the new year immediately and the IB lands by itself
|
||||
// when the bokslut for the prior year is executed (executeYearEndClosing
|
||||
// reuses an already-created next period and posts the IB verifikat into it).
|
||||
const warnings: Array<{ code: string; message: string }> = []
|
||||
if (openPriorPeriods.length > 0) {
|
||||
const names = openPriorPeriods.map((p) => p.name).join(', ')
|
||||
warnings.push({
|
||||
code: 'PRIOR_FISCAL_YEAR_STILL_OPEN',
|
||||
message:
|
||||
`Räkenskapsåret är skapat och du kan bokföra i det direkt. ${names} är fortfarande öppet, ` +
|
||||
'vilket är normalt medan bokslutet pågår: du får bokföra i båda åren samtidigt. ' +
|
||||
'Ingående balanser bokförs automatiskt när bokslutet för föregående år körs.',
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json(warnings.length > 0 ? { data, warnings } : { data })
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
|
||||
@@ -70,7 +70,11 @@ describe('POST /api/bookkeeping/journal-entries/[id]/correct', () => {
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error).toBe('Validation failed')
|
||||
// Inverted from `toBe('Validation failed')`: the constant was the bug.
|
||||
// `error` now names the offending field so a UI reading only `error` is
|
||||
// actionable.
|
||||
expect(body.error).toMatch(/^Valideringsfel: /)
|
||||
expect(body.error).toContain('lines')
|
||||
})
|
||||
|
||||
it('returns 400 when lines array is empty', async () => {
|
||||
@@ -82,7 +86,8 @@ describe('POST /api/bookkeeping/journal-entries/[id]/correct', () => {
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error).toBe('Validation failed')
|
||||
expect(body.error).toMatch(/^Valideringsfel: /)
|
||||
expect(body.error).toContain('At least two lines are required for double-entry')
|
||||
})
|
||||
|
||||
it('returns reversal and corrected entries on success', async () => {
|
||||
@@ -155,7 +160,8 @@ describe('POST /api/bookkeeping/journal-entries/[id]/correct', () => {
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error).toBe('Validation failed')
|
||||
expect(body.error).toMatch(/^Valideringsfel: /)
|
||||
expect(body.error).toContain('description')
|
||||
expect(mockCorrectEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
||||
@@ -67,7 +67,9 @@ describe('POST /api/bookkeeping/journal-entries/[id]/recordate', () => {
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error).toBe('Validation failed')
|
||||
// Inverted from `toBe('Validation failed')`: the constant was the bug.
|
||||
expect(body.error).toMatch(/^Valideringsfel: /)
|
||||
expect(body.error).toContain('new_entry_date')
|
||||
})
|
||||
|
||||
it('returns 400 when new_entry_date is not an ISO date', async () => {
|
||||
@@ -79,7 +81,8 @@ describe('POST /api/bookkeeping/journal-entries/[id]/recordate', () => {
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error).toBe('Validation failed')
|
||||
expect(body.error).toMatch(/^Valideringsfel: /)
|
||||
expect(body.error).toContain('new_entry_date')
|
||||
})
|
||||
|
||||
it('returns reversal and corrected entries on success', async () => {
|
||||
|
||||
@@ -7,6 +7,17 @@ import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { encryptCustomerPersonalNumber, maskCustomerRow } from '@/lib/customers/protect-personal-number'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
|
||||
/**
|
||||
* Shape produced by maskCustomerPersonalNumber: no read path ever returns the
|
||||
* stored personnummer, only '********-1234'. A client that PATCHes back a
|
||||
* customer it just read therefore submits the mask, which must mean "leave the
|
||||
* stored value alone", never "store this literally" and never "clear it".
|
||||
* components/customers/CustomerForm.tsx strips it before sending, but that
|
||||
* guard belongs here too: any other client (script, agent, future UI) that
|
||||
* skips it would otherwise destroy the value.
|
||||
*/
|
||||
const MASKED_PERSONAL_NUMBER = /^\*{8}-\d{4}$/
|
||||
|
||||
export const GET = withRouteContext(
|
||||
'customer.get',
|
||||
async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => {
|
||||
@@ -72,8 +83,14 @@ export const PATCH = withRouteContext(
|
||||
return errorResponseFromCode('CUSTOMER_UPDATE_FAILED', opLog, { requestId })
|
||||
}
|
||||
|
||||
// The masked sentinel counts as "field not supplied": it carries no new
|
||||
// value, so it must not be validated, stored or treated as a clear.
|
||||
const personalNumberSubmitted =
|
||||
body.personal_number !== undefined &&
|
||||
!(typeof body.personal_number === 'string' && MASKED_PERSONAL_NUMBER.test(body.personal_number))
|
||||
|
||||
const effectiveType = body.customer_type ?? existing.customer_type
|
||||
if (body.personal_number && effectiveType !== 'individual') {
|
||||
if (personalNumberSubmitted && body.personal_number && effectiveType !== 'individual') {
|
||||
return errorResponseFromCode('CUSTOMER_PERSONAL_NUMBER_NOT_ALLOWED', opLog, { requestId })
|
||||
}
|
||||
|
||||
@@ -91,7 +108,9 @@ export const PATCH = withRouteContext(
|
||||
if (body.country !== undefined) updateData.country = body.country
|
||||
if (body.org_number !== undefined) updateData.org_number = body.org_number
|
||||
if (body.vat_number !== undefined) updateData.vat_number = body.vat_number
|
||||
if (body.personal_number !== undefined) {
|
||||
if (personalNumberSubmitted) {
|
||||
// Stored as ciphertext; customers_personal_number_check accepts that
|
||||
// shape only (20260726110000).
|
||||
updateData.personal_number = encryptCustomerPersonalNumber(body.personal_number)
|
||||
} else if (body.customer_type !== undefined && effectiveType !== 'individual') {
|
||||
updateData.personal_number = null
|
||||
|
||||
@@ -2,7 +2,7 @@ import { NextResponse } from 'next/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
|
||||
import { decryptPersonnummer } from '@/lib/salary/personnummer'
|
||||
import { decryptPersonnummer, encryptPersonnummer } from '@/lib/salary/personnummer'
|
||||
|
||||
const captured: { insert: unknown[]; update: unknown[] } = { insert: [], update: [] }
|
||||
let queryResult: { data: unknown; error: unknown } = { data: null, error: null }
|
||||
@@ -51,6 +51,23 @@ import { PATCH } from '../[id]/route'
|
||||
|
||||
type CustomerWrite = { personal_number?: string | null }
|
||||
|
||||
// Synthetic personnummer, never a real one.
|
||||
const PERSONAL_NUMBER = '19900101-1234'
|
||||
const MASKED = '********-1234'
|
||||
|
||||
/**
|
||||
* The shape customers_personal_number_check accepts as of 20260726110000:
|
||||
* lowercase hex, 76 to 255 chars (24 iv + 32 auth tag + >= 20 ciphertext).
|
||||
* Asserting against it is the point of these tests. The routes encrypt before
|
||||
* writing, and until 20260726110000 the column still demanded the plaintext
|
||||
* personnummer format below, so every write was rejected by Postgres while
|
||||
* this mocked suite passed. Pinning both directions is what makes the unit
|
||||
* test able to catch that mismatch; tests/pg/customers-personal-number-
|
||||
* ciphertext.pg.test.ts proves the constraint itself.
|
||||
*/
|
||||
const CIPHERTEXT_SHAPE = /^[0-9a-f]{76,255}$/
|
||||
const OLD_PLAINTEXT_CHECK = /^(\d{6}|\d{8})[-+]?\d{4}$/
|
||||
|
||||
describe('personal_number on customer routes', () => {
|
||||
const routeParams = { params: Promise.resolve({ id: 'customer-1' }) }
|
||||
|
||||
@@ -99,12 +116,14 @@ describe('personal_number on customer routes', () => {
|
||||
})
|
||||
|
||||
it('stores the personal number when creating a private customer', async () => {
|
||||
// What the insert returns is what the DB would hold: ciphertext.
|
||||
const stored = encryptPersonnummer(PERSONAL_NUMBER)
|
||||
queryResult = {
|
||||
data: {
|
||||
id: 'customer-1',
|
||||
name: 'Anna Andersson',
|
||||
customer_type: 'individual',
|
||||
personal_number: '19900101-1234',
|
||||
personal_number: stored,
|
||||
},
|
||||
error: null,
|
||||
}
|
||||
@@ -115,7 +134,7 @@ describe('personal_number on customer routes', () => {
|
||||
body: {
|
||||
name: 'Anna Andersson',
|
||||
customer_type: 'individual',
|
||||
personal_number: '19900101-1234',
|
||||
personal_number: PERSONAL_NUMBER,
|
||||
},
|
||||
}),
|
||||
{ params: Promise.resolve({}) },
|
||||
@@ -123,18 +142,25 @@ describe('personal_number on customer routes', () => {
|
||||
|
||||
const { status, body } = await parseJsonResponse<{ data: { personal_number: string } }>(response)
|
||||
expect(status).toBe(200)
|
||||
const encrypted = (captured.insert[0] as CustomerWrite).personal_number as string
|
||||
expect(encrypted).not.toBe('19900101-1234')
|
||||
expect(decryptPersonnummer(encrypted)).toBe('19900101-1234')
|
||||
expect(body.data.personal_number).toBe('********-1234')
|
||||
|
||||
const written = (captured.insert[0] as CustomerWrite).personal_number as string
|
||||
// The value must be storable: Postgres accepts ciphertext shape only.
|
||||
expect(written).toMatch(CIPHERTEXT_SHAPE)
|
||||
expect(written).not.toMatch(OLD_PLAINTEXT_CHECK)
|
||||
// ...and it must still be the customer's personnummer.
|
||||
expect(written).not.toBe(PERSONAL_NUMBER)
|
||||
expect(decryptPersonnummer(written)).toBe(PERSONAL_NUMBER)
|
||||
// Read back masked: no read path returns the personnummer itself.
|
||||
expect(body.data.personal_number).toBe(MASKED)
|
||||
})
|
||||
|
||||
it('updates the personal number for an existing private customer', async () => {
|
||||
const stored = encryptPersonnummer('900101-1234')
|
||||
queryResult = {
|
||||
data: {
|
||||
id: 'customer-1',
|
||||
customer_type: 'individual',
|
||||
personal_number: '900101-1234',
|
||||
personal_number: stored,
|
||||
},
|
||||
error: null,
|
||||
}
|
||||
@@ -147,10 +173,79 @@ describe('personal_number on customer routes', () => {
|
||||
routeParams,
|
||||
)
|
||||
|
||||
const { status, body } = await parseJsonResponse<{ data: { personal_number: string } }>(response)
|
||||
expect(status).toBe(200)
|
||||
|
||||
const written = (captured.update[0] as CustomerWrite).personal_number as string
|
||||
expect(written).toMatch(CIPHERTEXT_SHAPE)
|
||||
expect(written).not.toMatch(OLD_PLAINTEXT_CHECK)
|
||||
expect(written).not.toBe('900101-1234')
|
||||
expect(decryptPersonnummer(written)).toBe('900101-1234')
|
||||
expect(body.data.personal_number).toBe(MASKED)
|
||||
})
|
||||
|
||||
it('keeps the stored personal number when the masked value is sent back', async () => {
|
||||
queryResult = {
|
||||
data: {
|
||||
id: 'customer-1',
|
||||
customer_type: 'individual',
|
||||
name: 'Anna A',
|
||||
personal_number: encryptPersonnummer(PERSONAL_NUMBER),
|
||||
},
|
||||
error: null,
|
||||
}
|
||||
|
||||
// A client that PATCHes back the customer it just read submits the mask.
|
||||
const response = await PATCH(
|
||||
createMockRequest('/api/customers/customer-1', {
|
||||
method: 'PATCH',
|
||||
body: { name: 'Anna A', personal_number: MASKED },
|
||||
}),
|
||||
routeParams,
|
||||
)
|
||||
|
||||
const { status, body } = await parseJsonResponse<{ data: { personal_number: string } }>(response)
|
||||
expect(status).toBe(200)
|
||||
// Neither stored literally nor cleared: the column is left untouched.
|
||||
expect(captured.update[0]).not.toHaveProperty('personal_number')
|
||||
expect(body.data.personal_number).toBe(MASKED)
|
||||
})
|
||||
|
||||
it('does not treat a masked value as a personal number on a corporate customer', async () => {
|
||||
queryResult = {
|
||||
data: { id: 'customer-1', customer_type: 'individual', name: 'Anna A' },
|
||||
error: null,
|
||||
}
|
||||
|
||||
// Switching type away from individual while echoing the mask clears the
|
||||
// column, and must not trip the "not allowed for businesses" guard.
|
||||
const response = await PATCH(
|
||||
createMockRequest('/api/customers/customer-1', {
|
||||
method: 'PATCH',
|
||||
body: { customer_type: 'swedish_business', personal_number: MASKED },
|
||||
}),
|
||||
routeParams,
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const encrypted = (captured.update[0] as CustomerWrite).personal_number as string
|
||||
expect(encrypted).not.toBe('900101-1234')
|
||||
expect(decryptPersonnummer(encrypted)).toBe('900101-1234')
|
||||
expect((captured.update[0] as CustomerWrite).personal_number).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects the masked value on create, where there is nothing to preserve', async () => {
|
||||
const response = await POST(
|
||||
createMockRequest('/api/customers', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
name: 'Anna Andersson',
|
||||
customer_type: 'individual',
|
||||
personal_number: MASKED,
|
||||
},
|
||||
}),
|
||||
{ params: Promise.resolve({}) },
|
||||
)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(captured.insert).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('clears the personal number when null is sent', async () => {
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* Tests for POST /api/deadlines/[id]/complete.
|
||||
*
|
||||
* The load-bearing case is the explicit-state one: the page's "Ångra" button
|
||||
* posts `{ is_completed: false }`, and a route that toggles instead of setting
|
||||
* turns a second undo click (or an undo of a row already un-ticked elsewhere)
|
||||
* into a re-completed Skatteverket deadline.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
|
||||
|
||||
const requireAuthMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-auth', () => ({
|
||||
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
const requireWriteMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
|
||||
}))
|
||||
|
||||
import { POST } from '../route'
|
||||
|
||||
const idParams = { params: Promise.resolve({ id: 'deadline-1' }) }
|
||||
|
||||
interface Captured {
|
||||
update?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Sequential query results plus the payload handed to `.update()`, which is the
|
||||
* only place the persisted state is observable.
|
||||
*/
|
||||
function createCapturingSupabase(
|
||||
results: { data?: unknown; error?: unknown }[],
|
||||
captured: Captured,
|
||||
) {
|
||||
let idx = 0
|
||||
const makeBuilder = () => {
|
||||
const result = results[idx++] ?? { data: null, error: null }
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const b: any = {}
|
||||
for (const m of ['select', 'eq', 'single', 'maybeSingle']) {
|
||||
b[m] = () => b
|
||||
}
|
||||
b.update = (payload: Record<string, unknown>) => {
|
||||
captured.update = payload
|
||||
return b
|
||||
}
|
||||
b.then = (resolve: (v: unknown) => void) =>
|
||||
resolve({ data: result.data ?? null, error: result.error ?? null })
|
||||
return b
|
||||
}
|
||||
return { from: () => makeBuilder() }
|
||||
}
|
||||
|
||||
function auth(supabase: unknown) {
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
requireWriteMock.mockResolvedValue({ ok: true })
|
||||
})
|
||||
|
||||
describe('POST /api/deadlines/[id]/complete', () => {
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: null,
|
||||
supabase: {},
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
const res = await POST(createMockRequest('/x', { method: 'POST' }), idParams)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 403 for a viewer', async () => {
|
||||
auth(createCapturingSupabase([], {}))
|
||||
requireWriteMock.mockResolvedValue({
|
||||
ok: false,
|
||||
response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }),
|
||||
})
|
||||
const res = await POST(
|
||||
createMockRequest('/x', { method: 'POST', body: { is_completed: false } }),
|
||||
idParams,
|
||||
)
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
|
||||
it('maps zero-rows to 404 with a Swedish message', async () => {
|
||||
auth(createCapturingSupabase([{ error: { code: 'PGRST116', message: 'no rows' } }], {}))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(
|
||||
await POST(
|
||||
createMockRequest('/x', { method: 'POST', body: { is_completed: false } }),
|
||||
idParams,
|
||||
),
|
||||
)
|
||||
expect(status).toBe(404)
|
||||
expect(body.error).toBe('Deadline hittades inte')
|
||||
})
|
||||
|
||||
it('marks a pending deadline done and stamps completed_at', async () => {
|
||||
const captured: Captured = {}
|
||||
auth(
|
||||
createCapturingSupabase(
|
||||
[{ data: { is_completed: false } }, { data: { id: 'deadline-1', is_completed: true } }],
|
||||
captured,
|
||||
),
|
||||
)
|
||||
const { status } = await parseJsonResponse(
|
||||
await POST(
|
||||
createMockRequest('/x', { method: 'POST', body: { is_completed: true } }),
|
||||
idParams,
|
||||
),
|
||||
)
|
||||
expect(status).toBe(200)
|
||||
expect(captured.update?.is_completed).toBe(true)
|
||||
expect(captured.update?.completed_at).toBeTruthy()
|
||||
})
|
||||
|
||||
it('undoes a completed deadline and clears completed_at', async () => {
|
||||
const captured: Captured = {}
|
||||
auth(
|
||||
createCapturingSupabase(
|
||||
[{ data: { is_completed: true } }, { data: { id: 'deadline-1', is_completed: false } }],
|
||||
captured,
|
||||
),
|
||||
)
|
||||
const { status } = await parseJsonResponse(
|
||||
await POST(
|
||||
createMockRequest('/x', { method: 'POST', body: { is_completed: false } }),
|
||||
idParams,
|
||||
),
|
||||
)
|
||||
expect(status).toBe(200)
|
||||
expect(captured.update?.is_completed).toBe(false)
|
||||
expect(captured.update?.completed_at).toBeNull()
|
||||
})
|
||||
|
||||
it('is idempotent: undoing an already-pending deadline leaves it pending', async () => {
|
||||
// The "Ångra" click can land after the row was already un-ticked in another
|
||||
// tab or by an MCP agent. A route that toggled here would re-complete the
|
||||
// deadline, and the caller would still report it as put back on the list.
|
||||
const captured: Captured = {}
|
||||
auth(
|
||||
createCapturingSupabase(
|
||||
[{ data: { is_completed: false } }, { data: { id: 'deadline-1', is_completed: false } }],
|
||||
captured,
|
||||
),
|
||||
)
|
||||
const { status } = await parseJsonResponse(
|
||||
await POST(
|
||||
createMockRequest('/x', { method: 'POST', body: { is_completed: false } }),
|
||||
idParams,
|
||||
),
|
||||
)
|
||||
expect(status).toBe(200)
|
||||
expect(captured.update?.is_completed).toBe(false)
|
||||
expect(captured.update?.completed_at).toBeNull()
|
||||
})
|
||||
|
||||
it('is idempotent: re-completing an already-done deadline keeps it done', async () => {
|
||||
const captured: Captured = {}
|
||||
auth(
|
||||
createCapturingSupabase(
|
||||
[{ data: { is_completed: true } }, { data: { id: 'deadline-1', is_completed: true } }],
|
||||
captured,
|
||||
),
|
||||
)
|
||||
await POST(
|
||||
createMockRequest('/x', { method: 'POST', body: { is_completed: true } }),
|
||||
idParams,
|
||||
)
|
||||
expect(captured.update?.is_completed).toBe(true)
|
||||
expect(captured.update?.completed_at).toBeTruthy()
|
||||
})
|
||||
|
||||
it('falls back to toggling when the body carries no state', async () => {
|
||||
// Backwards compatibility: a body-less POST keeps the original toggle.
|
||||
const captured: Captured = {}
|
||||
auth(
|
||||
createCapturingSupabase(
|
||||
[{ data: { is_completed: false } }, { data: { id: 'deadline-1', is_completed: true } }],
|
||||
captured,
|
||||
),
|
||||
)
|
||||
await POST(createMockRequest('/x', { method: 'POST' }), idParams)
|
||||
expect(captured.update?.is_completed).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a non-boolean is_completed with 400 instead of guessing', async () => {
|
||||
// { is_completed: "false" } is the load-bearing case: a string is truthy,
|
||||
// and the old hand-rolled parser silently degraded it to a toggle, so an
|
||||
// "undo" carrying the string could re-complete the deadline. A wrong type
|
||||
// must fail loudly and persist nothing.
|
||||
const captured: Captured = {}
|
||||
auth(createCapturingSupabase([], captured))
|
||||
const { status } = await parseJsonResponse(
|
||||
await POST(
|
||||
createMockRequest('/x', { method: 'POST', body: { is_completed: 'false' } }),
|
||||
idParams,
|
||||
),
|
||||
)
|
||||
expect(status).toBe(400)
|
||||
expect(captured.update).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects other wrong types for is_completed with 400', async () => {
|
||||
const captured: Captured = {}
|
||||
auth(createCapturingSupabase([], captured))
|
||||
for (const bad of [1, 'nope', null, [true]]) {
|
||||
const { status } = await parseJsonResponse(
|
||||
await POST(
|
||||
createMockRequest('/x', { method: 'POST', body: { is_completed: bad } }),
|
||||
idParams,
|
||||
),
|
||||
)
|
||||
expect(status, `is_completed=${JSON.stringify(bad)} must be a 400`).toBe(400)
|
||||
}
|
||||
expect(captured.update).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects unknown body keys with 400 (strict schema)', async () => {
|
||||
const captured: Captured = {}
|
||||
auth(createCapturingSupabase([], captured))
|
||||
const { status } = await parseJsonResponse(
|
||||
await POST(
|
||||
createMockRequest('/x', { method: 'POST', body: { is_completed: true, extra: 1 } }),
|
||||
idParams,
|
||||
),
|
||||
)
|
||||
expect(status).toBe(400)
|
||||
expect(captured.update).toBeUndefined()
|
||||
})
|
||||
|
||||
it('treats an explicit empty object as a toggle', async () => {
|
||||
const captured: Captured = {}
|
||||
auth(
|
||||
createCapturingSupabase(
|
||||
[{ data: { is_completed: false } }, { data: { id: 'deadline-1', is_completed: true } }],
|
||||
captured,
|
||||
),
|
||||
)
|
||||
const { status } = await parseJsonResponse(
|
||||
await POST(createMockRequest('/x', { method: 'POST', body: {} }), idParams),
|
||||
)
|
||||
expect(status).toBe(200)
|
||||
expect(captured.update?.is_completed).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,17 +1,62 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
|
||||
// Strict: `is_completed` must be a genuine boolean or absent. The hand-rolled
|
||||
// parser this replaces silently degraded { is_completed: "false" } (a string)
|
||||
// to a toggle: the exact hazard the explicit-state contract was built to
|
||||
// remove, since a truthy-string undo could re-complete the row it meant to
|
||||
// un-tick. Wrong types are a 400 now, never a guess.
|
||||
const CompleteDeadlineSchema = z
|
||||
.object({
|
||||
is_completed: z.boolean().optional(),
|
||||
})
|
||||
.strict()
|
||||
|
||||
/**
|
||||
* POST /api/deadlines/[id]/complete
|
||||
* Toggle completion status of a deadline
|
||||
* Set the completion status of a deadline.
|
||||
*
|
||||
* The body is optional. When it carries a boolean `is_completed` the route sets
|
||||
* exactly that state; a caller that sends no body (or `{}`) keeps the original
|
||||
* toggle behaviour. A body that IS present but malformed (wrong type, unknown
|
||||
* keys, broken JSON) is rejected with 400 instead of being reinterpreted.
|
||||
*
|
||||
* Honouring an explicit state is what makes the "Ångra" affordance an undo
|
||||
* rather than a second toggle: the click has to be idempotent, because the row
|
||||
* can already have been un-ticked in another tab or by an MCP agent between the
|
||||
* toast appearing and the click landing, and a blind toggle would then
|
||||
* re-complete a Skatteverket deadline the user was trying to put back on the
|
||||
* list. It also makes the caller's own confirmation truthful: the client picks
|
||||
* its toast from the state it asked for, so the server must not silently
|
||||
* persist the opposite.
|
||||
*/
|
||||
export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
'deadline.toggle_complete',
|
||||
async (_request, ctx, { params }) => {
|
||||
async (request, ctx, { params }) => {
|
||||
const { id } = await params
|
||||
const { supabase, companyId } = ctx
|
||||
|
||||
// Absent/empty body = toggle (the pre-explicit-state ergonomics); anything
|
||||
// else must validate. The raw text is read first because validateBody
|
||||
// treats an empty body as invalid JSON, which would 400 the plain toggle.
|
||||
const raw = (await request.text()).trim()
|
||||
let requestedState: boolean | null = null
|
||||
if (raw !== '') {
|
||||
const validation = await validateBody(
|
||||
new Request(request.url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: raw,
|
||||
}),
|
||||
CompleteDeadlineSchema,
|
||||
)
|
||||
if (!validation.success) return validation.response
|
||||
requestedState = validation.data.is_completed ?? null
|
||||
}
|
||||
|
||||
// First, get current deadline state
|
||||
const { data: existing, error: fetchError } = await supabase
|
||||
.from('deadlines')
|
||||
@@ -22,13 +67,14 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
|
||||
if (fetchError) {
|
||||
if (fetchError.code === 'PGRST116') {
|
||||
return NextResponse.json({ error: 'Deadline not found' }, { status: 404 })
|
||||
// "Deadline" is the term the Swedish UI uses too (messages/sv.json).
|
||||
return NextResponse.json({ error: 'Deadline hittades inte' }, { status: 404 })
|
||||
}
|
||||
return NextResponse.json({ error: getUserErrorMessage(fetchError) }, { status: 500 })
|
||||
}
|
||||
|
||||
// Toggle completion
|
||||
const newCompletedState = !existing.is_completed
|
||||
// Explicit state when the caller supplied one, otherwise toggle.
|
||||
const newCompletedState = requestedState ?? !existing.is_completed
|
||||
const { data, error } = await supabase
|
||||
.from('deadlines')
|
||||
.update({
|
||||
|
||||
@@ -33,6 +33,19 @@ import { POST } from '../import-existing/route'
|
||||
const request = () => createMockRequest('/api/dimensions/import-existing', { method: 'POST' })
|
||||
const noParams = { params: Promise.resolve({}) }
|
||||
|
||||
/**
|
||||
* Enqueue the two pages the scan reads through the two-step entry-lines fetch
|
||||
* (lib/bookkeeping/entry-lines.ts): the company's parent entries first, then
|
||||
* the lines keyed by journal_entry_id. The route never reads the parent, so a
|
||||
* single synthetic entry per line set is enough.
|
||||
*/
|
||||
function enqueueLines(rows: Array<{ id: string; dimensions: Record<string, string> }>) {
|
||||
enqueue({ data: rows.length > 0 ? [{ id: 'entry-1' }] : [] })
|
||||
// No entries means the helper never queries the lines at all.
|
||||
if (rows.length === 0) return
|
||||
enqueue({ data: rows.map((r) => ({ ...r, journal_entry_id: 'entry-1' })) })
|
||||
}
|
||||
|
||||
describe('POST /api/dimensions/import-existing', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -52,7 +65,7 @@ describe('POST /api/dimensions/import-existing', () => {
|
||||
|
||||
it('returns { created: 0 } when no line carries dimensions', async () => {
|
||||
enqueue({ data: null }) // ensure RPC
|
||||
enqueue({ data: [] }) // journal_entry_lines scan
|
||||
enqueueLines([]) // journal_entry_lines scan
|
||||
|
||||
const response = await POST(request(), noParams)
|
||||
const { status, body } = await parseJsonResponse<{ created: number }>(response)
|
||||
@@ -64,13 +77,12 @@ describe('POST /api/dimensions/import-existing', () => {
|
||||
it('creates inactive placeholder values for codes missing from the registry', async () => {
|
||||
enqueue({ data: null }) // ensure RPC
|
||||
// Lines: KS01 (dim 1) appears twice, P001 (dim 6) once, BUTIK already registered.
|
||||
enqueue({
|
||||
data: [
|
||||
{ id: 'l1', dimensions: { '1': 'KS01' } },
|
||||
{ id: 'l2', dimensions: { '1': 'KS01', '6': 'P001' } },
|
||||
{ id: 'l3', dimensions: { '1': 'BUTIK' } },
|
||||
],
|
||||
})
|
||||
enqueueLines([
|
||||
{ id: 'l1', dimensions: { '1': 'KS01' } },
|
||||
{ id: 'l2', dimensions: { '1': 'KS01', '6': 'P001' } },
|
||||
{ id: 'l3', dimensions: { '1': 'BUTIK' } },
|
||||
|
||||
])
|
||||
// Registry dims 1 & 6 exist (system dims).
|
||||
enqueue({
|
||||
data: [
|
||||
@@ -94,14 +106,13 @@ describe('POST /api/dimensions/import-existing', () => {
|
||||
enqueue({ data: null }) // ensure RPC
|
||||
// 'KS"01"' and 'KS{01}' both sanitize to KS01 (one candidate); a 50-char
|
||||
// code is capped at 40; '"{}"' sanitizes to empty and is dropped entirely.
|
||||
enqueue({
|
||||
data: [
|
||||
{ id: 'l1', dimensions: { '1': 'KS"01"' } },
|
||||
{ id: 'l2', dimensions: { '1': 'KS{01}' } },
|
||||
{ id: 'l3', dimensions: { '1': 'X'.repeat(50) } },
|
||||
{ id: 'l4', dimensions: { '1': '"{}"' } },
|
||||
],
|
||||
})
|
||||
enqueueLines([
|
||||
{ id: 'l1', dimensions: { '1': 'KS"01"' } },
|
||||
{ id: 'l2', dimensions: { '1': 'KS{01}' } },
|
||||
{ id: 'l3', dimensions: { '1': 'X'.repeat(50) } },
|
||||
{ id: 'l4', dimensions: { '1': '"{}"' } },
|
||||
|
||||
])
|
||||
enqueue({ data: [{ id: 'dim-1', sie_dim_no: 1 }] }) // registry dims
|
||||
enqueue({ data: [] }) // no existing values
|
||||
// Upsert returns the two surviving sanitized codes (KS01 + the capped one).
|
||||
@@ -116,12 +127,11 @@ describe('POST /api/dimensions/import-existing', () => {
|
||||
|
||||
it('tolerates duplicates in the batch: created counts only the rows the upsert returned', async () => {
|
||||
enqueue({ data: null }) // ensure RPC
|
||||
enqueue({
|
||||
data: [
|
||||
{ id: 'l1', dimensions: { '1': 'KS01' } },
|
||||
{ id: 'l2', dimensions: { '1': 'KS02' } },
|
||||
],
|
||||
})
|
||||
enqueueLines([
|
||||
{ id: 'l1', dimensions: { '1': 'KS01' } },
|
||||
{ id: 'l2', dimensions: { '1': 'KS02' } },
|
||||
|
||||
])
|
||||
enqueue({ data: [{ id: 'dim-1', sie_dim_no: 1 }] }) // registry dims
|
||||
enqueue({ data: [] }) // existing-values snapshot missed a raced KS02
|
||||
// ignoreDuplicates upsert skips the conflicting row instead of aborting
|
||||
@@ -137,7 +147,7 @@ describe('POST /api/dimensions/import-existing', () => {
|
||||
|
||||
it('creates a registry dimension for an unregistered dim number found on lines', async () => {
|
||||
enqueue({ data: null }) // ensure RPC
|
||||
enqueue({ data: [{ id: 'l1', dimensions: { '7': 'AVD-A' } }] }) // lines
|
||||
enqueueLines([{ id: 'l1', dimensions: { '7': 'AVD-A' } }]) // lines
|
||||
// Registry only has the system dims: dim 7 is missing.
|
||||
enqueue({
|
||||
data: [
|
||||
@@ -161,7 +171,7 @@ describe('POST /api/dimensions/import-existing', () => {
|
||||
|
||||
it('returns 500 DIMENSION_IMPORT_FAILED when the scan blows up', async () => {
|
||||
enqueue({ data: null }) // ensure RPC
|
||||
enqueue({ error: { message: 'relation missing' } }) // fetchAllRows throws
|
||||
enqueue({ error: { message: 'relation missing' } }) // entry page throws
|
||||
|
||||
const response = await POST(request(), noParams)
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import { fetchEntryLines, type EntryLinesQuery } from '@/lib/bookkeeping/entry-lines'
|
||||
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
|
||||
@@ -42,16 +42,19 @@ export const POST = withRouteContext(
|
||||
|
||||
try {
|
||||
// 1. Collect every {dim_no: code} pair used on this company's lines.
|
||||
// Inner-join scoping: journal_entry_lines has no company_id column.
|
||||
const lines = await fetchAllRows<LineRow>(({ from, to }) =>
|
||||
supabase
|
||||
.from('journal_entry_lines')
|
||||
.select('id, dimensions, journal_entries!inner(company_id)')
|
||||
.eq('journal_entries.company_id', companyId)
|
||||
.neq('dimensions', '{}')
|
||||
.order('id', { ascending: true })
|
||||
.range(from, to),
|
||||
)
|
||||
// journal_entry_lines has no company_id column, so the scope comes
|
||||
// from the parent entries. Driving the query from that side
|
||||
// (lib/bookkeeping/entry-lines.ts) instead of a
|
||||
// `journal_entries!inner(company_id)` embed keeps PostgREST from
|
||||
// compiling a correlated LATERAL join that walks every tenant's
|
||||
// lines. The parent is not read here, so it is not reattached.
|
||||
const lines = await fetchEntryLines<LineRow>({
|
||||
supabase,
|
||||
lineColumns: 'id, dimensions',
|
||||
filterEntries: (q: EntryLinesQuery) => q.eq('company_id', companyId),
|
||||
filterLines: (q: EntryLinesQuery) => q.neq('dimensions', '{}'),
|
||||
attachEntriesAs: null,
|
||||
})
|
||||
|
||||
const codesByDimNo = new Map<number, Set<string>>()
|
||||
for (const line of lines) {
|
||||
|
||||
@@ -174,6 +174,30 @@ describe('GET /api/dimensions/tagging/lines', () => {
|
||||
expect(second.lines.map((l) => l.id)).toEqual(['line-3'])
|
||||
})
|
||||
|
||||
it('orders each voucher lines by sort_order regardless of fetch order', async () => {
|
||||
// The two-step fetch (lib/bookkeeping/entry-lines.ts) pages the lines on
|
||||
// their PK for stable paging, so the workbench's voucher order
|
||||
// (sort_order, then id) has to be restored per verifikat.
|
||||
enqueue({ data: [makeRawEntry()] })
|
||||
enqueue({
|
||||
data: [
|
||||
makeRawLine({ id: 'line-c', sort_order: 2, account_number: '2641' }),
|
||||
makeRawLine({ id: 'line-a', sort_order: 0, account_number: '4010' }),
|
||||
makeRawLine({ id: 'line-b', sort_order: 1, account_number: '1930' }),
|
||||
],
|
||||
})
|
||||
|
||||
const response = await GET(request(), noParams)
|
||||
const { status, body } = await parseJsonResponse<VouchersBody>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.vouchers[0].lines.map((l) => l.id)).toEqual([
|
||||
'line-a',
|
||||
'line-b',
|
||||
'line-c',
|
||||
])
|
||||
})
|
||||
|
||||
it('caps the result at limit vouchers and reports total_capped', async () => {
|
||||
// limit=2 → route fetches 3 entries; a third means "there is more".
|
||||
enqueue({
|
||||
|
||||
@@ -29,6 +29,7 @@ import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validateQuery } from '@/lib/api/validate'
|
||||
import { DimensionTaggingLinesQuerySchema } from '@/lib/api/schemas'
|
||||
import { errorResponse } from '@/lib/errors/get-structured-error'
|
||||
import { fetchLinesByEntryIds } from '@/lib/bookkeeping/entry-lines'
|
||||
import { escapeLikePattern } from '@/lib/invoices/duplicate-payment-guard'
|
||||
|
||||
ensureInitialized()
|
||||
@@ -169,29 +170,40 @@ export const GET = withRouteContext(
|
||||
}
|
||||
|
||||
// Step 2: the COMPLETE line set for each qualifying voucher, so voucher-
|
||||
// level tagging always covers the whole verifikat. The entry IDs already
|
||||
// come from the company-filtered step-1 query; the explicit parent scope
|
||||
// here is defense in depth (repo convention).
|
||||
const { data: lineData, error: lineError } = await supabase
|
||||
.from('journal_entry_lines')
|
||||
.select('id, account_number, debit_amount, credit_amount, dimensions, journal_entry_id, sort_order, journal_entries!inner(company_id)')
|
||||
.eq('journal_entries.company_id', companyId)
|
||||
.in('journal_entry_id', entries.map((e) => e.id))
|
||||
.order('journal_entry_id', { ascending: true })
|
||||
.order('sort_order', { ascending: true })
|
||||
.order('id', { ascending: true })
|
||||
|
||||
if (lineError) {
|
||||
log.error('tagging voucher line fetch failed', lineError)
|
||||
return errorResponse(lineError, log, { requestId })
|
||||
// level tagging always covers the whole verifikat.
|
||||
//
|
||||
// The entry IDs already come from the company-filtered step-1 query, so
|
||||
// the old `journal_entries!inner(company_id)` embed added nothing but the
|
||||
// correlated LATERAL join PostgREST compiles it into, which walks every
|
||||
// tenant's journal_entry_lines (see lib/bookkeeping/entry-lines.ts).
|
||||
// fetchLinesByEntryIds keeps the same `.in('journal_entry_id', ...)`
|
||||
// scope, chunks it, and pages each chunk: the whole line set for a full
|
||||
// page of vouchers no longer silently stops at PostgREST's 1000-row cap.
|
||||
let lineData: RawLine[]
|
||||
try {
|
||||
lineData = await fetchLinesByEntryIds<RawLine>(
|
||||
supabase,
|
||||
entries.map((e) => e.id),
|
||||
'id, account_number, debit_amount, credit_amount, dimensions, sort_order',
|
||||
)
|
||||
} catch (err) {
|
||||
log.error('tagging voucher line fetch failed', err as Error)
|
||||
return errorResponse(err, log, { requestId })
|
||||
}
|
||||
|
||||
const linesByEntry = new Map<string, RawLine[]>()
|
||||
for (const line of (lineData ?? []) as unknown as RawLine[]) {
|
||||
for (const line of lineData) {
|
||||
const bucket = linesByEntry.get(line.journal_entry_id) ?? []
|
||||
bucket.push(line)
|
||||
linesByEntry.set(line.journal_entry_id, bucket)
|
||||
}
|
||||
// The helper orders on the line PK for stable paging; the workbench shows
|
||||
// lines in voucher order, which is (sort_order, id) inside each verifikat.
|
||||
for (const bucket of linesByEntry.values()) {
|
||||
bucket.sort(
|
||||
(a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0) || a.id.localeCompare(b.id),
|
||||
)
|
||||
}
|
||||
|
||||
const vouchers = entries.map((e) => ({
|
||||
journal_entry_id: e.id,
|
||||
|
||||
@@ -38,6 +38,16 @@ vi.mock('@/lib/supabase/server', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
// deleteDocument removes storage objects via the cookieless service-role
|
||||
// client: the documents bucket is WORM (no DELETE policy on storage.objects),
|
||||
// so a caller-bound remove() is silently blocked by RLS.
|
||||
const serviceRemoveMock = vi.fn()
|
||||
vi.mock('@/lib/auth/api-keys', () => ({
|
||||
createServiceClientNoCookies: () => ({
|
||||
storage: { from: vi.fn(() => ({ remove: serviceRemoveMock })) },
|
||||
}),
|
||||
}))
|
||||
|
||||
import { GET, DELETE } from '../route'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import { NextResponse } from 'next/server'
|
||||
@@ -55,6 +65,7 @@ beforeEach(() => {
|
||||
data: { signedUrl: 'https://example.com/signed' },
|
||||
error: null,
|
||||
})
|
||||
serviceRemoveMock.mockResolvedValue({ data: [], error: null })
|
||||
})
|
||||
|
||||
function makeReq(method: 'GET' | 'DELETE' = 'DELETE') {
|
||||
@@ -228,9 +239,17 @@ describe('DELETE /api/documents/[id]', () => {
|
||||
expect(status).toBe(200)
|
||||
expect(body.data).toEqual({ id: 'doc-1', deleted: true })
|
||||
|
||||
expect(mockSupabase.storage.from).toHaveBeenCalledWith('documents')
|
||||
const storageBucket = mockSupabase.storage.from.mock.results[0]?.value
|
||||
expect(storageBucket.remove).toHaveBeenCalledWith(['documents/user-1/kvitto.pdf'])
|
||||
// Both storage layouts are removed: the stored pointer plus the alternate
|
||||
// candidate key. During the company-scoped path migration a document can
|
||||
// exist under either prefix, and removing only the stored one would leave a
|
||||
// readable orphan copy of a document the user asked to erase. The removal
|
||||
// must go through the service-role client (WORM bucket: RLS silently
|
||||
// blocks a caller-bound remove()), never the user-bound client.
|
||||
expect(serviceRemoveMock).toHaveBeenCalledWith([
|
||||
'documents/user-1/kvitto.pdf',
|
||||
'documents/company-1/user-1/kvitto.pdf',
|
||||
])
|
||||
expect(mockSupabase.storage.from).not.toHaveBeenCalled()
|
||||
|
||||
expect(handler).toHaveBeenCalledOnce()
|
||||
expect(handler).toHaveBeenCalledWith(
|
||||
|
||||
@@ -192,6 +192,32 @@ describe('GET /api/documents/verify/cron', () => {
|
||||
expect(String(state.auditInserts[0].description)).not.toContain('DOCUMENT_OBJECT_MISSING')
|
||||
})
|
||||
|
||||
it('verifies via the company-scoped fallback key when a concurrent backfill re-homed the object', async () => {
|
||||
// The batch snapshot carries a stale legacy pointer: mid-batch, the
|
||||
// Phase B backfill copied the object to the company-scoped key and
|
||||
// removed the source. This must NOT produce a permanent false
|
||||
// DOCUMENT_OBJECT_MISSING audit row for a healthy document.
|
||||
const doc = makeDoc({
|
||||
id: 'doc-repointed',
|
||||
storage_path: 'documents/user-1/1_receipt.pdf',
|
||||
})
|
||||
const hash = registerObject('documents/company-1/user-1/1_receipt.pdf', 'healthy bytes')
|
||||
state.documents = [{ ...doc, sha256_hash: hash }]
|
||||
|
||||
const response = await GET(cronRequest())
|
||||
const json = await response.json()
|
||||
|
||||
expect(state.auditInserts).toHaveLength(0)
|
||||
expect(state.updates.map((u) => u.id)).toEqual(['doc-repointed'])
|
||||
expect(json).toEqual({
|
||||
processed: 1,
|
||||
verified: 1,
|
||||
failures: 0,
|
||||
missingObjects: 0,
|
||||
errors: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('surfaces a missing storage object as an audit incident AND stamps the check', async () => {
|
||||
const missing = makeDoc({ id: 'doc-missing', storage_path: 'user-1/company-1/gone.pdf' })
|
||||
const healthy = makeDoc({ id: 'doc-healthy', storage_path: 'user-1/company-1/ok.pdf' })
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { withCronContext } from '@/lib/api/with-cron-context'
|
||||
import { downloadDocumentObject } from '@/lib/core/documents/document-service'
|
||||
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
|
||||
/**
|
||||
@@ -55,9 +56,19 @@ export const GET = withCronContext('cron.documents_verify', async (_request, ctx
|
||||
let missingObjects = 0
|
||||
|
||||
const summary = await ctx.forEach('document', documents, async (doc, itemCtx) => {
|
||||
const { data: fileData, error: downloadError } = await supabase.storage
|
||||
.from('documents')
|
||||
.download(doc.storage_path)
|
||||
// Dual-layout download: the batch is snapshotted up front, and a
|
||||
// concurrent Phase B backfill (scripts/backfill-document-storage-paths.ts)
|
||||
// can re-home an object from the legacy uploader-scoped key to the
|
||||
// company-scoped key (and later remove the source) mid-batch, leaving
|
||||
// doc.storage_path stale. Trusting the stale pointer here wrote a
|
||||
// PERMANENT false DOCUMENT_OBJECT_MISSING INTEGRITY_FAILURE row into the
|
||||
// immutable audit log for a healthy document. The helper tries the
|
||||
// stored pointer first, then the alternate layout.
|
||||
const { blob: fileData, error: downloadError } = await downloadDocumentObject(
|
||||
supabase,
|
||||
doc.storage_path,
|
||||
doc.company_id
|
||||
)
|
||||
|
||||
if (downloadError || !fileData) {
|
||||
// The storage object is unreadable: surface it as an incident in the
|
||||
|
||||
@@ -19,18 +19,28 @@ vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
// Mock API key auth
|
||||
// Mock API key auth. Only the three IO-bound helpers are faked: hasScope and
|
||||
// DEFAULT_SCOPES stay real so the scope tests exercise the actual scope table.
|
||||
const mockValidateApiKey = vi.fn()
|
||||
const mockExtractBearerToken = vi.fn()
|
||||
const mockCreateServiceClientNoCookies = vi.fn()
|
||||
vi.mock('@/lib/auth/api-keys', () => ({
|
||||
validateApiKey: (...args: unknown[]) => mockValidateApiKey(...args),
|
||||
extractBearerToken: (...args: unknown[]) => mockExtractBearerToken(...args),
|
||||
createServiceClientNoCookies: () => mockCreateServiceClientNoCookies(),
|
||||
}))
|
||||
vi.mock('@/lib/auth/api-keys', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/auth/api-keys')>()
|
||||
return {
|
||||
...actual,
|
||||
validateApiKey: (...args: unknown[]) => mockValidateApiKey(...args),
|
||||
extractBearerToken: (...args: unknown[]) => mockExtractBearerToken(...args),
|
||||
createServiceClientNoCookies: () => mockCreateServiceClientNoCookies(),
|
||||
}
|
||||
})
|
||||
|
||||
import { DEFAULT_SCOPES } from '@/lib/auth/api-keys'
|
||||
import { GET } from '../route'
|
||||
|
||||
interface ErrorEnvelope {
|
||||
error: { code: string; message: string; message_en?: string; details?: unknown }
|
||||
}
|
||||
|
||||
describe('GET /api/events', () => {
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
|
||||
@@ -51,6 +61,33 @@ describe('GET /api/events', () => {
|
||||
},
|
||||
]
|
||||
|
||||
/** A successful validateApiKey result, scoped and live by default. */
|
||||
const apiKeyAuth = (overrides: Record<string, unknown> = {}) => ({
|
||||
userId: 'user-1',
|
||||
companyId: 'company-1',
|
||||
apiKeyId: 'key-1',
|
||||
apiKeyName: 'n8n poller',
|
||||
scopes: ['events:read'],
|
||||
mode: 'live',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
/**
|
||||
* Wire the service-role client the API-key branch uses. The route makes at
|
||||
* most two queries in order: company_members, then event_log.
|
||||
*/
|
||||
const withKeyClient = (
|
||||
results: { data?: unknown; error?: unknown }[],
|
||||
) => {
|
||||
const keyClient = createQueuedMockSupabase()
|
||||
for (const r of results) keyClient.enqueue(r)
|
||||
mockCreateServiceClientNoCookies.mockReturnValue(keyClient.supabase)
|
||||
return keyClient
|
||||
}
|
||||
|
||||
/** The membership row the re-check expects to find. */
|
||||
const membershipFound = { data: { company_id: 'company-1' } }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
@@ -58,6 +95,8 @@ describe('GET /api/events', () => {
|
||||
requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase, error: null })
|
||||
})
|
||||
|
||||
// ── Session auth (unchanged behaviour) ───────────────────────
|
||||
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockExtractBearerToken.mockReturnValue(null)
|
||||
requireAuthMock.mockResolvedValue({
|
||||
@@ -75,6 +114,8 @@ describe('GET /api/events', () => {
|
||||
})
|
||||
|
||||
it('returns events with session auth', async () => {
|
||||
// Exactly ONE queued result: if the session path wrongly ran the
|
||||
// company_members re-check it would consume this and return no events.
|
||||
enqueue({ data: sampleEvents })
|
||||
|
||||
const request = createMockRequest('/api/events')
|
||||
@@ -89,22 +130,21 @@ describe('GET /api/events', () => {
|
||||
expect(body.data).toHaveLength(2)
|
||||
expect(body.cursor).toBe(2)
|
||||
expect(body.has_more).toBe(false)
|
||||
expect(mockValidateApiKey).not.toHaveBeenCalled()
|
||||
expect(response.headers.get('X-Gnubok-Mode')).toBeNull()
|
||||
})
|
||||
|
||||
// ── API key auth ─────────────────────────────────────────────
|
||||
|
||||
it('returns events with API key auth', async () => {
|
||||
mockExtractBearerToken.mockReturnValue('gnubok_sk_test123')
|
||||
mockValidateApiKey.mockResolvedValue({ userId: 'user-1' })
|
||||
|
||||
const apiKeySupabase = createQueuedMockSupabase()
|
||||
apiKeySupabase.enqueue({ data: sampleEvents })
|
||||
mockCreateServiceClientNoCookies.mockReturnValue(apiKeySupabase.supabase)
|
||||
mockValidateApiKey.mockResolvedValue(apiKeyAuth())
|
||||
withKeyClient([membershipFound, { data: sampleEvents }])
|
||||
|
||||
const request = createMockRequest('/api/events')
|
||||
const response = await GET(request)
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data: typeof sampleEvents
|
||||
cursor: number
|
||||
has_more: boolean
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
@@ -118,23 +158,175 @@ describe('GET /api/events', () => {
|
||||
|
||||
const request = createMockRequest('/api/events')
|
||||
const response = await GET(request)
|
||||
const { status, body } = await parseJsonResponse(response)
|
||||
const { status, body } = await parseJsonResponse<ErrorEnvelope>(response)
|
||||
|
||||
expect(status).toBe(401)
|
||||
expect(body).toEqual({ error: 'Invalid API key' })
|
||||
expect(body.error.code).toBe('UNAUTHORIZED')
|
||||
})
|
||||
|
||||
it('returns 429 for rate-limited API key', async () => {
|
||||
it('returns 429 with a RATE_LIMITED code for a rate-limited API key', async () => {
|
||||
mockExtractBearerToken.mockReturnValue('gnubok_sk_limited')
|
||||
mockValidateApiKey.mockResolvedValue({ error: 'Rate limit exceeded', status: 429 })
|
||||
|
||||
const request = createMockRequest('/api/events')
|
||||
const response = await GET(request)
|
||||
const { status } = await parseJsonResponse(response)
|
||||
const { status, body } = await parseJsonResponse<ErrorEnvelope>(response)
|
||||
|
||||
expect(status).toBe(429)
|
||||
expect(body.error.code).toBe('RATE_LIMITED')
|
||||
})
|
||||
|
||||
// ── Guard 1: scope ───────────────────────────────────────────
|
||||
|
||||
it('returns 403 INSUFFICIENT_SCOPE for a key without events:read', async () => {
|
||||
mockExtractBearerToken.mockReturnValue('gnubok_sk_noscope')
|
||||
mockValidateApiKey.mockResolvedValue(
|
||||
apiKeyAuth({ scopes: ['reports:read', 'invoices:read'] }),
|
||||
)
|
||||
const keyClient = withKeyClient([membershipFound, { data: sampleEvents }])
|
||||
|
||||
const request = createMockRequest('/api/events')
|
||||
const response = await GET(request)
|
||||
const { status, body } = await parseJsonResponse<ErrorEnvelope>(response)
|
||||
|
||||
expect(status).toBe(403)
|
||||
expect(body.error.code).toBe('INSUFFICIENT_SCOPE')
|
||||
expect(body.error.details).toMatchObject({ required_scope: 'events:read' })
|
||||
// Denied before any DB access.
|
||||
expect(keyClient.supabase.from).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 403 for a legacy null-scope key falling back to DEFAULT_SCOPES', async () => {
|
||||
// validateApiKey substitutes DEFAULT_SCOPES when the api_keys row has
|
||||
// scopes = NULL. Those six read scopes do NOT include events:read.
|
||||
expect(DEFAULT_SCOPES).not.toContain('events:read')
|
||||
|
||||
mockExtractBearerToken.mockReturnValue('gnubok_sk_legacy')
|
||||
mockValidateApiKey.mockResolvedValue(apiKeyAuth({ scopes: DEFAULT_SCOPES }))
|
||||
withKeyClient([membershipFound, { data: sampleEvents }])
|
||||
|
||||
const request = createMockRequest('/api/events')
|
||||
const response = await GET(request)
|
||||
const { status, body } = await parseJsonResponse<ErrorEnvelope>(response)
|
||||
|
||||
expect(status).toBe(403)
|
||||
expect(body.error.code).toBe('INSUFFICIENT_SCOPE')
|
||||
})
|
||||
|
||||
// ── Guard 2: company membership re-check ─────────────────────
|
||||
|
||||
it('returns 404 when the key user is no longer a member of the bound company', async () => {
|
||||
mockExtractBearerToken.mockReturnValue('gnubok_sk_offboarded')
|
||||
mockValidateApiKey.mockResolvedValue(apiKeyAuth())
|
||||
// Membership lookup finds nothing (offboarded, or company archived).
|
||||
withKeyClient([{ data: null }, { data: sampleEvents }])
|
||||
|
||||
const request = createMockRequest('/api/events')
|
||||
const response = await GET(request)
|
||||
const { status, body } = await parseJsonResponse<ErrorEnvelope>(response)
|
||||
|
||||
expect(status).toBe(404)
|
||||
expect(body.error.code).toBe('NOT_FOUND')
|
||||
})
|
||||
|
||||
it('returns 500 when the membership lookup itself fails', async () => {
|
||||
mockExtractBearerToken.mockReturnValue('gnubok_sk_dberror')
|
||||
mockValidateApiKey.mockResolvedValue(apiKeyAuth())
|
||||
withKeyClient([{ error: { message: 'connection failure' } }, { data: sampleEvents }])
|
||||
|
||||
const request = createMockRequest('/api/events')
|
||||
const response = await GET(request)
|
||||
const { status, body } = await parseJsonResponse<ErrorEnvelope>(response)
|
||||
|
||||
expect(status).toBe(500)
|
||||
expect(body.error.code).toBe('INTERNAL_ERROR')
|
||||
})
|
||||
|
||||
// ── Guard 3: test-mode keys ──────────────────────────────────
|
||||
|
||||
it('serves a test-mode key and labels the response X-Gnubok-Mode: test', async () => {
|
||||
mockExtractBearerToken.mockReturnValue('gnubok_sk_test_abc')
|
||||
mockValidateApiKey.mockResolvedValue(apiKeyAuth({ mode: 'test' }))
|
||||
withKeyClient([membershipFound, { data: sampleEvents }])
|
||||
|
||||
const request = createMockRequest('/api/events')
|
||||
const response = await GET(request)
|
||||
const { status, body } = await parseJsonResponse<{ data: typeof sampleEvents }>(response)
|
||||
|
||||
// A read has no write path to simulate, so it is served (matching every v1
|
||||
// read) rather than blocked with TEST_KEY_WRITE_BLOCKED.
|
||||
expect(status).toBe(200)
|
||||
expect(body.data).toHaveLength(2)
|
||||
expect(response.headers.get('X-Gnubok-Mode')).toBe('test')
|
||||
})
|
||||
|
||||
it('does not label live-key responses with X-Gnubok-Mode', async () => {
|
||||
mockExtractBearerToken.mockReturnValue('gnubok_sk_live')
|
||||
mockValidateApiKey.mockResolvedValue(apiKeyAuth({ mode: 'live' }))
|
||||
withKeyClient([membershipFound, { data: sampleEvents }])
|
||||
|
||||
const request = createMockRequest('/api/events')
|
||||
const response = await GET(request)
|
||||
|
||||
expect(response.headers.get('X-Gnubok-Mode')).toBeNull()
|
||||
})
|
||||
|
||||
// ── Payload minimisation ─────────────────────────────────────
|
||||
|
||||
it('minimises event payloads before returning them', async () => {
|
||||
mockExtractBearerToken.mockReturnValue('gnubok_sk_ok')
|
||||
mockValidateApiKey.mockResolvedValue(apiKeyAuth())
|
||||
withKeyClient([
|
||||
membershipFound,
|
||||
{
|
||||
data: [
|
||||
{
|
||||
sequence: 7,
|
||||
event_type: 'invoice.created',
|
||||
entity_id: 'inv-7',
|
||||
// userId is what minimisePayload strips: an internal
|
||||
// auth.users.id that identifies the gnubok-side actor.
|
||||
data: { userId: 'user-1', invoice: { id: 'inv-7', total: 1250 } },
|
||||
created_at: '2026-03-25T10:00:00Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
const request = createMockRequest('/api/events')
|
||||
const response = await GET(request)
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data: { data: Record<string, unknown> }[]
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data[0].data).not.toHaveProperty('userId')
|
||||
expect(body.data[0].data).toHaveProperty('invoice')
|
||||
})
|
||||
|
||||
it('leaves non-object payloads untouched', async () => {
|
||||
enqueue({
|
||||
data: [
|
||||
{
|
||||
sequence: 3,
|
||||
event_type: 'period.locked',
|
||||
entity_id: null,
|
||||
data: null,
|
||||
created_at: '2026-03-25T10:00:00Z',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const request = createMockRequest('/api/events')
|
||||
const response = await GET(request)
|
||||
const { status, body } = await parseJsonResponse<{ data: { data: unknown }[] }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data[0].data).toBeNull()
|
||||
})
|
||||
|
||||
// ── Query params + cursor semantics ──────────────────────────
|
||||
|
||||
it('supports after cursor parameter', async () => {
|
||||
enqueue({ data: [sampleEvents[1]] })
|
||||
|
||||
@@ -219,4 +411,18 @@ describe('GET /api/events', () => {
|
||||
|
||||
expect(status).toBe(400)
|
||||
})
|
||||
|
||||
it('rejects invalid query params before running the event query for API keys', async () => {
|
||||
mockExtractBearerToken.mockReturnValue('gnubok_sk_ok')
|
||||
mockValidateApiKey.mockResolvedValue(apiKeyAuth())
|
||||
withKeyClient([membershipFound, { data: sampleEvents }])
|
||||
|
||||
const request = createMockRequest('/api/events', {
|
||||
searchParams: { after: '-1' },
|
||||
})
|
||||
const response = await GET(request)
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
})
|
||||
})
|
||||
|
||||
+133
-7
@@ -1,11 +1,59 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { requireAuth } from '@/lib/auth/require-auth'
|
||||
import { extractBearerToken, validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
|
||||
import {
|
||||
extractBearerToken,
|
||||
validateApiKey,
|
||||
createServiceClientNoCookies,
|
||||
hasScope,
|
||||
type ApiKeyMode,
|
||||
} from '@/lib/auth/api-keys'
|
||||
import { validateQuery } from '@/lib/api/validate'
|
||||
import { EventsQuerySchema } from '@/lib/api/schemas'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { minimisePayload } from '@/lib/webhooks/handler'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
|
||||
const log = createLogger('api/events')
|
||||
|
||||
/**
|
||||
* Scope that gates this endpoint. Declared in lib/auth/api-keys.ts as
|
||||
* "Polla händelseloggen (event_log) som webhook-fallback": the scope existed
|
||||
* from day one but was never enforced here, so a key holding only
|
||||
* DEFAULT_SCOPES (the six read scopes a legacy null-scope key falls back to,
|
||||
* none of which is events:read) could still drain the whole event log.
|
||||
*/
|
||||
const REQUIRED_SCOPE = 'events:read' as const
|
||||
|
||||
/** Response header mirroring the v1 wrapper so integrators can see the key mode. */
|
||||
const MODE_HEADER = 'X-Gnubok-Mode'
|
||||
|
||||
interface EventLogRow {
|
||||
sequence: number
|
||||
event_type: string
|
||||
entity_id: string | null
|
||||
data: unknown
|
||||
created_at: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Project a stored event payload through the same minimisation the webhook
|
||||
* fan-out applies (lib/webhooks/handler.ts `minimisePayload`).
|
||||
*
|
||||
* The event_log row stores the emit-site payload minus userId/companyId
|
||||
* (lib/events/handlers/event-log-handler.ts `stripMetaFields`), which is a
|
||||
* different and narrower projection than the webhook one. Routing the polled
|
||||
* rows through minimisePayload keeps the pull surface from ever handing out
|
||||
* more than the push surface for the same event, and means a future tightening
|
||||
* (e.g. stripping personnummer from payroll payloads) lands in one place for
|
||||
* both. GDPR Art.5(1)(c) data minimisation.
|
||||
*/
|
||||
function minimiseEventData(value: unknown): unknown {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return value
|
||||
return minimisePayload(value as Record<string, unknown>)
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/events
|
||||
@@ -21,7 +69,12 @@ import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-m
|
||||
* Supports both session auth (browser) and API key auth (automation platforms).
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
// Dual auth: API key or session
|
||||
// Dual auth: API key or session. This is a deliberate withRouteContext
|
||||
// opt-out: the wrapper is cookie-session only and cannot express the API-key
|
||||
// branch. The session branch below still goes through requireAuth(), so MFA
|
||||
// (AAL2) stays enforced; the key branch runs the same guards the other two
|
||||
// validateApiKey call sites run (lib/api/v1/with-api-v1.ts,
|
||||
// extensions/general/mcp-server): scope, then company membership.
|
||||
let userId: string
|
||||
let supabase: SupabaseClient
|
||||
// When authenticated via an API key, the key is BOUND to a specific company.
|
||||
@@ -29,15 +82,39 @@ export async function GET(request: Request) {
|
||||
// active company: otherwise a key scoped to company A would leak company B's
|
||||
// events whenever the user's active_company_id happened to point elsewhere.
|
||||
let keyCompanyId: string | null = null
|
||||
let keyMode: ApiKeyMode = 'live'
|
||||
|
||||
const token = extractBearerToken(request)
|
||||
if (token?.startsWith('gnubok_sk_')) {
|
||||
const authResult = await validateApiKey(token)
|
||||
if ('error' in authResult) {
|
||||
return NextResponse.json({ error: authResult.error }, { status: authResult.status })
|
||||
// validateApiKey only ever returns 401 (bad/unknown/refresh token) or
|
||||
// 429 (rate limit): map both onto the canonical envelope, same as v1.
|
||||
return errorResponseFromCode(
|
||||
authResult.status === 429 ? 'RATE_LIMITED' : 'UNAUTHORIZED',
|
||||
log,
|
||||
{ reason: authResult.error },
|
||||
)
|
||||
}
|
||||
|
||||
// Guard 1: scope. Mirrors with-api-v1.ts step 4.
|
||||
if (!hasScope(authResult.scopes, REQUIRED_SCOPE)) {
|
||||
return errorResponseFromCode('INSUFFICIENT_SCOPE', log, {
|
||||
details: { required_scope: REQUIRED_SCOPE, granted_scopes: authResult.scopes },
|
||||
})
|
||||
}
|
||||
|
||||
userId = authResult.userId
|
||||
keyCompanyId = authResult.companyId
|
||||
// Guard 3: test keys. A test key is simulation-only for WRITES (the v1
|
||||
// wrapper forces dry-run and blocks non-simulatable mutations with
|
||||
// TEST_KEY_WRITE_BLOCKED). This endpoint has no write path at all, so the
|
||||
// consistent behaviour for a read is the one v1 already ships: serve the
|
||||
// key's own bound company and label the response, rather than 403 with a
|
||||
// "cannot be simulated" message that would be untrue for a GET.
|
||||
keyMode = authResult.mode
|
||||
// Service-role client: RLS does NOT apply below this line, which is exactly
|
||||
// why the membership re-check further down is mandatory.
|
||||
supabase = createServiceClientNoCookies()
|
||||
} else {
|
||||
// Session auth: requireAuth enforces MFA (AAL2) on hosted, unlike a bare
|
||||
@@ -54,7 +131,44 @@ export async function GET(request: Request) {
|
||||
// scope. requireCompanyId throws when there is no company, but guard the
|
||||
// key-bound path too so a malformed binding can't widen the query scope.
|
||||
if (!companyId) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
return errorResponseFromCode('FORBIDDEN', log)
|
||||
}
|
||||
|
||||
// Guard 2: membership re-check, API-key path only.
|
||||
//
|
||||
// company_id on the api_keys row is a snapshot taken when the key was minted.
|
||||
// Offboarding a user from a company does not revoke their keys, and the
|
||||
// service-role client above bypasses RLS, so without this the key keeps
|
||||
// draining the event log of a company its owner no longer belongs to. Both
|
||||
// sibling call sites already re-check (with-api-v1.ts :349-370 and
|
||||
// mcp-server/company-routing.ts :90-102); this closes the third.
|
||||
//
|
||||
// archived_at IS NULL follows company-routing: an archived company is a
|
||||
// deactivated tenant and should stop feeding automation platforms.
|
||||
//
|
||||
// The session path is deliberately excluded: it runs on the request-scoped
|
||||
// client where RLS (user_company_ids()) already enforces the same rule.
|
||||
if (keyCompanyId !== null) {
|
||||
const { data: membership, error: membershipError } = await supabase
|
||||
.from('company_members')
|
||||
.select('company_id, companies!inner(archived_at)')
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', companyId)
|
||||
.is('companies.archived_at', null)
|
||||
.maybeSingle()
|
||||
|
||||
if (membershipError) {
|
||||
log.error('failed to resolve company membership', membershipError)
|
||||
return errorResponseFromCode('INTERNAL_ERROR', log, {
|
||||
details: { reason: getUserErrorMessage(membershipError) },
|
||||
})
|
||||
}
|
||||
|
||||
if (!membership) {
|
||||
// 404 rather than 403, matching v1 and MCP: do not confirm the company
|
||||
// exists to a caller who is no longer allowed to see it.
|
||||
return errorResponseFromCode('NOT_FOUND', log, { details: { companyId } })
|
||||
}
|
||||
}
|
||||
|
||||
// Validate query params
|
||||
@@ -81,14 +195,26 @@ export async function GET(request: Request) {
|
||||
const { data, error } = await query
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
|
||||
log.error('event_log query failed', error)
|
||||
return errorResponseFromCode('INTERNAL_ERROR', log, {
|
||||
details: { reason: getUserErrorMessage(error) },
|
||||
})
|
||||
}
|
||||
|
||||
const events = data ?? []
|
||||
const rows = (data ?? []) as EventLogRow[]
|
||||
const events = rows.map((row) => ({ ...row, data: minimiseEventData(row.data) }))
|
||||
|
||||
return NextResponse.json({
|
||||
const response = NextResponse.json({
|
||||
data: events,
|
||||
cursor: events.length > 0 ? events[events.length - 1].sequence : (after ?? 0),
|
||||
has_more: events.length === limit,
|
||||
})
|
||||
|
||||
// Signal test mode the same way the v1 wrapper does, so an integrator can see
|
||||
// which key mode served the response without inspecting the body.
|
||||
if (keyMode === 'test') {
|
||||
response.headers.set(MODE_HEADER, 'test')
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ vi.mock('@/lib/supabase/fetch-all', () => ({
|
||||
}))
|
||||
|
||||
import { GET } from '../route'
|
||||
import { encryptPersonnummer } from '@/lib/salary/personnummer'
|
||||
import { UNDECRYPTABLE_PERSONAL_NUMBER_MASK } from '@/lib/customers/protect-personal-number'
|
||||
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
|
||||
@@ -50,14 +52,14 @@ beforeEach(() => {
|
||||
describe('GET /api/export/customers', () => {
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
|
||||
const res = await GET(createMockRequest('/api/export/customers'))
|
||||
const res = await GET(createMockRequest('/api/export/customers'), { params: Promise.resolve({}) })
|
||||
const { status } = await parseJsonResponse(res)
|
||||
expect(status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns an xlsx customer register', async () => {
|
||||
enqueue({ data: { company_name: 'Acme AB' } })
|
||||
const res = await GET(createMockRequest('/api/export/customers'))
|
||||
const res = await GET(createMockRequest('/api/export/customers'), { params: Promise.resolve({}) })
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers.get('Content-Type')).toContain('spreadsheetml')
|
||||
@@ -73,7 +75,7 @@ describe('GET /api/export/customers', () => {
|
||||
|
||||
it('returns a CSV with BOM when format=csv', async () => {
|
||||
enqueue({ data: { company_name: 'Acme AB' } })
|
||||
const res = await GET(createMockRequest('/api/export/customers', { searchParams: { format: 'csv' } }))
|
||||
const res = await GET(createMockRequest('/api/export/customers', { searchParams: { format: 'csv' } }), { params: Promise.resolve({}) })
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers.get('Content-Type')).toContain('text/csv')
|
||||
@@ -81,4 +83,71 @@ describe('GET /api/export/customers', () => {
|
||||
expect([buf[0], buf[1], buf[2]]).toEqual([0xef, 0xbb, 0xbf])
|
||||
expect(buf.toString('utf-8')).toContain('Göteborg')
|
||||
})
|
||||
|
||||
it('masks an encrypted personal_number: never ciphertext, never the full number', async () => {
|
||||
// customers.personal_number holds AES-256-GCM ciphertext (migration
|
||||
// 20260726110000). The export must show what the UI shows a member:
|
||||
// '********-1234', not the hex blob and not the decrypted personnummer.
|
||||
const stored = encryptPersonnummer('19900101-1234') // synthetic
|
||||
mockFetchAllRows.mockResolvedValue([
|
||||
{
|
||||
...CUSTOMER,
|
||||
id: 'c2',
|
||||
name: 'Anna Andersson',
|
||||
customer_type: 'individual',
|
||||
org_number: null,
|
||||
vat_number: null,
|
||||
personal_number: stored,
|
||||
},
|
||||
])
|
||||
enqueue({ data: { company_name: 'Acme AB' } })
|
||||
|
||||
const res = await GET(createMockRequest('/api/export/customers'), { params: Promise.resolve({}) })
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const buf = Buffer.from(await res.arrayBuffer())
|
||||
const wb = XLSX.read(new Uint8Array(buf), { type: 'array' })
|
||||
const sheet = wb.Sheets[wb.SheetNames[0]]
|
||||
const rows = XLSX.utils.sheet_to_json<string[]>(sheet, { header: 1 })
|
||||
const serialized = JSON.stringify(rows)
|
||||
|
||||
expect(rows[1] as string[]).toContain('********-1234')
|
||||
// Neither the ciphertext nor the birthdate half of the number may appear.
|
||||
expect(serialized).not.toContain(stored)
|
||||
expect(serialized).not.toContain('19900101')
|
||||
})
|
||||
|
||||
it('falls back to a placeholder mask when the stored value cannot be decrypted', async () => {
|
||||
// Hex of the shape the DB CHECK accepts, but with an auth tag that can
|
||||
// never verify. One bad row must neither leak nor abort the export.
|
||||
const garbage = 'ab'.repeat(40)
|
||||
mockFetchAllRows.mockResolvedValue([
|
||||
{
|
||||
...CUSTOMER,
|
||||
id: 'c3',
|
||||
name: 'Berit Bengtsson',
|
||||
customer_type: 'individual',
|
||||
org_number: null,
|
||||
vat_number: null,
|
||||
personal_number: garbage,
|
||||
},
|
||||
])
|
||||
enqueue({ data: { company_name: 'Acme AB' } })
|
||||
|
||||
const res = await GET(createMockRequest('/api/export/customers', { searchParams: { format: 'csv' } }), { params: Promise.resolve({}) })
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const text = Buffer.from(await res.arrayBuffer()).toString('utf-8')
|
||||
expect(text).toContain(UNDECRYPTABLE_PERSONAL_NUMBER_MASK)
|
||||
expect(text).not.toContain(garbage)
|
||||
})
|
||||
|
||||
it('keeps the raw org number for business customers', async () => {
|
||||
// Org numbers are public registry data: they export untouched, and the
|
||||
// masking branch only engages when org_number is absent.
|
||||
enqueue({ data: { company_name: 'Acme AB' } })
|
||||
const res = await GET(createMockRequest('/api/export/customers', { searchParams: { format: 'csv' } }), { params: Promise.resolve({}) })
|
||||
const text = Buffer.from(await res.arrayBuffer()).toString('utf-8')
|
||||
expect(text).toContain('5560217780')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import { errorResponse } from '@/lib/errors/get-structured-error'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import { textColumn, integerColumn } from '@/lib/reports/xlsx-export'
|
||||
import { buildRegisterExport, parseExportFormat, todayIso } from '@/lib/export/register-export'
|
||||
import { maskStoredCustomerPersonalNumber } from '@/lib/customers/protect-personal-number'
|
||||
import type { Customer } from '@/types'
|
||||
|
||||
/**
|
||||
@@ -58,7 +59,14 @@ export const GET = withRouteContext(
|
||||
rows: customers,
|
||||
mapRow: (c) => [
|
||||
c.name,
|
||||
c.org_number ?? c.personal_number,
|
||||
// personal_number holds AES-256-GCM ciphertext (migration
|
||||
// 20260726110000). Decrypt-and-mask exactly like the customer
|
||||
// read surfaces: the export shows a member the same
|
||||
// '********-1234' the UI shows, never the ciphertext and never
|
||||
// the full personnummer (GDPR Art. 5(1)(f) data minimization on
|
||||
// a file that leaves the system). Decrypt failures fall back to
|
||||
// a placeholder mask instead of aborting the export.
|
||||
c.org_number ?? maskStoredCustomerPersonalNumber(c.personal_number),
|
||||
c.customer_type,
|
||||
c.email,
|
||||
c.phone,
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
vi.mock('@supabase/supabase-js', () => ({
|
||||
createClient: vi.fn().mockReturnValue({}),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/extensions/loader', () => ({
|
||||
loadExtensions: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/extensions/registry', () => ({
|
||||
extensionRegistry: {
|
||||
get: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/extensions/general/push-notifications/notification-scheduler', () => ({
|
||||
sendTaxDeadlineNotifications: vi.fn(),
|
||||
sendInvoiceNotifications: vi.fn(),
|
||||
sendMissingUnderlagNotifications: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/cron', () => ({
|
||||
verifyCronSecret: vi.fn().mockReturnValue(null),
|
||||
}))
|
||||
|
||||
import { GET } from '../route'
|
||||
import { extensionRegistry } from '@/lib/extensions/registry'
|
||||
import { loadExtensions } from '@/lib/extensions/loader'
|
||||
import {
|
||||
sendTaxDeadlineNotifications,
|
||||
sendInvoiceNotifications,
|
||||
sendMissingUnderlagNotifications,
|
||||
} from '@/extensions/general/push-notifications/notification-scheduler'
|
||||
import { verifyCronSecret } from '@/lib/auth/cron'
|
||||
|
||||
const mockRegistryGet = vi.mocked(extensionRegistry.get)
|
||||
const mockVerifyCronSecret = vi.mocked(verifyCronSecret)
|
||||
const mockTax = vi.mocked(sendTaxDeadlineNotifications)
|
||||
const mockInvoice = vi.mocked(sendInvoiceNotifications)
|
||||
const mockUnderlag = vi.mocked(sendMissingUnderlagNotifications)
|
||||
|
||||
function makeRequest() {
|
||||
return new Request('http://localhost/api/extensions/push-notifications/cron', {
|
||||
headers: { authorization: 'Bearer synthetic-cron-secret' },
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockVerifyCronSecret.mockReturnValue(null)
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL = 'https://synthetic.supabase.co'
|
||||
process.env.SUPABASE_SERVICE_ROLE_KEY = 'synthetic-service-role-key'
|
||||
})
|
||||
|
||||
describe('GET /api/extensions/push-notifications/cron', () => {
|
||||
it('returns 401 when the cron secret is rejected', async () => {
|
||||
mockVerifyCronSecret.mockReturnValue(
|
||||
NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
)
|
||||
|
||||
const response = await GET(makeRequest())
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(mockTax).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 503 EXTENSION_DISABLED when the extension is not in the registry', async () => {
|
||||
// Physical extension routes deploy in every build; the registry, generated
|
||||
// from extensions.config.json, is what turns them on. Disabled must mean
|
||||
// no sends AND a visible failure if the cron is ever scheduled anyway.
|
||||
mockRegistryGet.mockReturnValue(undefined)
|
||||
|
||||
const response = await GET(makeRequest())
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(503)
|
||||
expect(body.code).toBe('EXTENSION_DISABLED')
|
||||
expect(mockTax).not.toHaveBeenCalled()
|
||||
expect(mockInvoice).not.toHaveBeenCalled()
|
||||
expect(mockUnderlag).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('runs all three schedulers and sums the totals when enabled', async () => {
|
||||
mockRegistryGet.mockReturnValue({ id: 'push-notifications' } as never)
|
||||
mockTax.mockResolvedValue({ sent: 2, skipped: 1 })
|
||||
mockInvoice.mockResolvedValue({ sent: 3, skipped: 0 })
|
||||
mockUnderlag.mockResolvedValue({ sent: 1, skipped: 4 })
|
||||
|
||||
const response = await GET(makeRequest())
|
||||
const body = await response.json()
|
||||
|
||||
expect(loadExtensions).toHaveBeenCalled()
|
||||
expect(mockRegistryGet).toHaveBeenCalledWith('push-notifications')
|
||||
expect(response.status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
expect(body.totalSent).toBe(6)
|
||||
expect(body.totalSkipped).toBe(5)
|
||||
expect(body.details.taxDeadlines).toEqual({ sent: 2, skipped: 1 })
|
||||
})
|
||||
|
||||
it('returns the error envelope when Supabase configuration is missing', async () => {
|
||||
mockRegistryGet.mockReturnValue({ id: 'push-notifications' } as never)
|
||||
delete process.env.NEXT_PUBLIC_SUPABASE_URL
|
||||
|
||||
const response = await GET(makeRequest())
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(500)
|
||||
expect(body.error.code).toBe('INTERNAL_ERROR')
|
||||
expect(mockTax).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { loadExtensions } from '@/lib/extensions/loader'
|
||||
import { extensionRegistry } from '@/lib/extensions/registry'
|
||||
import {
|
||||
sendTaxDeadlineNotifications,
|
||||
sendInvoiceNotifications,
|
||||
@@ -12,11 +13,29 @@ import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structure
|
||||
/**
|
||||
* GET /api/extensions/push-notifications/cron: daily 09:00 UTC.
|
||||
* Sends due tax, invoice and missing-underlag push notifications.
|
||||
*
|
||||
* NOT scheduled: this path is absent from vercel.json's crons (and therefore
|
||||
* from the Docker crontabs generated from it). Adding it there is a product
|
||||
* decision, not a code change.
|
||||
*/
|
||||
export const GET = withCronContext('cron.push_notifications', async (_request, ctx) => {
|
||||
// Ensure extensions are loaded so event handlers are registered.
|
||||
// Load the registry so it reflects extensions.config.json.
|
||||
loadExtensions()
|
||||
|
||||
// Physical routes under app/api/extensions/<id>/ compile into EVERY build,
|
||||
// including the core-with-zero-extensions one: the registry (generated from
|
||||
// extensions.config.json) is what actually switches an extension on. Mirror
|
||||
// the ext/[...path] dispatcher: a disabled extension must not expose a live
|
||||
// send/query surface, and a scheduled-but-disabled cron must fail visibly
|
||||
// (503) instead of quietly doing the work anyway.
|
||||
if (!extensionRegistry.get('push-notifications')) {
|
||||
ctx.log.warn('push-notifications extension is not enabled; cron refused')
|
||||
return NextResponse.json(
|
||||
{ error: 'Push notifications extension is not enabled', code: 'EXTENSION_DISABLED' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
|
||||
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY
|
||||
|
||||
|
||||
@@ -13,8 +13,9 @@ ensureInitialized()
|
||||
* company. Backs the dashboard SkattekontoDriftTile. Returns null when no
|
||||
* snapshot exists yet (fresh company, never synced).
|
||||
*
|
||||
* Access is recorded through the structured logger (Sentry / Vercel logs)
|
||||
* because the response carries sensitive GL drift figures. Persisting every
|
||||
* Access is recorded through the structured logger (Vercel logs; the
|
||||
* observability sink only sees errors and no-ops until a provider is
|
||||
* configured) because the response carries sensitive GL drift figures. Persisting every
|
||||
* dashboard tile poll into event_log would be too noisy: the structured
|
||||
* log line gives an auditable record without overrunning the 30-day event
|
||||
* log retention (SOC 2 CC8.1, ISO 27001 A.8.15).
|
||||
|
||||
@@ -180,7 +180,8 @@ export const POST = withRouteContext(
|
||||
// ASVS V16: durable audit sink for a failed correction. The core event bus
|
||||
// has no opening_balance.* correction event type and lib/events/types.ts is
|
||||
// outside the scope of this change, so the failure is recorded via the
|
||||
// structured logger: it lands in the JSON log sink (Vercel/Sentry), tagged
|
||||
// structured logger: it lands in the JSON log (Vercel logs, plus the
|
||||
// observability sink, which no-ops until a provider is configured), tagged
|
||||
// `audit: true` + both entry ids so an operator can reconcile the period by
|
||||
// hand. (Follow-up: promote to a typed event persisted to event_log.)
|
||||
const auditCorrectionFailure = (fields: Record<string, unknown>) => {
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import {
|
||||
parseJsonResponse,
|
||||
createMockRouteParams,
|
||||
createQueuedMockSupabase,
|
||||
} from '@/tests/helpers'
|
||||
|
||||
const { supabase: mockSupabase, reset } = createQueuedMockSupabase()
|
||||
|
||||
const requireAuthMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-auth', () => ({
|
||||
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}))
|
||||
|
||||
// The route delegates everything to replaceSIEImport (which itself runs the
|
||||
// replace_sie_import RPC on the service client). The route's own contract is
|
||||
// what these tests pin: auth, the 403 mapping of the RPC's authorization
|
||||
// raise, the 400 fallback, and the success envelope.
|
||||
const replaceSIEImportMock = vi.fn()
|
||||
vi.mock('@/lib/import/sie-import', () => ({
|
||||
replaceSIEImport: (...args: unknown[]) => replaceSIEImportMock(...args),
|
||||
}))
|
||||
|
||||
import { POST } from '../route'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
|
||||
function makeReq() {
|
||||
return new Request('http://localhost/api/import/sie/import-1/replace', { method: 'POST' })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase, error: null })
|
||||
})
|
||||
|
||||
describe('POST /api/import/sie/[id]/replace', () => {
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: null,
|
||||
supabase: mockSupabase,
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
const res = await POST(makeReq(), createMockRouteParams({ id: 'import-1' }))
|
||||
expect(res.status).toBe(401)
|
||||
expect(replaceSIEImportMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('maps the RPC authorization raise (42501) to 403 SIE_REPLACE_FORBIDDEN', async () => {
|
||||
// replaceSIEImport flattens the PostgREST error into a message string, so
|
||||
// the route matches the raise text pinned by migration 20260727120000.
|
||||
replaceSIEImportMock.mockResolvedValue({
|
||||
success: false,
|
||||
deletedEntries: 0,
|
||||
error: 'Kunde inte ersätta import: Only company owners and admins can replace SIE imports',
|
||||
})
|
||||
|
||||
const res = await POST(makeReq(), createMockRouteParams({ id: 'import-1' }))
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
error: { code: string; message: string }
|
||||
}>(res)
|
||||
|
||||
expect(status).toBe(403)
|
||||
expect(body.error.code).toBe('SIE_REPLACE_FORBIDDEN')
|
||||
// Swedish user-facing message, not raw Postgres prose.
|
||||
expect(body.error.message).toBe(
|
||||
'Endast ägare eller administratörer kan ersätta en SIE-import.',
|
||||
)
|
||||
})
|
||||
|
||||
it('returns 400 SIE_REPLACE_FAILED with the reason for other failures', async () => {
|
||||
replaceSIEImportMock.mockResolvedValue({
|
||||
success: false,
|
||||
deletedEntries: 0,
|
||||
error: 'Kan inte ersätta import i ett låst eller stängt räkenskapsår. Öppna perioden först.',
|
||||
})
|
||||
|
||||
const res = await POST(makeReq(), createMockRouteParams({ id: 'import-1' }))
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
error: { code: string; details?: { reason?: string } }
|
||||
}>(res)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('SIE_REPLACE_FAILED')
|
||||
expect(body.error.details?.reason).toContain('låst eller stängt')
|
||||
})
|
||||
|
||||
it('passes the authorising user through and returns the deleted count', async () => {
|
||||
replaceSIEImportMock.mockResolvedValue({ success: true, deletedEntries: 42 })
|
||||
|
||||
const res = await POST(makeReq(), createMockRouteParams({ id: 'import-1' }))
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
success: boolean
|
||||
deletedEntries: number
|
||||
}>(res)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
expect(body.deletedEntries).toBe(42)
|
||||
expect(replaceSIEImportMock).toHaveBeenCalledWith(
|
||||
mockSupabase,
|
||||
'company-1',
|
||||
'import-1',
|
||||
'user-1',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -18,12 +18,25 @@ export const POST = withRouteContext(
|
||||
'sie_import.replace',
|
||||
async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => {
|
||||
const { id } = await params
|
||||
const { supabase, companyId, log, requestId } = ctx
|
||||
const { supabase, companyId, user, log, requestId } = ctx
|
||||
const opLog = log.child({ sieImportId: id })
|
||||
|
||||
const result = await replaceSIEImport(supabase, companyId!, id)
|
||||
// The replace_sie_import RPC gates on owner/admin membership. It usually
|
||||
// runs on the service client where auth.uid() is NULL, so the authorising
|
||||
// user is passed explicitly; the RPC honors it only for service_role
|
||||
// callers (migration 20260727120000).
|
||||
const result = await replaceSIEImport(supabase, companyId!, id, user.id)
|
||||
|
||||
if (!result.success) {
|
||||
// The RPC raises 42501 with this exact text when the actor is not an
|
||||
// owner/admin. replaceSIEImport flattens the PostgREST error into a
|
||||
// message string, so the route detects the authorization raise by the
|
||||
// text, which is pinned by migration 20260727120000 and by
|
||||
// lib/import/__tests__/sie-import.replace.pg.test.ts. Surface it as a
|
||||
// structured 403 instead of a 400 with raw English prose in details.
|
||||
if (result.error?.includes('Only company owners and admins can replace SIE imports')) {
|
||||
return errorResponseFromCode('SIE_REPLACE_FORBIDDEN', opLog, { requestId })
|
||||
}
|
||||
return errorResponseFromCode('SIE_REPLACE_FAILED', opLog, {
|
||||
requestId,
|
||||
details: { reason: result.error },
|
||||
|
||||
@@ -4,6 +4,23 @@
|
||||
* Exercises the routes through the real withRouteContext wrapper, mocking only
|
||||
* its auth/company/write dependencies and injecting a queued Supabase mock via
|
||||
* requireAuth. Covers: 401, 403 viewer, validation (400), and happy paths.
|
||||
*
|
||||
* The route has no 404 path: PUT upserts (a missing mapping is created, not
|
||||
* rejected) and DELETE is idempotent (removing an unknown source account is a
|
||||
* no-op, not a miss). The DELETE test below pins that contract.
|
||||
*
|
||||
* The PUT column-level tests use createRecordingSupabase() instead of the
|
||||
* shared queued mock: the queued mock is a Proxy that discards call arguments,
|
||||
* so it cannot see which columns the route actually writes, which is exactly
|
||||
* what regressed here (source_name silently dropped from the upsert).
|
||||
*
|
||||
* The POST tests do the same one level deeper. Mocking saveMappings only
|
||||
* proves which value the route passed as the second positional argument; it
|
||||
* cannot show which COLUMN that value ends up in. That is what hid the bug
|
||||
* this file used to pin: the route passed user.id where saveMappings expects
|
||||
* companyId, and both are strings, so nothing complained. One POST test
|
||||
* therefore runs the real saveMappings against a recording Supabase double and
|
||||
* asserts on the row it upserts.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
@@ -35,6 +52,78 @@ import { GET, POST, PUT, DELETE } from '../route'
|
||||
|
||||
const emptyParams = { params: Promise.resolve({}) }
|
||||
|
||||
type StoredMapping = { source_name: string | null } | null
|
||||
|
||||
type ReadChain = {
|
||||
eq: (column: string, value: unknown) => ReadChain
|
||||
maybeSingle: () => Promise<{ data: StoredMapping; error: null }>
|
||||
}
|
||||
|
||||
type UpsertCall = {
|
||||
table: string
|
||||
payload: Record<string, unknown>
|
||||
options: { onConflict?: string } | undefined
|
||||
}
|
||||
|
||||
type BatchUpsertCall = {
|
||||
table: string
|
||||
rows: Record<string, unknown>[]
|
||||
options: { onConflict?: string } | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Supabase double for the POST path, which upserts an array of rows in
|
||||
* batches. Records every row verbatim so a test can assert which column each
|
||||
* value landed in.
|
||||
*/
|
||||
function createBatchRecordingSupabase() {
|
||||
const upserts: BatchUpsertCall[] = []
|
||||
|
||||
const supabase = {
|
||||
from: (table: string) => ({
|
||||
upsert: async (rows: Record<string, unknown>[], options?: { onConflict?: string }) => {
|
||||
upserts.push({ table, rows, options })
|
||||
return { data: null, error: null }
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
return { supabase, upserts }
|
||||
}
|
||||
|
||||
/**
|
||||
* Supabase double that records the upsert payload and options verbatim.
|
||||
* `stored` is the mapping row the read-back finds (null = first save).
|
||||
*/
|
||||
function createRecordingSupabase(stored: StoredMapping) {
|
||||
const upserts: UpsertCall[] = []
|
||||
const readBackColumns: string[] = []
|
||||
|
||||
const readChain: ReadChain = {
|
||||
eq: () => readChain,
|
||||
maybeSingle: async () => ({ data: stored, error: null }),
|
||||
}
|
||||
|
||||
const supabase = {
|
||||
from: (table: string) => ({
|
||||
select: (columns: string) => {
|
||||
readBackColumns.push(columns)
|
||||
return readChain
|
||||
},
|
||||
upsert: (payload: Record<string, unknown>, options?: { onConflict?: string }) => {
|
||||
upserts.push({ table, payload, options })
|
||||
return {
|
||||
select: () => ({
|
||||
single: async () => ({ data: payload, error: null }),
|
||||
}),
|
||||
}
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
return { supabase, upserts, readBackColumns }
|
||||
}
|
||||
|
||||
describe('/api/import/sie/mappings', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -82,13 +171,47 @@ describe('/api/import/sie/mappings', () => {
|
||||
})
|
||||
|
||||
const response = await POST(request, emptyParams)
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
const { status, body } = await parseJsonResponse<{ error: string; type: string }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error).toBe('Invalid mappings data')
|
||||
expect(body.type).toBe('validation_error')
|
||||
expect(saveMappingsMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('POST saves the mappings', async () => {
|
||||
it('POST rejects wrongly-typed mapping elements with 400 instead of a Postgres 500', async () => {
|
||||
// Element-level payloads used to be unvalidated: a numeric sourceAccount
|
||||
// or a string confidence sailed through to Postgres and surfaced as 500.
|
||||
const badElements = [
|
||||
[{ sourceAccount: 1920, targetAccount: '1930' }], // number, not string
|
||||
[{ sourceAccount: '1920', targetAccount: '1930', confidence: 'high' }],
|
||||
[{ sourceAccount: '1920', targetAccount: '1930', matchType: 'guess' }],
|
||||
[{ sourceAccount: '', targetAccount: '1930' }], // empty source key
|
||||
['not-an-object'],
|
||||
]
|
||||
for (const mappings of badElements) {
|
||||
const response = await POST(
|
||||
createMockRequest('/api/import/sie/mappings', { method: 'POST', body: { mappings } }),
|
||||
emptyParams,
|
||||
)
|
||||
expect(response.status, `payload ${JSON.stringify(mappings)} must be a 400`).toBe(400)
|
||||
}
|
||||
expect(saveMappingsMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('POST accepts unmapped elements (no targetAccount): saveMappings filters them', async () => {
|
||||
const mappings = [
|
||||
{ sourceAccount: '1920', targetAccount: '1930' },
|
||||
{ sourceAccount: '8888' }, // not yet mapped: valid on the wire
|
||||
]
|
||||
const response = await POST(
|
||||
createMockRequest('/api/import/sie/mappings', { method: 'POST', body: { mappings } }),
|
||||
emptyParams,
|
||||
)
|
||||
expect(response.status).toBe(200)
|
||||
expect(saveMappingsMock).toHaveBeenCalledWith(supabase, 'company-1', mappings, 'user-1')
|
||||
})
|
||||
|
||||
it('POST saves the mappings under the active company', async () => {
|
||||
const mappings = [{ sourceAccount: '1920', targetAccount: '1930' }]
|
||||
const request = createMockRequest('/api/import/sie/mappings', {
|
||||
method: 'POST',
|
||||
@@ -100,7 +223,70 @@ describe('/api/import/sie/mappings', () => {
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
expect(saveMappingsMock).toHaveBeenCalledWith(supabase, 'user-1', mappings)
|
||||
// saveMappings(supabase, companyId, mappings): the second argument is the
|
||||
// tenant key and is written straight into sie_account_mappings.company_id.
|
||||
// The route used to pass user.id here; both are strings, so the compiler
|
||||
// could not tell them apart.
|
||||
expect(saveMappingsMock).toHaveBeenCalledWith(supabase, 'company-1', mappings, 'user-1')
|
||||
expect(saveMappingsMock.mock.calls[0][1]).not.toBe('user-1')
|
||||
})
|
||||
|
||||
it('POST writes company_id, not the user id, into the mapping row', async () => {
|
||||
// One level deeper than the mocked-argument assertion above: run the real
|
||||
// saveMappings so the test sees which COLUMN the value lands in. company_id
|
||||
// is NOT NULL and FK-bound to companies, so a user UUID there can never
|
||||
// persist.
|
||||
const { saveMappings: realSaveMappings } =
|
||||
await vi.importActual<typeof import('@/lib/import/sie-import')>('@/lib/import/sie-import')
|
||||
const { supabase: recording, upserts } = createBatchRecordingSupabase()
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase: recording })
|
||||
saveMappingsMock.mockImplementation(realSaveMappings)
|
||||
|
||||
const request = createMockRequest('/api/import/sie/mappings', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
mappings: [
|
||||
{
|
||||
sourceAccount: '1920',
|
||||
sourceName: 'Bank',
|
||||
targetAccount: '1930',
|
||||
confidence: 1.0,
|
||||
matchType: 'manual',
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const response = await POST(request, emptyParams)
|
||||
expect(response.status).toBe(200)
|
||||
|
||||
expect(upserts).toHaveLength(1)
|
||||
expect(upserts[0].table).toBe('sie_account_mappings')
|
||||
expect(upserts[0].rows).toHaveLength(1)
|
||||
expect(upserts[0].rows[0]).toMatchObject({
|
||||
company_id: 'company-1',
|
||||
source_account: '1920',
|
||||
target_account: '1930',
|
||||
})
|
||||
expect(upserts[0].rows[0].company_id).not.toBe('user-1')
|
||||
expect(upserts[0].options?.onConflict).toBe('company_id,source_account')
|
||||
})
|
||||
|
||||
it('POST ignores a company id smuggled into the body', async () => {
|
||||
// Tenancy comes from withRouteContext (membership-validated), never from
|
||||
// the caller. A body field naming another company must not redirect the
|
||||
// write.
|
||||
const mappings = [{ sourceAccount: '1920', targetAccount: '1930' }]
|
||||
const request = createMockRequest('/api/import/sie/mappings', {
|
||||
method: 'POST',
|
||||
body: { mappings, companyId: 'company-2', company_id: 'company-2' },
|
||||
})
|
||||
|
||||
const response = await POST(request, emptyParams)
|
||||
expect(response.status).toBe(200)
|
||||
|
||||
expect(saveMappingsMock).toHaveBeenCalledWith(supabase, 'company-1', mappings, 'user-1')
|
||||
expect(saveMappingsMock.mock.calls[0][1]).not.toBe('company-2')
|
||||
})
|
||||
|
||||
it('GET lists the saved mappings', async () => {
|
||||
@@ -125,7 +311,26 @@ describe('/api/import/sie/mappings', () => {
|
||||
expect(status).toBe(400)
|
||||
})
|
||||
|
||||
it('PUT rejects wrongly-typed fields with 400 instead of a Postgres 500', async () => {
|
||||
const badBodies = [
|
||||
{ sourceAccount: '1920', targetAccount: 1930 }, // number, not string
|
||||
{ sourceAccount: 1920, targetAccount: '1930' },
|
||||
{ sourceAccount: '1920', targetAccount: '1930', sourceName: 42 },
|
||||
{ sourceAccount: '1920', targetAccount: '' }, // empty target
|
||||
]
|
||||
for (const body of badBodies) {
|
||||
const response = await PUT(
|
||||
createMockRequest('/api/import/sie/mappings', { method: 'PUT', body }),
|
||||
emptyParams,
|
||||
)
|
||||
const { status, body: parsed } = await parseJsonResponse<{ type?: string }>(response)
|
||||
expect(status, `body ${JSON.stringify(body)} must be a 400`).toBe(400)
|
||||
expect(parsed.type).toBe('validation_error')
|
||||
}
|
||||
})
|
||||
|
||||
it('PUT upserts a single mapping', async () => {
|
||||
enqueue({ data: null }) // read-back of the stored SIE label
|
||||
enqueue({ data: { source_account: '1920', target_account: '1930' } })
|
||||
|
||||
const request = createMockRequest('/api/import/sie/mappings', {
|
||||
@@ -140,6 +345,95 @@ describe('/api/import/sie/mappings', () => {
|
||||
expect(body.data.target_account).toBe('1930')
|
||||
})
|
||||
|
||||
it('PUT returns 500 when the stored-label read-back fails', async () => {
|
||||
enqueue({ error: { code: '42P01', message: 'relation does not exist' } })
|
||||
|
||||
const request = createMockRequest('/api/import/sie/mappings', {
|
||||
method: 'PUT',
|
||||
body: { sourceAccount: '1920', targetAccount: '1930' },
|
||||
})
|
||||
|
||||
const response = await PUT(request, emptyParams)
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(500)
|
||||
})
|
||||
|
||||
it('PUT keeps the stored SIE label when the client does not resend it', async () => {
|
||||
// Re-save: the mapping row already carries the label from the SIE file's
|
||||
// #KONTO record. The client only sends the new target account.
|
||||
const { supabase: recording, upserts, readBackColumns } =
|
||||
createRecordingSupabase({ source_name: 'Kassa' })
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase: recording })
|
||||
|
||||
const request = createMockRequest('/api/import/sie/mappings', {
|
||||
method: 'PUT',
|
||||
body: { sourceAccount: '1910', targetAccount: '1930' },
|
||||
})
|
||||
|
||||
const response = await PUT(request, emptyParams)
|
||||
expect(response.status).toBe(200)
|
||||
|
||||
expect(readBackColumns).toEqual(['source_name'])
|
||||
expect(upserts).toHaveLength(1)
|
||||
// Full column set: every column the route is expected to write. id,
|
||||
// created_at and updated_at are deliberately absent (DB defaults/trigger).
|
||||
expect(upserts[0].payload).toEqual({
|
||||
user_id: 'user-1',
|
||||
company_id: 'company-1',
|
||||
source_account: '1910',
|
||||
source_name: 'Kassa',
|
||||
target_account: '1930',
|
||||
confidence: 1.0,
|
||||
match_type: 'manual',
|
||||
})
|
||||
// (user_id, source_account) was dropped by the multi-tenant refactor.
|
||||
expect(upserts[0].options?.onConflict).toBe('company_id,source_account')
|
||||
})
|
||||
|
||||
it('PUT sets the SIE label on a first save when the client sends it', async () => {
|
||||
const { supabase: recording, upserts } = createRecordingSupabase(null)
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase: recording })
|
||||
|
||||
const request = createMockRequest('/api/import/sie/mappings', {
|
||||
method: 'PUT',
|
||||
body: { sourceAccount: '1910', targetAccount: '1930', sourceName: 'Bankgiro' },
|
||||
})
|
||||
|
||||
const response = await PUT(request, emptyParams)
|
||||
expect(response.status).toBe(200)
|
||||
|
||||
expect(upserts[0].payload.source_name).toBe('Bankgiro')
|
||||
})
|
||||
|
||||
it('PUT prefers the label the client sends over the stored one', async () => {
|
||||
const { supabase: recording, upserts } = createRecordingSupabase({ source_name: 'Kassa' })
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase: recording })
|
||||
|
||||
const request = createMockRequest('/api/import/sie/mappings', {
|
||||
method: 'PUT',
|
||||
body: { sourceAccount: '1910', targetAccount: '1930', sourceName: 'Kassa och bank' },
|
||||
})
|
||||
|
||||
await PUT(request, emptyParams)
|
||||
|
||||
expect(upserts[0].payload.source_name).toBe('Kassa och bank')
|
||||
})
|
||||
|
||||
it('PUT leaves source_name null when no label is known anywhere', async () => {
|
||||
const { supabase: recording, upserts } = createRecordingSupabase(null)
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase: recording })
|
||||
|
||||
const request = createMockRequest('/api/import/sie/mappings', {
|
||||
method: 'PUT',
|
||||
body: { sourceAccount: '1910', targetAccount: '1930', sourceName: ' ' },
|
||||
})
|
||||
|
||||
await PUT(request, emptyParams)
|
||||
|
||||
expect(upserts[0].payload.source_name).toBeNull()
|
||||
})
|
||||
|
||||
it('DELETE returns 403 for a viewer', async () => {
|
||||
requireWriteMock.mockResolvedValue({
|
||||
ok: false,
|
||||
@@ -166,4 +460,18 @@ describe('/api/import/sie/mappings', () => {
|
||||
expect(status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
})
|
||||
|
||||
it('DELETE of an unknown source account succeeds: the route has no 404 path', async () => {
|
||||
enqueue({ data: null })
|
||||
|
||||
const request = createMockRequest('/api/import/sie/mappings', {
|
||||
method: 'DELETE',
|
||||
searchParams: { sourceAccount: '9999' },
|
||||
})
|
||||
|
||||
const response = await DELETE(request, emptyParams)
|
||||
const { status } = await parseJsonResponse<{ success: boolean }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,9 +1,39 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { saveMappings } from '@/lib/import/sie-import'
|
||||
import type { AccountMapping } from '@/lib/import/types'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
|
||||
// Mirrors what saveMappings() (lib/import/sie-import.ts) reads off each
|
||||
// element: sourceAccount/sourceName/targetAccount/confidence/matchType land
|
||||
// verbatim in sie_account_mappings columns. Everything except sourceAccount is
|
||||
// optional because saveMappings itself tolerates absence: it filters out
|
||||
// elements with a falsy targetAccount (unmapped accounts travel in the same
|
||||
// array), and confidence/match_type fall back to their column defaults.
|
||||
// The point of the schema is type safety: a wrongly-typed element used to
|
||||
// reach Postgres and surface as a 500 instead of a 400.
|
||||
const SieMappingElementSchema = z.object({
|
||||
sourceAccount: z.string().min(1).max(20),
|
||||
sourceName: z.string().max(200).nullish(),
|
||||
targetAccount: z.string().max(20).nullish(),
|
||||
targetName: z.string().max(200).nullish(),
|
||||
confidence: z.number().min(0).max(1).nullish(),
|
||||
matchType: z.enum(['exact', 'name', 'class', 'manual', 'bas_range']).nullish(),
|
||||
isOverride: z.boolean().nullish(),
|
||||
})
|
||||
|
||||
const SaveMappingsSchema = z.object({
|
||||
mappings: z.array(SieMappingElementSchema),
|
||||
})
|
||||
|
||||
const UpdateMappingSchema = z.object({
|
||||
sourceAccount: z.string().min(1).max(20),
|
||||
targetAccount: z.string().min(1).max(20),
|
||||
sourceName: z.string().max(200).nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* GET /api/import/sie/mappings
|
||||
* Get all saved account mappings for the user
|
||||
@@ -31,16 +61,26 @@ export const GET = withRouteContext(
|
||||
*/
|
||||
export const POST = withRouteContext(
|
||||
'sie_import.mappings.save',
|
||||
async (request, { supabase, user }) => {
|
||||
const body = await request.json()
|
||||
const mappings: AccountMapping[] = body.mappings
|
||||
|
||||
if (!mappings || !Array.isArray(mappings)) {
|
||||
return NextResponse.json({ error: 'Invalid mappings data' }, { status: 400 })
|
||||
}
|
||||
async (request, { supabase, companyId, user }) => {
|
||||
const validation = await validateBody(request, SaveMappingsSchema)
|
||||
if (!validation.success) return validation.response
|
||||
// Sparse elements are deliberate (see the schema comment): saveMappings
|
||||
// handles absent fields itself, so the wire type is a relaxed superset of
|
||||
// AccountMapping.
|
||||
const mappings = validation.data.mappings as unknown as AccountMapping[]
|
||||
|
||||
try {
|
||||
await saveMappings(supabase, user.id, mappings)
|
||||
// saveMappings' second parameter is the tenant key: it lands verbatim in
|
||||
// sie_account_mappings.company_id, which is NOT NULL and FK-bound to
|
||||
// companies. This used to pass user.id, so every row the endpoint tried
|
||||
// to write carried a user UUID in the company column. companyId is
|
||||
// resolved by withRouteContext from the caller's own membership and is
|
||||
// never taken from the request body, so a caller cannot write mappings
|
||||
// into a company they do not belong to.
|
||||
// userId is passed explicitly: sie_account_mappings.user_id is NOT NULL
|
||||
// with no default, and saveMappings' session fallback returns nothing on
|
||||
// the cookieless service client used by API-key and MCP callers.
|
||||
await saveMappings(supabase, companyId, mappings, user.id)
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
@@ -59,27 +99,51 @@ export const POST = withRouteContext(
|
||||
export const PUT = withRouteContext(
|
||||
'sie_import.mappings.update',
|
||||
async (request, { supabase, user, companyId }) => {
|
||||
const body = await request.json()
|
||||
const { sourceAccount, targetAccount } = body
|
||||
const validation = await validateBody(request, UpdateMappingSchema)
|
||||
if (!validation.success) return validation.response
|
||||
const { sourceAccount, targetAccount, sourceName } = validation.data
|
||||
|
||||
if (!sourceAccount || !targetAccount) {
|
||||
return NextResponse.json(
|
||||
{ error: 'sourceAccount and targetAccount are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
// source_name is the label the exporting system gave the account in the SIE
|
||||
// file's #KONTO record ('#KONTO 1910 "Kassa"'). During the mapping review it
|
||||
// is the only thing that tells one unfamiliar source account number from
|
||||
// another, and lib/import/account-mapper.ts reads it back for display. It is
|
||||
// per-file and cannot be recomputed from the BAS chart, so read the stored
|
||||
// value and carry it forward instead of requiring the caller to resend it: a
|
||||
// client that doesn't know about the field is exactly how it got erased.
|
||||
const { data: existing, error: existingError } = await supabase
|
||||
.from('sie_account_mappings')
|
||||
.select('source_name')
|
||||
.eq('company_id', companyId)
|
||||
.eq('source_account', sourceAccount)
|
||||
.maybeSingle()
|
||||
|
||||
if (existingError) {
|
||||
return NextResponse.json({ error: getUserErrorMessage(existingError) }, { status: 500 })
|
||||
}
|
||||
|
||||
// A caller that does know the label (the import review sends it straight
|
||||
// from the parsed file) wins, so the first save can set it; otherwise keep
|
||||
// whatever is already stored.
|
||||
const resolvedSourceName =
|
||||
typeof sourceName === 'string' && sourceName.trim().length > 0
|
||||
? sourceName
|
||||
: (existing?.source_name ?? null)
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('sie_account_mappings')
|
||||
.upsert({
|
||||
user_id: user.id,
|
||||
company_id: companyId,
|
||||
source_account: sourceAccount,
|
||||
source_name: resolvedSourceName,
|
||||
target_account: targetAccount,
|
||||
confidence: 1.0,
|
||||
match_type: 'manual',
|
||||
}, {
|
||||
onConflict: 'user_id,source_account',
|
||||
// The (user_id, source_account) unique constraint was dropped by the
|
||||
// multi-tenant refactor; (company_id, source_account) is the one that
|
||||
// exists, and the one saveMappings() upserts against.
|
||||
onConflict: 'company_id,source_account',
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
@@ -32,6 +32,9 @@ const mockGetAvailableVatRates = vi.fn()
|
||||
vi.mock('@/lib/invoices/vat-rules', () => ({
|
||||
getVatRules: (...args: unknown[]) => mockGetVatRules(...args),
|
||||
getAvailableVatRates: (...args: unknown[]) => mockGetAvailableVatRates(...args),
|
||||
// The builder gates on the permitted set (taxed-where-performed exceptions);
|
||||
// these route tests only care that the gate reads the stubbed rates.
|
||||
getPermittedVatRates: (...args: unknown[]) => mockGetAvailableVatRates(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/currency/riksbanken', () => ({
|
||||
@@ -151,6 +154,7 @@ describe('PATCH /api/invoices/[id]', () => {
|
||||
enqueue({ data: makeCustomer({ id: 'customer-1', customer_type: 'swedish_business' }), error: null }) // customer
|
||||
enqueue({ data: { vat_registered: true }, error: null }) // company_settings.vat_registered
|
||||
enqueue({ data: [{ id: 'inv-1' }], error: null }) // update ... select('id')
|
||||
enqueue({ data: [], error: null }) // snapshot existing invoice_items
|
||||
enqueue({ data: [], error: null }) // delete invoice_items
|
||||
enqueue({ data: null, error: null }) // insert invoice_items
|
||||
enqueue({
|
||||
|
||||
@@ -705,4 +705,228 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
|
||||
expect(mockCreateInvoicePaymentJournalEntry).toHaveBeenCalled()
|
||||
expect(mockCreateJournalEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Foreign-currency unit handling.
|
||||
// total / paid_amount / remaining_amount are stored in the INVOICE currency;
|
||||
// custom lines are journal lines and therefore SEK. Everything below pins
|
||||
// the conversion between the two.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
it('converts a SEK custom-line payment to invoice currency before touching the ledger (EUR, partial)', async () => {
|
||||
// 1 000 EUR invoice at 11,4967 SEK/EUR. The customer pays 5 748,35 kr,
|
||||
// which is exactly 500 EUR: a genuine partial payment. Without the
|
||||
// conversion the guard compared 5 748,35 (SEK) against 1 000 (EUR), read
|
||||
// the partial as a full settlement and ran the duplicate-payment guard on
|
||||
// it: a matching bank row would then 409 a perfectly valid partial.
|
||||
const customer = makeCustomer()
|
||||
const invoice = makeInvoice({
|
||||
id: 'inv-1',
|
||||
status: 'sent',
|
||||
currency: 'EUR',
|
||||
exchange_rate: 11.4967,
|
||||
total: 1000,
|
||||
remaining_amount: 1000,
|
||||
customer,
|
||||
})
|
||||
|
||||
enqueue({ data: invoice, error: null })
|
||||
// No duplicate-guard probes enqueued: 500 EUR < 1 000 EUR remaining, so the
|
||||
// guard is skipped entirely.
|
||||
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
|
||||
enqueue({ data: [{ id: 'inv-1' }], error: null })
|
||||
|
||||
mockFindFiscalPeriod.mockResolvedValue('fp-1')
|
||||
mockCreateJournalEntry.mockResolvedValue({ id: 'je-eur-partial' })
|
||||
const paidHandler = vi.fn()
|
||||
eventBus.on('invoice.paid', paidHandler)
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-1/mark-paid', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
lines: [
|
||||
{ account_number: '1930', debit_amount: 5748.35, credit_amount: 0 },
|
||||
{ account_number: '1510', debit_amount: 0, credit_amount: 5748.35 },
|
||||
],
|
||||
},
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
success: boolean
|
||||
status: string
|
||||
paid_amount: number
|
||||
remaining_amount: number
|
||||
journal_entry_id: string
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.status).toBe('partially_paid')
|
||||
// Both in EUR, never 5 748,35 and never a negative remainder.
|
||||
expect(body.paid_amount).toBe(500)
|
||||
expect(body.remaining_amount).toBe(500)
|
||||
expect(body.journal_entry_id).toBe('je-eur-partial')
|
||||
// The load-bearing assertion for the guard fix: the guard compares in
|
||||
// invoice currency now, so a 500 EUR payment on a 1 000 EUR remaining is a
|
||||
// partial and the transactions scan never runs. Comparing the raw SEK
|
||||
// 5 748,35 against 1 000 read it as a full settlement and probed.
|
||||
expect(mockSupabase.from).not.toHaveBeenCalledWith('transactions')
|
||||
// The event carries the invoice-currency amount, matching the ledger.
|
||||
expect(paidHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ paymentAmount: 500 }),
|
||||
)
|
||||
})
|
||||
|
||||
it('still runs the duplicate guard when the converted SEK lines settle a EUR invoice in full', async () => {
|
||||
// The complement of the test above: 11 496,70 kr at 11,4967 is exactly the
|
||||
// 1 000 EUR remaining, so this IS a full settlement and the advisory must
|
||||
// still fire. Converting for the comparison must not disable the guard on
|
||||
// foreign-currency invoices.
|
||||
const customer = makeCustomer()
|
||||
const invoice = makeInvoice({
|
||||
id: 'inv-1',
|
||||
status: 'sent',
|
||||
currency: 'EUR',
|
||||
exchange_rate: 11.4967,
|
||||
total: 1000,
|
||||
remaining_amount: 1000,
|
||||
customer,
|
||||
})
|
||||
|
||||
enqueue({ data: invoice, error: null })
|
||||
// merchant_name ILIKE probe: the bank row is in kronor, because the
|
||||
// candidate lookup scans transactions.amount, which is SEK.
|
||||
enqueue({
|
||||
data: [
|
||||
{
|
||||
id: 'tx-eur',
|
||||
date: '2026-05-10',
|
||||
amount: 11496.7,
|
||||
description: 'Inbetalning Test AB',
|
||||
merchant_name: 'Test AB',
|
||||
reference: null,
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
// description ILIKE probe: no additional match.
|
||||
enqueue({ data: [], error: null })
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-1/mark-paid', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
lines: [
|
||||
{ account_number: '1930', debit_amount: 11496.7, credit_amount: 0 },
|
||||
{ account_number: '1510', debit_amount: 0, credit_amount: 11496.7 },
|
||||
],
|
||||
},
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
error: { code: string; details: { candidates: Array<{ id: string }> } }
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(409)
|
||||
expect(body.error.code).toBe('INVOICE_PAID_LIKELY_DUPLICATE')
|
||||
expect(body.error.details.candidates[0].id).toBe('tx-eur')
|
||||
expect(mockCreateJournalEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 400 MATCH_INVOICE_BOOKING_RATE_MISSING when a EUR invoice carries no exchange rate', async () => {
|
||||
// 11 496,70 kr against a 1 000 EUR invoice with no rate on file. Defaulting
|
||||
// the rate to 1 would read the payment as 11 496,70 EUR.
|
||||
const invoice = makeInvoice({
|
||||
id: 'inv-1',
|
||||
status: 'sent',
|
||||
currency: 'EUR',
|
||||
exchange_rate: null,
|
||||
total: 1000,
|
||||
remaining_amount: 1000,
|
||||
})
|
||||
|
||||
enqueue({ data: invoice, error: null })
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-1/mark-paid', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
lines: [
|
||||
{ account_number: '1930', debit_amount: 11496.7, credit_amount: 0 },
|
||||
{ account_number: '1510', debit_amount: 0, credit_amount: 11496.7 },
|
||||
],
|
||||
},
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string; details?: unknown } }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
// Same code the bank-match path throws for the same condition
|
||||
// (lib/bookkeeping/invoice-payment-lines.ts).
|
||||
expect(body.error.code).toBe('MATCH_INVOICE_BOOKING_RATE_MISSING')
|
||||
expect(mockCreateJournalEntry).not.toHaveBeenCalled()
|
||||
expect(mockCreateInvoicePaymentJournalEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses a sub-remaining SEK payment on a rate-less EUR invoice instead of booking kronor as euro', async () => {
|
||||
// The silent-corruption direction: 500 kr against a 1 000 EUR invoice sits
|
||||
// below the invoice-currency remaining, so no overpayment guard catches it.
|
||||
// With a rate-1 fallback it recorded 500 EUR paid and left 500 EUR
|
||||
// remaining, when the customer had really paid ~43 EUR.
|
||||
const invoice = makeInvoice({
|
||||
id: 'inv-1',
|
||||
status: 'sent',
|
||||
currency: 'EUR',
|
||||
exchange_rate: null,
|
||||
total: 1000,
|
||||
remaining_amount: 1000,
|
||||
})
|
||||
|
||||
enqueue({ data: invoice, error: null })
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-1/mark-paid', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
lines: [
|
||||
{ account_number: '1930', debit_amount: 500, credit_amount: 0 },
|
||||
{ account_number: '1510', debit_amount: 0, credit_amount: 500 },
|
||||
],
|
||||
},
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('MATCH_INVOICE_BOOKING_RATE_MISSING')
|
||||
expect(mockCreateJournalEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves a rate-less EUR invoice payable when no custom lines are supplied', async () => {
|
||||
// The default path pays remaining_amount, which is already in invoice
|
||||
// currency: no conversion happens, so no rate is required.
|
||||
const invoice = makeInvoice({
|
||||
id: 'inv-1',
|
||||
status: 'sent',
|
||||
currency: 'EUR',
|
||||
exchange_rate: null,
|
||||
total: 1000,
|
||||
remaining_amount: 1000,
|
||||
})
|
||||
|
||||
enqueue({ data: invoice, error: null })
|
||||
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
|
||||
enqueue({ data: [{ id: 'inv-1' }], error: null })
|
||||
|
||||
mockCreateInvoicePaymentJournalEntry.mockResolvedValue({ id: 'je-eur-full' })
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-1/mark-paid', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
status: string
|
||||
paid_amount: number
|
||||
remaining_amount: number
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.status).toBe('paid')
|
||||
expect(body.paid_amount).toBe(1000)
|
||||
expect(body.remaining_amount).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -116,7 +116,37 @@ export const POST = withRouteContext(
|
||||
const paymentAmount = customLines
|
||||
? customLines.reduce((s, l) => s + l.debit_amount, 0)
|
||||
: remainingAmount
|
||||
const paidRounded = Math.round(paymentAmount * 100) / 100
|
||||
|
||||
// Unit contract: total / paid_amount / remaining_amount are stored in the
|
||||
// INVOICE currency (total_sek carries the SEK view of total); custom lines
|
||||
// are journal lines, so they are always SEK. The SEK amount therefore has
|
||||
// to be converted before it is compared against, or subtracted from, the
|
||||
// invoice-currency remaining. The default path (no lines) already pays the
|
||||
// remaining in invoice currency and needs no rate at all.
|
||||
const isForeignCurrency = !!invoice.currency && invoice.currency !== 'SEK'
|
||||
const needsFxConversion = isForeignCurrency && customLines !== undefined
|
||||
const fxRate =
|
||||
invoice.exchange_rate && invoice.exchange_rate > 0 ? invoice.exchange_rate : null
|
||||
if (needsFxConversion && fxRate === null) {
|
||||
// Never fall back to rate 1: that reads an 11 496,70 kr payment against a
|
||||
// 1 000 EUR invoice as 11 496,70 EUR and corrupts the AR sub-ledger.
|
||||
// Same code as buildInvoicePaymentClearingLines' refusal
|
||||
// (MATCH_INVOICE_BOOKING_RATE_MISSING, lib/bookkeeping/invoice-payment-lines.ts):
|
||||
// one condition, one code across every invoice-settlement surface.
|
||||
opLog.warn('mark-paid rejected: foreign-currency invoice without exchange rate', {
|
||||
invoiceId: id,
|
||||
currency: invoice.currency,
|
||||
})
|
||||
return errorResponseFromCode('MATCH_INVOICE_BOOKING_RATE_MISSING', opLog, {
|
||||
requestId,
|
||||
details: { invoice_id: id, currency: invoice.currency },
|
||||
})
|
||||
}
|
||||
const paymentAmountInInvoiceCurrency = needsFxConversion
|
||||
? roundOre(paymentAmount / fxRate!)
|
||||
: paymentAmount
|
||||
|
||||
const paidRounded = Math.round(paymentAmountInInvoiceCurrency * 100) / 100
|
||||
const remainingRounded = Math.round(remainingAmount * 100) / 100
|
||||
if (!force && paidRounded >= remainingRounded) {
|
||||
const customerName = (invoice as Invoice & { customer?: { name?: string } }).customer?.name
|
||||
@@ -126,10 +156,23 @@ export const POST = withRouteContext(
|
||||
invoiceId: id,
|
||||
})
|
||||
} else {
|
||||
// Invoice currency on purpose. The lookup scans transactions.amount,
|
||||
// which is denominated in the BANK ROW's currency, not necessarily
|
||||
// kronor; it therefore takes the payment in invoice currency plus the
|
||||
// invoice's stored conversion and bands each currency separately.
|
||||
// Handing it the raw SEK custom-line total would band a kronor figure
|
||||
// against a EUR column (and vice versa).
|
||||
const candidates = await findDuplicatePaymentCandidatesForInvoice(supabase, {
|
||||
companyId: companyId!,
|
||||
invoice: { invoice_number: invoice.invoice_number, customer_name: customerName },
|
||||
paymentAmount,
|
||||
invoice: {
|
||||
invoice_number: invoice.invoice_number,
|
||||
customer_name: customerName,
|
||||
currency: invoice.currency ?? null,
|
||||
total: invoice.total ?? null,
|
||||
total_sek: invoice.total_sek ?? null,
|
||||
exchange_rate: invoice.exchange_rate ?? null,
|
||||
},
|
||||
paymentAmount: paymentAmountInInvoiceCurrency,
|
||||
paymentDate,
|
||||
})
|
||||
if (candidates.length > 0) {
|
||||
@@ -157,18 +200,9 @@ export const POST = withRouteContext(
|
||||
const accountingMethod = settings?.accounting_method || 'accrual'
|
||||
const entityType = (settings?.entity_type as EntityType) || 'enskild_firma'
|
||||
|
||||
// paymentAmount and the duplicate-payment guard above operate in the
|
||||
// booking currency (SEK for custom lines); convert to invoice currency for
|
||||
// the ledger comparison so a foreign-currency invoice isn't falsely
|
||||
// rejected as overpaid.
|
||||
const fxRate =
|
||||
invoice.currency && invoice.currency !== 'SEK' && invoice.exchange_rate
|
||||
? invoice.exchange_rate
|
||||
: 1
|
||||
const paymentAmountInInvoiceCurrency = customLines
|
||||
? roundOre(paymentAmount / fxRate)
|
||||
: paymentAmount
|
||||
|
||||
// paymentAmountInInvoiceCurrency was resolved above, before the
|
||||
// duplicate-payment guard, so the guard comparison and the ledger math run
|
||||
// in the same unit as remaining_amount.
|
||||
const result = await settleInvoicePayment(supabase, companyId!, user.id, {
|
||||
invoice: invoice as Invoice & { customer?: { name?: string | null } | null },
|
||||
paymentAmountInInvoiceCurrency,
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { createMockRequest, parseJsonResponse, createMockRouteParams, makeInvoice } from '@/tests/helpers'
|
||||
|
||||
/**
|
||||
* Minimal recording Supabase mock: the queue behaviour of
|
||||
* createQueuedMockSupabase plus captured table/op/payload/filters, so the
|
||||
* "only the SEK columns are rewritten" contract can actually be asserted.
|
||||
*/
|
||||
interface QueryResult {
|
||||
data: unknown
|
||||
error: unknown
|
||||
}
|
||||
interface RecordedQuery {
|
||||
table: string
|
||||
op: 'select' | 'update' | 'insert' | 'upsert' | 'delete'
|
||||
payload?: unknown
|
||||
filters: Record<string, unknown>
|
||||
}
|
||||
|
||||
function createRecordingSupabase() {
|
||||
const queue: QueryResult[] = []
|
||||
const recorded: RecordedQuery[] = []
|
||||
|
||||
const enqueue = (r: { data?: unknown; error?: unknown }) =>
|
||||
queue.push({ data: r.data ?? null, error: r.error ?? null })
|
||||
const reset = () => {
|
||||
queue.length = 0
|
||||
recorded.length = 0
|
||||
}
|
||||
|
||||
const from = (table: string) => {
|
||||
const result = queue.shift() ?? { data: null, error: null }
|
||||
const rec: RecordedQuery = { table, op: 'select', filters: {} }
|
||||
recorded.push(rec)
|
||||
|
||||
const api: unknown = new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_target, prop) {
|
||||
if (prop === 'then') {
|
||||
return (onFulfilled: (v: QueryResult) => void) => onFulfilled(result)
|
||||
}
|
||||
return (...args: unknown[]) => {
|
||||
if (prop === 'update' || prop === 'insert' || prop === 'upsert') {
|
||||
rec.op = prop
|
||||
rec.payload = args[0]
|
||||
} else if (prop === 'delete') {
|
||||
rec.op = 'delete'
|
||||
} else if (prop === 'eq' || prop === 'is') {
|
||||
rec.filters[String(args[0])] = args[1]
|
||||
}
|
||||
return api
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
return api
|
||||
}
|
||||
|
||||
const supabase = {
|
||||
from: vi.fn(from),
|
||||
rpc: vi.fn(),
|
||||
auth: { getUser: vi.fn() },
|
||||
}
|
||||
|
||||
return { supabase, enqueue, reset, recorded }
|
||||
}
|
||||
|
||||
const { supabase: mockSupabase, enqueue, reset, recorded } = createRecordingSupabase()
|
||||
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: () => Promise.resolve(mockSupabase),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}))
|
||||
|
||||
const mockFetchExchangeRate = vi.fn()
|
||||
vi.mock('@/lib/currency/riksbanken', () => ({
|
||||
fetchExchangeRate: (...args: unknown[]) => mockFetchExchangeRate(...args),
|
||||
}))
|
||||
|
||||
const mockResolvePeriodStatus = vi.fn()
|
||||
vi.mock('@/lib/core/bookkeeping/period-service', () => ({
|
||||
resolvePeriodStatusForDate: (...args: unknown[]) => mockResolvePeriodStatus(...args),
|
||||
}))
|
||||
|
||||
import { POST } from '../route'
|
||||
|
||||
const INVOICE_ID = '11111111-2222-4333-8444-555555555555'
|
||||
|
||||
const sentEurInvoice = makeInvoice({
|
||||
id: INVOICE_ID,
|
||||
status: 'sent',
|
||||
invoice_number: 'F-2026001',
|
||||
currency: 'EUR',
|
||||
invoice_date: '2026-06-15',
|
||||
delivery_date: null,
|
||||
subtotal: 1000,
|
||||
vat_amount: 0,
|
||||
total: 1000,
|
||||
subtotal_sek: null,
|
||||
vat_amount_sek: null,
|
||||
total_sek: null,
|
||||
exchange_rate: null,
|
||||
exchange_rate_date: null,
|
||||
journal_entry_id: null,
|
||||
})
|
||||
|
||||
function post(id = INVOICE_ID) {
|
||||
const request = createMockRequest(`/api/invoices/${id}/refresh-exchange-rate`, { method: 'POST' })
|
||||
return POST(request, createMockRouteParams({ id }))
|
||||
}
|
||||
|
||||
describe('POST /api/invoices/[id]/refresh-exchange-rate', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: { id: 'user-1' } } })
|
||||
mockResolvePeriodStatus.mockResolvedValue({ period_id: 'fp-1', status: 'open', lock_date: null })
|
||||
mockFetchExchangeRate.mockResolvedValue({ currency: 'EUR', rate: 11.5, date: '2026-06-15' })
|
||||
})
|
||||
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
|
||||
|
||||
const { status, body } = await parseJsonResponse(await post())
|
||||
|
||||
expect(status).toBe(401)
|
||||
expect(body).toEqual({ error: 'Unauthorized' })
|
||||
})
|
||||
|
||||
it('returns 400 when the invoice id is not a uuid', async () => {
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
|
||||
await post('not-a-uuid'),
|
||||
)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('VALIDATION_ERROR')
|
||||
// Rejected before any DB work, including the sandbox lookup.
|
||||
expect(mockSupabase.from).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 404 when the invoice does not exist for the company', async () => {
|
||||
enqueue({ data: { is_sandbox: false } })
|
||||
enqueue({ data: null, error: { message: 'Not found' } })
|
||||
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await post())
|
||||
|
||||
expect(status).toBe(404)
|
||||
expect(body.error.code).toBe('INVOICE_NOT_FOUND')
|
||||
})
|
||||
|
||||
it('fills in the taxable-event rate on an unbooked SENT invoice without touching its currency amounts', async () => {
|
||||
enqueue({ data: { is_sandbox: false } })
|
||||
enqueue({ data: sentEurInvoice })
|
||||
enqueue({ data: [] }) // journal_entries: nothing references the invoice
|
||||
enqueue({
|
||||
data: [{ ...sentEurInvoice, exchange_rate: 11.5, exchange_rate_date: '2026-06-15', total_sek: 11500 }],
|
||||
})
|
||||
|
||||
const { status, body } = await parseJsonResponse<{ data: { exchange_rate: number } }>(await post())
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.exchange_rate).toBe(11.5)
|
||||
|
||||
// The rate is fetched for the invoice date (the taxable event here, since
|
||||
// delivery_date is null) and WITH the supabase client, so the shared
|
||||
// exchange_rates cache backs both the read-through and the 429 fallback.
|
||||
expect(mockFetchExchangeRate).toHaveBeenCalledTimes(1)
|
||||
const [currency, date, client] = mockFetchExchangeRate.mock.calls[0]
|
||||
expect(currency).toBe('EUR')
|
||||
expect((date as Date).toISOString().slice(0, 10)).toBe('2026-06-15')
|
||||
expect(client).toBe(mockSupabase)
|
||||
|
||||
const update = recorded.find((q) => q.op === 'update')
|
||||
expect(update?.table).toBe('invoices')
|
||||
expect(update?.payload).toEqual({
|
||||
exchange_rate: 11.5,
|
||||
exchange_rate_date: '2026-06-15',
|
||||
subtotal_sek: 11500,
|
||||
vat_amount_sek: 0,
|
||||
total_sek: 11500,
|
||||
updated_at: expect.any(String),
|
||||
})
|
||||
// The debt stays denominated in the invoice currency.
|
||||
const payloadKeys = Object.keys(update?.payload as Record<string, unknown>)
|
||||
expect(payloadKeys).not.toContain('subtotal')
|
||||
expect(payloadKeys).not.toContain('vat_amount')
|
||||
expect(payloadKeys).not.toContain('total')
|
||||
expect(payloadKeys).not.toContain('remaining_amount')
|
||||
expect(payloadKeys).not.toContain('currency')
|
||||
// TOCTOU guard: the write loses to a concurrent send/book.
|
||||
expect(update?.filters).toMatchObject({ journal_entry_id: null, company_id: 'company-1' })
|
||||
})
|
||||
|
||||
it('uses delivery_date as the rate date when it differs from the invoice date', async () => {
|
||||
enqueue({ data: { is_sandbox: false } })
|
||||
enqueue({ data: { ...sentEurInvoice, delivery_date: '2026-05-20' } })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: [sentEurInvoice] })
|
||||
|
||||
await post()
|
||||
|
||||
const [, date] = mockFetchExchangeRate.mock.calls[0]
|
||||
expect((date as Date).toISOString().slice(0, 10)).toBe('2026-05-20')
|
||||
})
|
||||
|
||||
it('refuses a booked invoice and points at the rättelse tracks', async () => {
|
||||
enqueue({ data: { is_sandbox: false } })
|
||||
enqueue({ data: { ...sentEurInvoice, journal_entry_id: 'je-1' } })
|
||||
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await post())
|
||||
|
||||
expect(status).toBe(409)
|
||||
expect(body.error.code).toBe('INVOICE_FX_REFRESH_BOOKED')
|
||||
// Nothing was fetched or written: the SEK amounts are in a verifikat.
|
||||
expect(mockFetchExchangeRate).not.toHaveBeenCalled()
|
||||
expect(recorded.some((q) => q.op === 'update')).toBe(false)
|
||||
})
|
||||
|
||||
it('refuses when a verifikat references the invoice even though journal_entry_id is null (legacy rows)', async () => {
|
||||
enqueue({ data: { is_sandbox: false } })
|
||||
enqueue({ data: sentEurInvoice })
|
||||
enqueue({ data: [{ id: 'je-9', voucher_series: 'A', voucher_number: 17 }] })
|
||||
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await post())
|
||||
|
||||
expect(status).toBe(409)
|
||||
expect(body.error.code).toBe('INVOICE_FX_REFRESH_BOOKED')
|
||||
expect(recorded.some((q) => q.op === 'update')).toBe(false)
|
||||
})
|
||||
|
||||
it('refuses when the fiscal period covering the invoice date is locked', async () => {
|
||||
mockResolvePeriodStatus.mockResolvedValue({
|
||||
period_id: 'fp-1',
|
||||
status: 'locked',
|
||||
lock_date: '2026-06-30',
|
||||
})
|
||||
enqueue({ data: { is_sandbox: false } })
|
||||
enqueue({ data: sentEurInvoice })
|
||||
enqueue({ data: [] })
|
||||
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await post())
|
||||
|
||||
expect(status).toBe(409)
|
||||
expect(body.error.code).toBe('INVOICE_FX_REFRESH_PERIOD_LOCKED')
|
||||
expect(mockResolvePeriodStatus).toHaveBeenCalledWith(mockSupabase, 'company-1', '2026-06-15')
|
||||
expect(mockFetchExchangeRate).not.toHaveBeenCalled()
|
||||
expect(recorded.some((q) => q.op === 'update')).toBe(false)
|
||||
})
|
||||
|
||||
it('refuses fail-closed when the period lock state could not be read', async () => {
|
||||
mockResolvePeriodStatus.mockResolvedValue({
|
||||
period_id: null,
|
||||
status: 'locked',
|
||||
lock_date: null,
|
||||
lookup_failed: true,
|
||||
})
|
||||
enqueue({ data: { is_sandbox: false } })
|
||||
enqueue({ data: sentEurInvoice })
|
||||
enqueue({ data: [] })
|
||||
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
error: { code: string; details: { lookup_failed: boolean } }
|
||||
}>(await post())
|
||||
|
||||
expect(status).toBe(409)
|
||||
expect(body.error.code).toBe('INVOICE_FX_REFRESH_PERIOD_LOCKED')
|
||||
expect(body.error.details.lookup_failed).toBe(true)
|
||||
})
|
||||
|
||||
it('returns 502 and writes nothing when Riksbanken and the cache both fail', async () => {
|
||||
mockFetchExchangeRate.mockResolvedValue(null)
|
||||
enqueue({ data: { is_sandbox: false } })
|
||||
enqueue({ data: sentEurInvoice })
|
||||
enqueue({ data: [] })
|
||||
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await post())
|
||||
|
||||
expect(status).toBe(502)
|
||||
expect(body.error.code).toBe('INVOICE_FX_REFRESH_RATE_UNAVAILABLE')
|
||||
// Never an invented rate: the invoice is left untouched and retryable.
|
||||
expect(recorded.some((q) => q.op === 'update')).toBe(false)
|
||||
})
|
||||
|
||||
it('is a no-op for a SEK invoice', async () => {
|
||||
enqueue({ data: { is_sandbox: false } })
|
||||
enqueue({ data: makeInvoice({ id: INVOICE_ID, status: 'sent', currency: 'SEK' }) })
|
||||
|
||||
const { status } = await parseJsonResponse(await post())
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(mockFetchExchangeRate).not.toHaveBeenCalled()
|
||||
expect(recorded.some((q) => q.op === 'update')).toBe(false)
|
||||
})
|
||||
|
||||
it('writes nothing when the stored rate is already the taxable-event rate', async () => {
|
||||
enqueue({ data: { is_sandbox: false } })
|
||||
enqueue({
|
||||
data: { ...sentEurInvoice, exchange_rate: 11.5, exchange_rate_date: '2026-06-15' },
|
||||
})
|
||||
enqueue({ data: [] })
|
||||
|
||||
const { status } = await parseJsonResponse(await post())
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(recorded.some((q) => q.op === 'update')).toBe(false)
|
||||
})
|
||||
|
||||
it('reports the concurrent-booking race instead of silently succeeding', async () => {
|
||||
enqueue({ data: { is_sandbox: false } })
|
||||
enqueue({ data: sentEurInvoice })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: [] }) // update matched 0 rows: journal_entry_id was set meanwhile
|
||||
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await post())
|
||||
|
||||
expect(status).toBe(409)
|
||||
expect(body.error.code).toBe('INVOICE_FX_REFRESH_BOOKED')
|
||||
})
|
||||
|
||||
it('reverts the update with the prior values when a verifikat appears between the guard and the write', async () => {
|
||||
// TOCTOU: a booking flow that read the invoice at the OLD rate commits
|
||||
// its journal entry AFTER the source_id guard but BEFORE journal_entry_id
|
||||
// is stamped on the invoice. The route must detect the entry on the
|
||||
// post-update recheck, put the previous rate/SEK values back via a CAS on
|
||||
// what it just wrote, and answer with the booked conflict.
|
||||
const previouslyRated = {
|
||||
...sentEurInvoice,
|
||||
exchange_rate: 11.2,
|
||||
exchange_rate_date: '2026-06-10',
|
||||
subtotal_sek: 11200,
|
||||
vat_amount_sek: 0,
|
||||
total_sek: 11200,
|
||||
}
|
||||
enqueue({ data: { is_sandbox: false } })
|
||||
enqueue({ data: previouslyRated })
|
||||
enqueue({ data: [] }) // pre-write guard: no entry references the invoice yet
|
||||
enqueue({ data: [{ ...previouslyRated, exchange_rate: 11.5 }] }) // guarded update succeeds
|
||||
enqueue({ data: [{ id: 'je-race' }] }) // post-update recheck: an entry appeared
|
||||
enqueue({ data: [] }) // revert update
|
||||
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
error: { code: string; details: { journal_entry_id: string; reason: string } }
|
||||
}>(await post())
|
||||
|
||||
expect(status).toBe(409)
|
||||
expect(body.error.code).toBe('INVOICE_FX_REFRESH_BOOKED')
|
||||
expect(body.error.details.journal_entry_id).toBe('je-race')
|
||||
expect(body.error.details.reason).toBe('booked_concurrently_reverted')
|
||||
|
||||
const updates = recorded.filter((q) => q.op === 'update')
|
||||
expect(updates).toHaveLength(2)
|
||||
// The revert restores exactly the values read before the update.
|
||||
expect(updates[1].table).toBe('invoices')
|
||||
expect(updates[1].payload).toEqual({
|
||||
exchange_rate: 11.2,
|
||||
exchange_rate_date: '2026-06-10',
|
||||
subtotal_sek: 11200,
|
||||
vat_amount_sek: 0,
|
||||
total_sek: 11200,
|
||||
updated_at: expect.any(String),
|
||||
})
|
||||
// CAS: the revert only touches the row if it still holds the values this
|
||||
// request wrote, so a later legitimate write is never clobbered.
|
||||
expect(updates[1].filters).toMatchObject({
|
||||
id: INVOICE_ID,
|
||||
company_id: 'company-1',
|
||||
exchange_rate: 11.5,
|
||||
exchange_rate_date: '2026-06-15',
|
||||
})
|
||||
})
|
||||
|
||||
it('does not revert when the post-update recheck finds no verifikat', async () => {
|
||||
enqueue({ data: { is_sandbox: false } })
|
||||
enqueue({ data: sentEurInvoice })
|
||||
enqueue({ data: [] }) // pre-write guard
|
||||
enqueue({ data: [{ ...sentEurInvoice, exchange_rate: 11.5 }] }) // update
|
||||
enqueue({ data: [] }) // recheck: still unbooked
|
||||
|
||||
const { status } = await parseJsonResponse(await post())
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(recorded.filter((q) => q.op === 'update')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('is blocked in the sandbox', async () => {
|
||||
enqueue({ data: { is_sandbox: true } })
|
||||
|
||||
const { status, body } = await parseJsonResponse<{ sandbox_blocked: boolean }>(await post())
|
||||
|
||||
expect(status).toBe(403)
|
||||
expect(body.sandbox_blocked).toBe(true)
|
||||
expect(mockFetchExchangeRate).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,263 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { fetchExchangeRate } from '@/lib/currency/riksbanken'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import { resolvePeriodStatusForDate } from '@/lib/core/bookkeeping/period-service'
|
||||
import { guardSandbox } from '@/lib/sandbox/guard'
|
||||
import type { Currency, Invoice } from '@/types'
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
|
||||
/**
|
||||
* POST /api/invoices/[id]/refresh-exchange-rate
|
||||
*
|
||||
* Repair path for a foreign-currency invoice whose SEK conversion is missing
|
||||
* or was stamped from the wrong day's rate. The invoice twin of
|
||||
* /api/transactions/[id]/refresh-exchange-rate.
|
||||
*
|
||||
* Why it exists: buildInvoiceWriteData fetches the Riksbanken rate at create
|
||||
* time. One transient 429 there used to leave exchange_rate NULL forever, and
|
||||
* resolveSekAmount() then books the raw foreign number as if it were kronor
|
||||
* (1 000 EUR posted as 1 000 kr on 1510/2611). PATCH /api/invoices/[id] cannot
|
||||
* fix it: that route only accepts drafts, and the invoice is already sent.
|
||||
* Several structured errors (MATCH_INVOICE_BOOKING_RATE_MISSING,
|
||||
* BATCH_FX_RATE_MISSING) tell the user to "komplettera fakturans exchange_rate"
|
||||
* and this is the endpoint that actually does it.
|
||||
*
|
||||
* What it changes: only exchange_rate, exchange_rate_date and the three *_sek
|
||||
* columns. The invoice's own currency amounts (subtotal / vat_amount / total /
|
||||
* remaining_amount) are never touched: the customer's debt is denominated in
|
||||
* the invoice currency and re-rating it would silently reprice a sent invoice.
|
||||
*
|
||||
* Which rate: the one at the TAXABLE EVENT, per ML 8 kap 21-23 § ("Rate to
|
||||
* use: at time of taxable event (delivery/supply date or advance payment date,
|
||||
* not invoice date unless same)"). delivery_date when set, otherwise
|
||||
* invoice_date, which is the same day by definition when delivery_date is null
|
||||
* (ML 17 kap 24 § p.7 requires the delivery date on the invoice precisely when
|
||||
* the two differ).
|
||||
*
|
||||
* Booked invoices are REFUSED, never re-rated. Once the invoice has a
|
||||
* verifikat, the SEK amounts are bokförda poster: changing them is a rättelse
|
||||
* of a posted entry and BFL 5 kap 5 § allows exactly two tracks for that
|
||||
* (storno via reverseEntry/correctEntry, or the inline rättelse RPCs). A
|
||||
* silent UPDATE of the invoice row behind a committed verifikat would also
|
||||
* desync the invoice from the ledger it produced, which is the worse failure:
|
||||
* the reports would keep showing the old, wrong SEK value.
|
||||
*/
|
||||
export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
'invoice.refreshExchangeRate',
|
||||
async (_request, { supabase, companyId, log, requestId }, { params }) => {
|
||||
const { id } = await params
|
||||
|
||||
// Defense in depth: a malformed id would otherwise reach Postgres as an
|
||||
// invalid uuid literal (22P02) and surface as a confusing 404.
|
||||
if (!UUID_RE.test(id)) {
|
||||
return errorResponseFromCode('VALIDATION_ERROR', log, {
|
||||
requestId,
|
||||
details: { field: 'id', reason: 'not_a_uuid' },
|
||||
})
|
||||
}
|
||||
|
||||
// Riksbanken is a gated external service in the sandbox.
|
||||
const blocked = await guardSandbox(supabase, companyId)
|
||||
if (blocked) return blocked
|
||||
|
||||
// The *_sek columns are projected solely so the post-update compensation
|
||||
// below can restore the exact prior values if a concurrent booking wins.
|
||||
const { data: invoice, error: fetchError } = await supabase
|
||||
.from('invoices')
|
||||
.select(
|
||||
'id, status, currency, invoice_date, delivery_date, subtotal, vat_amount, total, exchange_rate, exchange_rate_date, subtotal_sek, vat_amount_sek, total_sek, journal_entry_id',
|
||||
)
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.single<
|
||||
Pick<
|
||||
Invoice,
|
||||
| 'id'
|
||||
| 'status'
|
||||
| 'currency'
|
||||
| 'invoice_date'
|
||||
| 'delivery_date'
|
||||
| 'subtotal'
|
||||
| 'vat_amount'
|
||||
| 'total'
|
||||
| 'exchange_rate'
|
||||
| 'exchange_rate_date'
|
||||
| 'subtotal_sek'
|
||||
| 'vat_amount_sek'
|
||||
| 'total_sek'
|
||||
| 'journal_entry_id'
|
||||
>
|
||||
>()
|
||||
|
||||
if (fetchError || !invoice) {
|
||||
return errorResponseFromCode('INVOICE_NOT_FOUND', log, { requestId })
|
||||
}
|
||||
|
||||
// A SEK invoice has no conversion to repair.
|
||||
if (invoice.currency === 'SEK') {
|
||||
return NextResponse.json({ data: invoice })
|
||||
}
|
||||
|
||||
// Guard 1: already booked → the SEK values live in a verifikat.
|
||||
// invoice.journal_entry_id is the primary link, but it was backfilled late
|
||||
// (see mark-sent) so older booked rows can still carry NULL. The
|
||||
// journal_entries lookup by source_id is the authoritative check.
|
||||
if (invoice.journal_entry_id) {
|
||||
return errorResponseFromCode('INVOICE_FX_REFRESH_BOOKED', log, {
|
||||
requestId,
|
||||
details: { invoice_id: id, journal_entry_id: invoice.journal_entry_id },
|
||||
})
|
||||
}
|
||||
|
||||
const { data: linkedEntries, error: entriesError } = await supabase
|
||||
.from('journal_entries')
|
||||
.select('id, voucher_series, voucher_number')
|
||||
.eq('company_id', companyId)
|
||||
.eq('source_id', id)
|
||||
.limit(1)
|
||||
|
||||
if (entriesError) {
|
||||
// Fail closed: an unknown booking state must not authorise a re-rate.
|
||||
log.error('invoice fx refresh: journal entry lookup failed', entriesError, { invoiceId: id })
|
||||
return errorResponse(entriesError, log, { requestId })
|
||||
}
|
||||
if (linkedEntries && linkedEntries.length > 0) {
|
||||
return errorResponseFromCode('INVOICE_FX_REFRESH_BOOKED', log, {
|
||||
requestId,
|
||||
details: { invoice_id: id, journal_entry_id: linkedEntries[0].id },
|
||||
})
|
||||
}
|
||||
|
||||
// Guard 2: period locks. The invoice's verifikat is dated invoice_date
|
||||
// (invoice-entries.ts), so that is the period this repair feeds. Behind a
|
||||
// lock/close the whole räkenskapsinformation for the period is frozen and
|
||||
// the only route is storno. resolvePeriodStatusForDate fails CLOSED: a
|
||||
// failed lookup reports `locked`, which is the answer we want here too.
|
||||
const periodStatus = await resolvePeriodStatusForDate(supabase, companyId, invoice.invoice_date)
|
||||
if (periodStatus.status !== 'open') {
|
||||
return errorResponseFromCode('INVOICE_FX_REFRESH_PERIOD_LOCKED', log, {
|
||||
requestId,
|
||||
details: {
|
||||
invoice_id: id,
|
||||
date: invoice.invoice_date,
|
||||
period_status: periodStatus.status,
|
||||
lock_date: periodStatus.lock_date,
|
||||
lookup_failed: periodStatus.lookup_failed ?? false,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const rateDate = invoice.delivery_date || invoice.invoice_date
|
||||
const rate = await fetchExchangeRate(
|
||||
invoice.currency as Currency,
|
||||
new Date(rateDate),
|
||||
supabase,
|
||||
)
|
||||
if (!rate) {
|
||||
return errorResponseFromCode('INVOICE_FX_REFRESH_RATE_UNAVAILABLE', log, {
|
||||
requestId,
|
||||
details: { invoice_id: id, currency: invoice.currency, date: rateDate },
|
||||
})
|
||||
}
|
||||
|
||||
// Idempotent: nothing to write when the stored rate already is the
|
||||
// taxable-event rate.
|
||||
if (invoice.exchange_rate === rate.rate && invoice.exchange_rate_date === rate.date) {
|
||||
return NextResponse.json({ data: invoice })
|
||||
}
|
||||
|
||||
const toSek = (amount: number) => roundOre(amount * rate.rate)
|
||||
|
||||
const { data: updated, error: updateError } = await supabase
|
||||
.from('invoices')
|
||||
.update({
|
||||
exchange_rate: rate.rate,
|
||||
exchange_rate_date: rate.date,
|
||||
subtotal_sek: toSek(invoice.subtotal),
|
||||
vat_amount_sek: toSek(invoice.vat_amount),
|
||||
total_sek: toSek(invoice.total),
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
// TOCTOU: a concurrent send/book between the guard above and this write
|
||||
// must lose, not silently re-rate a now-posted invoice.
|
||||
.is('journal_entry_id', null)
|
||||
.select('*')
|
||||
|
||||
if (updateError) {
|
||||
log.error('invoice fx refresh: persist failed', updateError, { invoiceId: id })
|
||||
return errorResponse(updateError, log, { requestId })
|
||||
}
|
||||
if (!updated || updated.length === 0) {
|
||||
return errorResponseFromCode('INVOICE_FX_REFRESH_BOOKED', log, {
|
||||
requestId,
|
||||
details: { invoice_id: id, reason: 'booked_concurrently' },
|
||||
})
|
||||
}
|
||||
|
||||
// TOCTOU compensation. The `.is('journal_entry_id', null)` CAS above only
|
||||
// covers the invoice ROW, but a booking flow that read the invoice at the
|
||||
// OLD rate can commit its journal entry after our journal_entries check
|
||||
// and before invoices.journal_entry_id is stamped: the verifikat then
|
||||
// posts at the old rate while the row now stores the new one, the exact
|
||||
// divergence this route exists to prevent. Re-check by source_id; if an
|
||||
// entry appeared, restore the prior values via a CAS on the values this
|
||||
// request just wrote (never clobbering a later legitimate write) and
|
||||
// report the booked conflict. A perfect fix needs DB-level serialization
|
||||
// (out of scope); this shrinks the race window from the whole request to
|
||||
// the few milliseconds between this SELECT and the revert.
|
||||
const { data: postEntries, error: postCheckError } = await supabase
|
||||
.from('journal_entries')
|
||||
.select('id')
|
||||
.eq('company_id', companyId)
|
||||
.eq('source_id', id)
|
||||
.limit(1)
|
||||
|
||||
if (postCheckError) {
|
||||
// Cannot tell whether a booking won the race: leave the update in place
|
||||
// (same residual risk as before this compensation existed) but make the
|
||||
// blind spot visible in logs.
|
||||
log.warn('invoice fx refresh: post-update booking recheck failed', {
|
||||
invoiceId: id,
|
||||
error: postCheckError.message,
|
||||
})
|
||||
} else if (postEntries && postEntries.length > 0) {
|
||||
const { error: revertError } = await supabase
|
||||
.from('invoices')
|
||||
.update({
|
||||
exchange_rate: invoice.exchange_rate,
|
||||
exchange_rate_date: invoice.exchange_rate_date,
|
||||
subtotal_sek: invoice.subtotal_sek ?? null,
|
||||
vat_amount_sek: invoice.vat_amount_sek ?? null,
|
||||
total_sek: invoice.total_sek ?? null,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
// CAS on exactly what this request wrote: if someone else already
|
||||
// changed the rate again, their write wins and nothing is reverted.
|
||||
.eq('exchange_rate', rate.rate)
|
||||
.eq('exchange_rate_date', rate.date)
|
||||
if (revertError) {
|
||||
log.error('invoice fx refresh: revert after concurrent booking failed', revertError, {
|
||||
invoiceId: id,
|
||||
})
|
||||
}
|
||||
return errorResponseFromCode('INVOICE_FX_REFRESH_BOOKED', log, {
|
||||
requestId,
|
||||
details: {
|
||||
invoice_id: id,
|
||||
journal_entry_id: (postEntries[0] as { id: string }).id,
|
||||
reason: 'booked_concurrently_reverted',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: updated[0] })
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
@@ -7,6 +7,7 @@ import { validateBody } from '@/lib/api/validate'
|
||||
import { UpdateInvoiceSchema } from '@/lib/api/schemas'
|
||||
import { buildInvoiceWriteData } from '@/lib/invoices/build-invoice-write'
|
||||
import { isEditableInvoiceDraft } from '@/lib/invoices/is-editable-draft'
|
||||
import { replaceInvoiceItems } from '@/lib/invoices/replace-invoice-items'
|
||||
import type { InvoiceDocumentType } from '@/types'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
|
||||
@@ -237,30 +238,17 @@ export const PATCH = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
return errorResponseFromCode('INVOICE_UPDATE_NOT_DRAFT', ctxLog, { requestId })
|
||||
}
|
||||
|
||||
// Replace line items wholesale. A draft has no journal entry or linked docs,
|
||||
// so delete + reinsert is safe and lets the user add / remove / reorder rows
|
||||
// freely. invoice_items cascade nothing else.
|
||||
const { error: deleteItemsError } = await supabase
|
||||
.from('invoice_items')
|
||||
.delete()
|
||||
.eq('invoice_id', id)
|
||||
|
||||
if (deleteItemsError) {
|
||||
ctxLog.error('invoice items delete failed on update', deleteItemsError, { invoiceId: id })
|
||||
// Replace line items wholesale (shared helper: the v1 REST route and the
|
||||
// update_invoice commit executor use the same delete + reinsert). A draft
|
||||
// has no journal entry or linked docs, so full replace is safe and lets
|
||||
// the user add / remove / reorder rows freely. invoice_items cascade
|
||||
// nothing else.
|
||||
const replaced = await replaceInvoiceItems(supabase, id, build.items)
|
||||
if (!replaced.ok) {
|
||||
ctxLog.error(`invoice items ${replaced.stage} failed on update`, replaced.error, { invoiceId: id })
|
||||
return errorResponseFromCode('INVOICE_CREATE_ITEMS_FAILED', ctxLog, {
|
||||
requestId,
|
||||
details: { pgCode: deleteItemsError.code, pgMessage: getUserErrorMessage(deleteItemsError) },
|
||||
})
|
||||
}
|
||||
|
||||
const itemsToInsert = build.items.map((item) => ({ ...item, invoice_id: id }))
|
||||
const { error: itemsError } = await supabase.from('invoice_items').insert(itemsToInsert)
|
||||
|
||||
if (itemsError) {
|
||||
ctxLog.error('invoice items insert failed on update', itemsError, { invoiceId: id })
|
||||
return errorResponseFromCode('INVOICE_CREATE_ITEMS_FAILED', ctxLog, {
|
||||
requestId,
|
||||
details: { pgCode: itemsError.code, pgMessage: getUserErrorMessage(itemsError) },
|
||||
details: { pgCode: replaced.error.code, pgMessage: getUserErrorMessage(replaced.error) },
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,9 @@ vi.mock('@/lib/invoices/vat-rules', () => ({
|
||||
getVatRules: (...args: unknown[]) => mockGetVatRules(...args),
|
||||
calculateVat: (...args: unknown[]) => mockCalculateVat(...args),
|
||||
getAvailableVatRates: (...args: unknown[]) => mockGetAvailableVatRates(...args),
|
||||
// The builder gates on the permitted set (taxed-where-performed exceptions);
|
||||
// these route tests only care that the gate reads the stubbed rates.
|
||||
getPermittedVatRates: (...args: unknown[]) => mockGetAvailableVatRates(...args),
|
||||
calculateTotal: vi.fn(),
|
||||
}))
|
||||
|
||||
@@ -413,6 +416,42 @@ describe('POST /api/invoices (create credit note)', () => {
|
||||
expect((body.error as unknown as { code: string }).code).toBe('INVOICE_CREDIT_NOT_SENT')
|
||||
})
|
||||
|
||||
it('returns 400 when invoice is cancelled', async () => {
|
||||
const original = makeInvoice({ id: VALID_UUID, status: 'cancelled' })
|
||||
enqueue({ data: original, error: null })
|
||||
|
||||
const request = createMockRequest('/api/invoices', {
|
||||
method: 'POST',
|
||||
body: { credited_invoice_id: VALID_UUID },
|
||||
})
|
||||
const response = await POST(request)
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect((body.error as unknown as { code: string }).code).toBe('INVOICE_CREDIT_NOT_SENT')
|
||||
})
|
||||
|
||||
// Documents a KNOWN GAP, not a rule. ML (2023:200) 17 kap 22-23 SS permits an
|
||||
// aendringsfaktura against a part-paid invoice; the app refuses it because
|
||||
// issueCreditNote() cannot flip a 'partially_paid' original to 'credited' and
|
||||
// would strand a posted reversing verifikat (see the comment on the guard in
|
||||
// route.ts and the DECISIONS.md entry). This test exists so lifting the gap
|
||||
// fails here and forces the coordinated change rather than passing silently.
|
||||
it('refuses a partially paid invoice (known gap: needs issue-credit-note.ts too)', async () => {
|
||||
const original = makeInvoice({ id: VALID_UUID, status: 'partially_paid' })
|
||||
enqueue({ data: original, error: null })
|
||||
|
||||
const request = createMockRequest('/api/invoices', {
|
||||
method: 'POST',
|
||||
body: { credited_invoice_id: VALID_UUID },
|
||||
})
|
||||
const response = await POST(request)
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect((body.error as unknown as { code: string }).code).toBe('INVOICE_CREDIT_NOT_SENT')
|
||||
})
|
||||
|
||||
it('creates a credit note draft without booking or crediting the original', async () => {
|
||||
const items = [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
/**
|
||||
* The public reminder action endpoint feeds /invoice-action/[token], an
|
||||
* unauthenticated page that tells a customer what to pay. The statutory
|
||||
* påminnelseavgift (Lag 1981:739) is a krona amount booked 1510/3990 in SEK, so
|
||||
* it must never be relabelled as the invoice currency nor summed into a
|
||||
* foreign-currency total.
|
||||
*/
|
||||
|
||||
let queued: { data: unknown; error: unknown } = { data: null, error: null }
|
||||
|
||||
vi.mock('@supabase/ssr', () => {
|
||||
const buildChain = (): unknown =>
|
||||
new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_t, prop) {
|
||||
if (prop === 'then') {
|
||||
return (resolve: (v: unknown) => void) => resolve(queued)
|
||||
}
|
||||
return () => buildChain()
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
createServerClient: vi.fn(() => ({
|
||||
from: vi.fn(() => buildChain()),
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
import { GET } from '../route'
|
||||
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
|
||||
|
||||
interface ActionPayload {
|
||||
currency: string
|
||||
total: number
|
||||
interestAmount: number
|
||||
reminderFee: number
|
||||
reminderFeeCurrency: string
|
||||
totalDue: number
|
||||
feeDueSeparately: number
|
||||
}
|
||||
|
||||
function makeReminderRow(currency: string, total: number) {
|
||||
return {
|
||||
id: 'reminder-1',
|
||||
reminder_level: 1,
|
||||
sent_at: '2026-05-20T08:00:00Z',
|
||||
response_type: null,
|
||||
action_token_used: false,
|
||||
interest_amount: 10,
|
||||
interest_rate: 0.105,
|
||||
interest_from_date: '2026-05-01',
|
||||
interest_days: 30,
|
||||
reminder_fee: 60,
|
||||
invoice: {
|
||||
id: 'invoice-1',
|
||||
invoice_number: 'F2026011',
|
||||
invoice_date: '2026-04-15',
|
||||
due_date: '2026-05-01',
|
||||
total,
|
||||
currency,
|
||||
status: 'overdue',
|
||||
customer: { name: 'Erik Andersson' },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function callGet() {
|
||||
const response = await GET(
|
||||
createMockRequest('/api/invoices/reminders/action', {
|
||||
searchParams: { token: 'tok-1' },
|
||||
}),
|
||||
)
|
||||
return parseJsonResponse<ActionPayload>(response)
|
||||
}
|
||||
|
||||
describe('GET /api/invoices/reminders/action', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('returns 400 without a token', async () => {
|
||||
queued = { data: null, error: null }
|
||||
const response = await GET(createMockRequest('/api/invoices/reminders/action'))
|
||||
expect(response.status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 404 for an unknown token', async () => {
|
||||
queued = { data: null, error: { message: 'not found' } }
|
||||
const { status } = await callGet()
|
||||
expect(status).toBe(404)
|
||||
})
|
||||
|
||||
it('folds the fee into the total for a SEK invoice (unchanged behaviour)', async () => {
|
||||
queued = { data: makeReminderRow('SEK', 10_000), error: null }
|
||||
const { status, body } = await callGet()
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.currency).toBe('SEK')
|
||||
expect(body.reminderFee).toBe(60)
|
||||
expect(body.reminderFeeCurrency).toBe('SEK')
|
||||
expect(body.totalDue).toBe(10_070)
|
||||
expect(body.feeDueSeparately).toBe(0)
|
||||
})
|
||||
|
||||
it('never sums the SEK fee into a EUR total', async () => {
|
||||
queued = { data: makeReminderRow('EUR', 1_000), error: null }
|
||||
const { status, body } = await callGet()
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.currency).toBe('EUR')
|
||||
// 1000 EUR + 10 EUR interest. The 60 kr fee stays out of the EUR figure:
|
||||
// 1070 here would demand roughly 690 kr too much on a public page.
|
||||
expect(body.totalDue).toBe(1_010)
|
||||
expect(body.feeDueSeparately).toBe(60)
|
||||
expect(body.reminderFeeCurrency).toBe('SEK')
|
||||
})
|
||||
|
||||
it('always reports the fee currency as SEK so the page cannot label 60 as EUR', async () => {
|
||||
queued = { data: makeReminderRow('USD', 500), error: null }
|
||||
const { body } = await callGet()
|
||||
|
||||
expect(body.reminderFeeCurrency).toBe('SEK')
|
||||
expect(body.reminderFee).toBe(60)
|
||||
expect(body.totalDue).toBe(510)
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,9 @@
|
||||
import { createServerClient } from '@supabase/ssr'
|
||||
import { NextResponse } from 'next/server'
|
||||
import {
|
||||
calculateReminderAmounts,
|
||||
REMINDER_FEE_CURRENCY,
|
||||
} from '@/lib/email/reminder-templates'
|
||||
|
||||
// Create a service client (no auth needed - public endpoint with token validation)
|
||||
function createServiceClient() {
|
||||
@@ -166,8 +170,18 @@ export async function GET(request: Request) {
|
||||
|
||||
const interestAmount = Number(reminder.interest_amount ?? 0)
|
||||
const reminderFee = Number(reminder.reminder_fee ?? 0)
|
||||
const totalDue =
|
||||
Math.round((Number(invoice.total) + interestAmount + reminderFee) * 100) / 100
|
||||
|
||||
// The invoice total and the dröjsmålsränta are in the invoice currency; the
|
||||
// påminnelseavgift is a statutory SEK amount booked 1510/3990 in SEK. They are
|
||||
// split per currency instead of summed into one scalar: adding 60 kr to a EUR
|
||||
// total, or relabelling it as 60 EUR, would demand the wrong money from the
|
||||
// customer on a public page.
|
||||
const amounts = calculateReminderAmounts({
|
||||
invoiceTotal: Number(invoice.total),
|
||||
interestAmount,
|
||||
reminderFee,
|
||||
currency: invoice.currency,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
invoiceNumber: invoice.invoice_number,
|
||||
@@ -187,6 +201,11 @@ export async function GET(request: Request) {
|
||||
interestFromDate: reminder.interest_from_date,
|
||||
interestDays: reminder.interest_days,
|
||||
reminderFee,
|
||||
totalDue,
|
||||
/** Always 'SEK': the fee is a krona statute, never the invoice currency. */
|
||||
reminderFeeCurrency: REMINDER_FEE_CURRENCY,
|
||||
/** In `currency`. Includes the fee only when the invoice is itself in SEK. */
|
||||
totalDue: amounts.totalDue,
|
||||
/** In SEK. Non-zero only when the fee must be demanded outside `totalDue`. */
|
||||
feeDueSeparately: amounts.feeDueSeparately,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -275,6 +275,22 @@ async function createCreditNote(
|
||||
return errorResponseFromCode('INVOICE_CREDIT_ALREADY_CREDITED', log, { requestId })
|
||||
}
|
||||
|
||||
// 'partially_paid' is missing from this list and that is a real gap, not a
|
||||
// rule: an aendringsfaktura per ML (2023:200) 17 kap 22-23 SS references the
|
||||
// original's loepnummer, and whether the customer has paid nothing, part or
|
||||
// all of it has no bearing on the right to issue one. It is NOT added here
|
||||
// alone, because this door is not where the flow ends: issueCreditNote()
|
||||
// (lib/invoices/issue-credit-note.ts) flips the original to 'credited' with
|
||||
// the same three-status compare-and-set, and it runs AFTER the reversing
|
||||
// verifikat is posted. Widening only this check would post an immutable
|
||||
// voucher and then fail on the status flip, leaving a fully credited invoice
|
||||
// sitting at 'partially_paid': open in the AR ledger and still chased by
|
||||
// reminders. Widening it is a coordinated change across the six sites listed
|
||||
// in DECISIONS.md, with issue-credit-note.ts first.
|
||||
//
|
||||
// Genuinely refused either way: 'draft' (never issued, so there is no
|
||||
// loepnummer for ML 17 kap 22 to reference) and 'cancelled'. 'credited' is
|
||||
// refused above.
|
||||
if (!['sent', 'paid', 'overdue'].includes(originalInvoice.status)) {
|
||||
return errorResponseFromCode('INVOICE_CREDIT_NOT_SENT', log, {
|
||||
requestId,
|
||||
|
||||
@@ -28,9 +28,13 @@ vi.mock('@/lib/auth/require-write', () => ({
|
||||
|
||||
const mockGetVatRules = vi.fn()
|
||||
const mockGetAvailableVatRates = vi.fn()
|
||||
// Mocked SEPARATELY from getAvailableVatRates on purpose: the picker default and
|
||||
// the validation gate are two different sets, and the gate must read this one.
|
||||
const mockGetPermittedVatRates = vi.fn()
|
||||
vi.mock('@/lib/invoices/vat-rules', () => ({
|
||||
getVatRules: (...args: unknown[]) => mockGetVatRules(...args),
|
||||
getAvailableVatRates: (...args: unknown[]) => mockGetAvailableVatRates(...args),
|
||||
getPermittedVatRates: (...args: unknown[]) => mockGetPermittedVatRates(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/currency/riksbanken', () => ({
|
||||
@@ -59,6 +63,13 @@ const validBody = {
|
||||
items: [{ description: 'Konsulttjänst', quantity: 10, unit: 'tim', unit_price: 1000 }],
|
||||
}
|
||||
|
||||
const DOMESTIC_RATES = [
|
||||
{ rate: 25, label: '25%', treatment: 'standard_25' },
|
||||
{ rate: 12, label: '12%', treatment: 'reduced_12' },
|
||||
{ rate: 6, label: '6%', treatment: 'reduced_6' },
|
||||
{ rate: 0, label: '0% (momsfri)', treatment: 'exempt' },
|
||||
]
|
||||
|
||||
function mockDomesticVat() {
|
||||
mockGetVatRules.mockReturnValue({
|
||||
treatment: 'standard_25',
|
||||
@@ -66,11 +77,33 @@ function mockDomesticVat() {
|
||||
momsRuta: '05',
|
||||
reverseChargeText: null,
|
||||
})
|
||||
// Domestically the two sets are identical (getPermittedVatRates only widens
|
||||
// for a foreign business customer).
|
||||
mockGetAvailableVatRates.mockReturnValue(DOMESTIC_RATES)
|
||||
mockGetPermittedVatRates.mockReturnValue(DOMESTIC_RATES)
|
||||
}
|
||||
|
||||
/**
|
||||
* A VAT-validated EU business: the picker DEFAULT is a single locked 0%
|
||||
* (huvudregeln, ML 6 kap. 34 §), while the PERMITTED set also carries the
|
||||
* Swedish rates for the supplies taxed where they are performed. The gate must
|
||||
* read the permitted set, so the two mocks deliberately disagree here.
|
||||
*/
|
||||
function mockEuBusinessVat() {
|
||||
mockGetVatRules.mockReturnValue({
|
||||
treatment: 'reverse_charge',
|
||||
rate: 0,
|
||||
momsRuta: '39',
|
||||
reverseChargeText: 'Omvänd skattskyldighet / Reverse charge',
|
||||
})
|
||||
mockGetAvailableVatRates.mockReturnValue([
|
||||
{ rate: 0, label: '0% (omvänd skattskyldighet)', treatment: 'reverse_charge' },
|
||||
])
|
||||
mockGetPermittedVatRates.mockReturnValue([
|
||||
{ rate: 0, label: '0% (omvänd skattskyldighet)', treatment: 'reverse_charge' },
|
||||
{ rate: 25, label: '25%', treatment: 'standard_25' },
|
||||
{ rate: 12, label: '12%', treatment: 'reduced_12' },
|
||||
{ rate: 6, label: '6%', treatment: 'reduced_6' },
|
||||
{ rate: 0, label: '0% (momsfri)', treatment: 'exempt' },
|
||||
])
|
||||
}
|
||||
|
||||
@@ -118,11 +151,13 @@ describe('POST /api/invoices/self-billed', () => {
|
||||
it('rejects an item VAT rate the customer is not allowed to use', async () => {
|
||||
mockGetVatRules.mockReturnValue({ treatment: 'standard_25', rate: 25, momsRuta: '05', reverseChargeText: null })
|
||||
// Domestic-only set: 0% is NOT allowed for this customer.
|
||||
mockGetAvailableVatRates.mockReturnValue([
|
||||
const noZero = [
|
||||
{ rate: 25, label: '25%', treatment: 'standard_25' },
|
||||
{ rate: 12, label: '12%', treatment: 'reduced_12' },
|
||||
{ rate: 6, label: '6%', treatment: 'reduced_6' },
|
||||
])
|
||||
]
|
||||
mockGetAvailableVatRates.mockReturnValue(noZero)
|
||||
mockGetPermittedVatRates.mockReturnValue(noZero)
|
||||
enqueue({ data: makeCustomer({ id: VALID_UUID }), error: null })
|
||||
|
||||
const request = createMockRequest('/api/invoices/self-billed', {
|
||||
@@ -136,6 +171,63 @@ describe('POST /api/invoices/self-billed', () => {
|
||||
expect(body.error.code).toBe('INVOICE_CREATE_VAT_RULE_VIOLATION')
|
||||
})
|
||||
|
||||
it('accepts 12% to a VAT-validated EU business (taxed where performed)', async () => {
|
||||
// A Stockholm hotel night self-billed by a German customer: 12% Swedish VAT
|
||||
// is lawful even though the picker default for that customer is 0%. Refusing
|
||||
// it made the sale impossible to register at all.
|
||||
mockEuBusinessVat()
|
||||
const customer = makeCustomer({ id: VALID_UUID, customer_type: 'eu_business' })
|
||||
const created = makeInvoice({
|
||||
id: 'inv-1',
|
||||
invoice_number: null,
|
||||
is_self_billed: true,
|
||||
external_invoice_number: 'KUND-55012',
|
||||
total: 11200,
|
||||
})
|
||||
|
||||
enqueue({ data: customer, error: null }) // fetch customer
|
||||
enqueue({ data: created, error: null }) // insert invoice
|
||||
enqueue({ data: null, error: null }) // insert items
|
||||
enqueue({ data: { accounting_method: 'accrual', entity_type: 'aktiebolag' }, error: null }) // settings
|
||||
enqueue({ data: { ...created, customer, items: [] }, error: null }) // fetch complete
|
||||
enqueue({ data: null, error: null }) // update journal_entry_id
|
||||
enqueue({ data: { ...created, customer, items: [], journal_entry_id: 'je-1' }, error: null }) // fetch final
|
||||
|
||||
mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-1' })
|
||||
|
||||
const request = createMockRequest('/api/invoices/self-billed', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
...validBody,
|
||||
items: [{ description: 'Hotellnatt Stockholm', quantity: 10, unit: 'st', unit_price: 1000, vat_rate: 12 }],
|
||||
},
|
||||
})
|
||||
const response = await POST(request, { params: Promise.resolve({}) })
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
})
|
||||
|
||||
it('still rejects a rate that is not a Swedish VAT rate at all', async () => {
|
||||
// Widening the permitted set must not turn this into a pass-through: 10% is
|
||||
// no Swedish rate. Here it never even reaches the domain gate, because
|
||||
// SelfBillingInvoiceItemSchema only accepts 0/6/12/25.
|
||||
mockEuBusinessVat()
|
||||
|
||||
const request = createMockRequest('/api/invoices/self-billed', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
...validBody,
|
||||
items: [{ description: 'X', quantity: 1, unit: 'st', unit_price: 100, vat_rate: 10 }],
|
||||
},
|
||||
})
|
||||
const response = await POST(request, { params: Promise.resolve({}) })
|
||||
const { status, body } = await parseJsonResponse<{ type: string }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.type).toBe('validation_error')
|
||||
})
|
||||
|
||||
it('creates a self-billed sale, books it (accrual), skips own numbering, and emits invoice.created', async () => {
|
||||
mockDomesticVat()
|
||||
const customer = makeCustomer({ id: VALID_UUID, name: 'Stora Bolaget AB' })
|
||||
|
||||
@@ -91,7 +91,8 @@ describe('PUT /api/kpi/preferences', () => {
|
||||
})
|
||||
|
||||
it('upserts and returns the stored value on the happy path', async () => {
|
||||
enqueue({ data: { value: { accountOverrides: { some_kpi: ['3001'] } } } })
|
||||
enqueue({ data: { value: {} } }) // existing row lookup
|
||||
enqueue({ data: { value: { accountOverrides: { some_kpi: ['3001'] } } } }) // upsert
|
||||
const req = createMockRequest('/api/kpi/preferences', {
|
||||
method: 'PUT',
|
||||
body: { accountOverrides: { some_kpi: ['3001'] } },
|
||||
@@ -101,4 +102,123 @@ describe('PUT /api/kpi/preferences', () => {
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.accountOverrides.some_kpi).toEqual(['3001'])
|
||||
})
|
||||
|
||||
it('rejects an accountOverrides value that is not an array of strings', async () => {
|
||||
const req = createMockRequest('/api/kpi/preferences', {
|
||||
method: 'PUT',
|
||||
body: { accountOverrides: { some_kpi: 'not-an-array' } },
|
||||
})
|
||||
const res = await PUT(req, { params: Promise.resolve({}) })
|
||||
const { status } = await parseJsonResponse(res)
|
||||
expect(status).toBe(400)
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
/**
|
||||
* A partial save used to reset every unmentioned setting: mergeWithDefaults()
|
||||
* fills absent keys with FACTORY defaults, and the merged document was what got
|
||||
* stored, so saving an account override wiped the user's visible-KPI selection
|
||||
* and ordering. The merge base is now the STORED row. Asserted on the payload
|
||||
* handed to upsert(); the shared queued mock proxies every chain method, so the
|
||||
* payload is captured with a purpose-built recorder.
|
||||
*/
|
||||
describe('PUT /api/kpi/preferences merge semantics', () => {
|
||||
function createCapturingSupabase(results: { data?: unknown; error?: unknown }[]) {
|
||||
const upsertPayloads: Record<string, unknown>[] = []
|
||||
const upsertOptions: ({ onConflict?: string } | undefined)[] = []
|
||||
let idx = 0
|
||||
const makeBuilder = () => {
|
||||
const result = results[idx++] ?? { data: null, error: null }
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const b: any = {}
|
||||
for (const m of ['select', 'eq', 'maybeSingle', 'single']) {
|
||||
b[m] = () => b
|
||||
}
|
||||
b.upsert = (payload: Record<string, unknown>, options?: { onConflict?: string }) => {
|
||||
upsertPayloads.push(payload)
|
||||
upsertOptions.push(options)
|
||||
return b
|
||||
}
|
||||
b.then = (resolve: (v: unknown) => void) =>
|
||||
resolve({ data: result.data ?? null, error: result.error ?? null })
|
||||
return b
|
||||
}
|
||||
return { supabase: { from: () => makeBuilder() }, upsertPayloads, upsertOptions }
|
||||
}
|
||||
|
||||
const stored = {
|
||||
visibleKpis: ['kpi_a'],
|
||||
kpiOrder: ['kpi_a', 'kpi_b'],
|
||||
accountOverrides: { kpi_a: ['3001'] },
|
||||
}
|
||||
|
||||
async function put(body: unknown, existing: unknown) {
|
||||
const { supabase, upsertPayloads, upsertOptions } = createCapturingSupabase([
|
||||
{ data: existing === undefined ? null : { value: existing } },
|
||||
{ data: { value: 'ok' } },
|
||||
])
|
||||
requireAuthMock.mockResolvedValue({ user: mockUser, supabase, error: null })
|
||||
const res = await PUT(
|
||||
createMockRequest('/api/kpi/preferences', { method: 'PUT', body }),
|
||||
{ params: Promise.resolve({}) },
|
||||
)
|
||||
return {
|
||||
res,
|
||||
value: upsertPayloads[0]?.value as Record<string, unknown> | undefined,
|
||||
payload: upsertPayloads[0],
|
||||
options: upsertOptions[0],
|
||||
}
|
||||
}
|
||||
|
||||
it('keeps the stored visibleKpis and kpiOrder when only accountOverrides is sent', async () => {
|
||||
const { res, value } = await put({ accountOverrides: { kpi_b: ['4010'] } }, stored)
|
||||
expect(res.status).toBe(200)
|
||||
expect(value).toEqual({
|
||||
visibleKpis: ['kpi_a'],
|
||||
kpiOrder: ['kpi_a', 'kpi_b'],
|
||||
accountOverrides: { kpi_b: ['4010'] },
|
||||
})
|
||||
})
|
||||
|
||||
it('replaces every key when the dialog sends the complete object', async () => {
|
||||
const full = {
|
||||
visibleKpis: ['kpi_z'],
|
||||
kpiOrder: ['kpi_z'],
|
||||
accountOverrides: { kpi_z: ['3010'] },
|
||||
}
|
||||
const { value } = await put(full, stored)
|
||||
expect(value).toEqual(full)
|
||||
})
|
||||
|
||||
it('falls back to defaults for a row that has never been written', async () => {
|
||||
const { res, value } = await put({ visibleKpis: ['kpi_new'] }, undefined)
|
||||
expect(res.status).toBe(200)
|
||||
expect(value?.visibleKpis).toEqual(['kpi_new'])
|
||||
// The other two keys come from the defaults, not from undefined.
|
||||
expect(value?.kpiOrder).toBeDefined()
|
||||
expect(value?.accountOverrides).toBeDefined()
|
||||
})
|
||||
|
||||
it('an empty body stores the stored value unchanged', async () => {
|
||||
const { value } = await put({}, stored)
|
||||
expect(value).toEqual(stored)
|
||||
})
|
||||
|
||||
it('upserts against the company-scoped unique constraint', async () => {
|
||||
// Migration 20260330130000 dropped UNIQUE (user_id, extension_id, key) in
|
||||
// favor of UNIQUE (company_id, extension_id, key). Naming the old trio in
|
||||
// onConflict makes Postgres reject every save with 42P10, because the
|
||||
// surviving user_id index is non-unique and cannot arbitrate ON CONFLICT.
|
||||
const { res, payload, options } = await put({ visibleKpis: ['kpi_a'] }, stored)
|
||||
expect(res.status).toBe(200)
|
||||
expect(options?.onConflict).toBe('company_id,extension_id,key')
|
||||
// The row still carries company scoping + last-writer attribution.
|
||||
expect(payload).toMatchObject({
|
||||
company_id: 'company-1',
|
||||
user_id: 'user-1',
|
||||
extension_id: 'core/kpi',
|
||||
key: 'preferences',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { sparsePatchBody } from '@/lib/api/sparse-patch'
|
||||
import { mergeWithDefaults } from '@/lib/reports/kpi-definitions'
|
||||
import type { KPIPreferences } from '@/types'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
@@ -7,6 +10,19 @@ import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-m
|
||||
const EXTENSION_ID = 'core/kpi'
|
||||
const KEY = 'preferences'
|
||||
|
||||
/**
|
||||
* Deliberately carries no `.default()`: the defaults belong to
|
||||
* mergeWithDefaults() on the read path, not to the parse of a write. A schema
|
||||
* default here would resurrect on every save exactly the way a `.partial()`
|
||||
* schema's defaults do. Keys are all optional so the route can tell "the
|
||||
* caller set this" from "the caller said nothing about this".
|
||||
*/
|
||||
const UpdateKPIPreferencesSchema = z.object({
|
||||
visibleKpis: z.array(z.string()).optional(),
|
||||
kpiOrder: z.array(z.string()).optional(),
|
||||
accountOverrides: z.record(z.string(), z.array(z.string())).optional(),
|
||||
})
|
||||
|
||||
export const GET = withRouteContext('kpi.preferences.get', async (_request, { supabase, companyId }) => {
|
||||
const { data } = await supabase
|
||||
.from('extension_data')
|
||||
@@ -23,26 +39,15 @@ export const GET = withRouteContext('kpi.preferences.get', async (_request, { su
|
||||
export const PUT = withRouteContext(
|
||||
'kpi.preferences.update',
|
||||
async (request, { supabase, companyId, user }) => {
|
||||
let body: unknown
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
|
||||
}
|
||||
|
||||
const prefs = body as Partial<KPIPreferences>
|
||||
const validation = await validateBody(request, sparsePatchBody(UpdateKPIPreferencesSchema))
|
||||
if (!validation.success) return validation.response
|
||||
const prefs = validation.data
|
||||
|
||||
// Validate account overrides: must be 4-digit numeric strings
|
||||
if (prefs.accountOverrides) {
|
||||
for (const [kpiId, accounts] of Object.entries(prefs.accountOverrides)) {
|
||||
if (!Array.isArray(accounts)) {
|
||||
return NextResponse.json(
|
||||
{ error: `accountOverrides.${kpiId} must be an array` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
for (const acc of accounts) {
|
||||
if (typeof acc !== 'string' || !/^\d{4}$/.test(acc)) {
|
||||
if (!/^\d{4}$/.test(acc)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Invalid account number "${acc}" in ${kpiId}: must be 4 digits` },
|
||||
{ status: 400 }
|
||||
@@ -52,8 +57,34 @@ export const PUT = withRouteContext(
|
||||
}
|
||||
}
|
||||
|
||||
const merged = mergeWithDefaults(prefs)
|
||||
// Merge over what is STORED, not over the defaults. mergeWithDefaults()
|
||||
// fills every absent key with its default value, so saving one setting
|
||||
// used to reset the other two: a PUT of `{ accountOverrides: … }` wiped
|
||||
// visibleKpis and kpiOrder back to factory settings. Defaults still apply
|
||||
// to a row that has never been written (and on the read path), but they no
|
||||
// longer overwrite a stored choice the caller never mentioned. The
|
||||
// settings dialog always sends the complete object (its reset button fills
|
||||
// the draft with getDefaultPreferences() before saving), so its behaviour
|
||||
// is unchanged; only sparse callers stop losing data.
|
||||
const { data: existing } = await supabase
|
||||
.from('extension_data')
|
||||
.select('value')
|
||||
.eq('company_id', companyId)
|
||||
.eq('extension_id', EXTENSION_ID)
|
||||
.eq('key', KEY)
|
||||
.maybeSingle()
|
||||
|
||||
const stored = mergeWithDefaults((existing?.value as Partial<KPIPreferences>) ?? {})
|
||||
const merged: KPIPreferences = { ...stored, ...prefs }
|
||||
|
||||
// KPI preferences are COMPANY-scoped, not per-user: the read paths (GET
|
||||
// above and the KPI report route) filter on (company_id, extension_id,
|
||||
// key) with no user filter, and the key carries no user id. user_id is
|
||||
// stored purely as "who wrote this last" attribution. The upsert must
|
||||
// therefore arbitrate on the company-scoped unique constraint: migration
|
||||
// 20260330130000 dropped UNIQUE (user_id, extension_id, key) in favor of
|
||||
// UNIQUE (company_id, extension_id, key), so naming the old column trio
|
||||
// here makes Postgres fail every save with 42P10 (no matching constraint).
|
||||
const { data, error } = await supabase
|
||||
.from('extension_data')
|
||||
.upsert(
|
||||
@@ -64,7 +95,7 @@ export const PUT = withRouteContext(
|
||||
key: KEY,
|
||||
value: merged,
|
||||
},
|
||||
{ onConflict: 'user_id,extension_id,key' }
|
||||
{ onConflict: 'company_id,extension_id,key' }
|
||||
)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* Tests for GET /api/reconciliation/bank/unmatched-entries.
|
||||
*
|
||||
* Exercises the route through the real withRouteContext wrapper and the REAL
|
||||
* matcher (tryReconcileTransaction / ledgerLineAmountIn); only the RPC-backed
|
||||
* candidate fetch is mocked, so the currency guard is tested end to end rather
|
||||
* than against a stubbed scorer.
|
||||
*
|
||||
* The focus is the ranking currency: it is the CASH ACCOUNT's, never the
|
||||
* transaction's own. Sourcing it from the transaction made
|
||||
* tryReconcileTransaction's `transaction.currency !== expectedCurrency` guard
|
||||
* compare the transaction against itself, so it never rejected anything and a
|
||||
* foreign bank line was ranked against this account's SEK vouchers.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
|
||||
import type { GLLineForMatching } from '@/lib/reconciliation/bank-reconciliation'
|
||||
|
||||
const { supabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
|
||||
const requireAuthMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-auth', () => ({
|
||||
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
// Only the candidate fetch is stubbed: the scorer and ledgerLineAmountIn stay
|
||||
// real so a regression in the currency handling actually fails here.
|
||||
const fetchGLLinesForMatchingMock = vi.fn()
|
||||
vi.mock('@/lib/reconciliation/bank-reconciliation', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/reconciliation/bank-reconciliation')>()
|
||||
return {
|
||||
...actual,
|
||||
fetchGLLinesForMatching: (...args: unknown[]) => fetchGLLinesForMatchingMock(...args),
|
||||
}
|
||||
})
|
||||
|
||||
import { GET } from '../route'
|
||||
|
||||
const emptyParams = { params: Promise.resolve({}) }
|
||||
const TX_ID = '11111111-1111-4111-8111-111111111111'
|
||||
|
||||
type RankedLine = GLLineForMatching & { confidence: number }
|
||||
type Body = { data: RankedLine[]; not_rankable_reason?: string }
|
||||
|
||||
function makeGLLine(overrides: Partial<GLLineForMatching> = {}): GLLineForMatching {
|
||||
return {
|
||||
line_id: 'line-1',
|
||||
journal_entry_id: 'je-1',
|
||||
// The RPC projects the SEK debit/credit columns only: see the currency /
|
||||
// amount_in_currency note on UnlinkedGLLine.
|
||||
debit_amount: 0,
|
||||
credit_amount: 1150,
|
||||
line_description: null,
|
||||
entry_date: '2026-03-10',
|
||||
voucher_number: 42,
|
||||
voucher_series: 'A',
|
||||
entry_description: 'Leverantörsbetalning',
|
||||
source_type: 'manual',
|
||||
linked_transaction_count: 0,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/** Queue the cash_accounts lookup the route always performs first. */
|
||||
function enqueueCashAccount(currency: string | null = 'SEK') {
|
||||
enqueue({ data: { id: 'cash-1', currency } })
|
||||
}
|
||||
|
||||
/** Queue the transaction lookup the ranked path performs second. */
|
||||
function enqueueTransaction(overrides: Record<string, unknown> = {}) {
|
||||
enqueue({
|
||||
data: {
|
||||
id: TX_ID,
|
||||
amount: -1150,
|
||||
date: '2026-03-10',
|
||||
currency: 'SEK',
|
||||
reference: null,
|
||||
...overrides,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function request(searchParams: Record<string, string>) {
|
||||
return createMockRequest('/api/reconciliation/bank/unmatched-entries', { searchParams })
|
||||
}
|
||||
|
||||
describe('GET /api/reconciliation/bank/unmatched-entries', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase })
|
||||
fetchGLLinesForMatchingMock.mockResolvedValue([])
|
||||
})
|
||||
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: null,
|
||||
supabase,
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
|
||||
const response = await GET(request({ account_number: '1930' }), emptyParams)
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(fetchGLLinesForMatchingMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects an account the company has not registered as a cash account', async () => {
|
||||
// The route's "not found": an unregistered ledger account is refused rather
|
||||
// than probed, so this endpoint cannot be used to read arbitrary GL accounts.
|
||||
enqueue({ data: null })
|
||||
|
||||
const response = await GET(request({ account_number: '1510' }), emptyParams)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(fetchGLLinesForMatchingMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns no candidates when transaction_id does not resolve in this company', async () => {
|
||||
enqueueCashAccount('SEK')
|
||||
enqueue({ data: null })
|
||||
fetchGLLinesForMatchingMock.mockResolvedValue([makeGLLine()])
|
||||
|
||||
const response = await GET(
|
||||
request({ account_number: '1930', transaction_id: TX_ID }),
|
||||
emptyParams,
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<Body>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data).toEqual([])
|
||||
})
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// SEK: the 95% path, byte-for-byte unchanged
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
it('ranks a SEK bank line against SEK vouchers exactly as before', async () => {
|
||||
enqueueCashAccount('SEK')
|
||||
enqueueTransaction({ currency: 'SEK', amount: -1150, date: '2026-03-10' })
|
||||
fetchGLLinesForMatchingMock.mockResolvedValue([
|
||||
makeGLLine({ line_id: 'line-far', journal_entry_id: 'je-far', entry_date: '2026-02-01' }),
|
||||
makeGLLine({ line_id: 'line-hit', journal_entry_id: 'je-hit', entry_date: '2026-03-10' }),
|
||||
])
|
||||
|
||||
const response = await GET(
|
||||
request({ account_number: '1930', transaction_id: TX_ID }),
|
||||
emptyParams,
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<Body>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
// Nothing withheld on a SEK account: ledgerLineAmountIn never returns null there.
|
||||
expect(body.data).toHaveLength(2)
|
||||
expect(body.not_rankable_reason).toBeUndefined()
|
||||
// Exact amount + exact date = auto_exact, sorted to the top.
|
||||
expect(body.data[0].journal_entry_id).toBe('je-hit')
|
||||
expect(body.data[0].confidence).toBe(0.95)
|
||||
expect(body.data[1].confidence).toBe(0)
|
||||
})
|
||||
|
||||
it('leaves the unranked list untouched when no transaction_id is passed', async () => {
|
||||
enqueueCashAccount('SEK')
|
||||
const lines = [makeGLLine()]
|
||||
fetchGLLinesForMatchingMock.mockResolvedValue(lines)
|
||||
|
||||
const response = await GET(request({ account_number: '1930' }), emptyParams)
|
||||
const { status, body } = await parseJsonResponse<Body>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data).toHaveLength(1)
|
||||
expect(body.data[0]).not.toHaveProperty('confidence')
|
||||
})
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Foreign currency: never ranked against an unconverted SEK ledger leg
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
it('does not rank a foreign bank line against a same-magnitude SEK voucher', async () => {
|
||||
// A 1 150 EUR payment out of the EUR account. The voucher's 1932 leg holds
|
||||
// 1 150 in the SEK columns for an unrelated amount of money; the RPC
|
||||
// projects no currency / amount_in_currency, so there is no rate to convert
|
||||
// with. Offering it as the settlement would be a coincidence of magnitude.
|
||||
enqueueCashAccount('EUR')
|
||||
enqueueTransaction({ currency: 'EUR', amount: -1150, date: '2026-03-10' })
|
||||
fetchGLLinesForMatchingMock.mockResolvedValue([makeGLLine({ credit_amount: 1150 })])
|
||||
|
||||
const response = await GET(
|
||||
request({ account_number: '1932', transaction_id: TX_ID }),
|
||||
emptyParams,
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<Body>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data).toEqual([])
|
||||
// Honest about WHY the list is empty: candidates exist, they just cannot be
|
||||
// expressed in the account's currency. Not "no unmatched verifikationer".
|
||||
expect(body.not_rankable_reason).toBe('gl_lines_missing_currency_amount')
|
||||
})
|
||||
|
||||
it('refuses to rank a bank line denominated in another currency than the account', async () => {
|
||||
// The tautology this fixes: with the reconciliation currency read off the
|
||||
// transaction, this EUR row was ranked against the SEK account's vouchers.
|
||||
enqueueCashAccount('SEK')
|
||||
enqueueTransaction({ currency: 'EUR', amount: -1150, date: '2026-03-10' })
|
||||
fetchGLLinesForMatchingMock.mockResolvedValue([makeGLLine({ credit_amount: 1150 })])
|
||||
|
||||
const response = await GET(
|
||||
request({ account_number: '1930', transaction_id: TX_ID }),
|
||||
emptyParams,
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<Body>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data).toEqual([])
|
||||
expect(body.not_rankable_reason).toBe('transaction_currency_mismatch')
|
||||
})
|
||||
|
||||
it('ranks a foreign bank line when the ledger line carries a foreign amount', async () => {
|
||||
// Proves the filter is per-line, not a blanket "non-SEK account cannot
|
||||
// rank": the day the RPC projects currency + amount_in_currency, ranking
|
||||
// resumes with no further change here.
|
||||
enqueueCashAccount('EUR')
|
||||
enqueueTransaction({ currency: 'EUR', amount: -100, date: '2026-03-10' })
|
||||
fetchGLLinesForMatchingMock.mockResolvedValue([
|
||||
makeGLLine({
|
||||
// Debit/credit are SEK; the EUR figure lives in amount_in_currency.
|
||||
credit_amount: 1150,
|
||||
currency: 'EUR',
|
||||
amount_in_currency: 100,
|
||||
}),
|
||||
])
|
||||
|
||||
const response = await GET(
|
||||
request({ account_number: '1932', transaction_id: TX_ID }),
|
||||
emptyParams,
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<Body>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data).toHaveLength(1)
|
||||
expect(body.data[0].confidence).toBe(0.95)
|
||||
expect(body.not_rankable_reason).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,10 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { fetchGLLinesForMatching, tryReconcileTransaction } from '@/lib/reconciliation/bank-reconciliation'
|
||||
import {
|
||||
fetchGLLinesForMatching,
|
||||
ledgerLineAmountIn,
|
||||
tryReconcileTransaction,
|
||||
} from '@/lib/reconciliation/bank-reconciliation'
|
||||
import type { Transaction } from '@/types'
|
||||
|
||||
export const GET = withRouteContext(
|
||||
@@ -28,9 +32,11 @@ export const GET = withRouteContext(
|
||||
// including '1930': the cash_accounts backfill seeds 1930 for every company
|
||||
// that had a SEK PSD2 account, and the AccountPickerDialog seeds it for new
|
||||
// companies on first connection.
|
||||
// `currency` comes along because it, not the transaction's own currency, is
|
||||
// the unit this account is reconciled in: see the ranking block below.
|
||||
const { data: cashAccount } = await supabase
|
||||
.from('cash_accounts')
|
||||
.select('id')
|
||||
.select('id, currency')
|
||||
.eq('company_id', companyId)
|
||||
.eq('ledger_account', accountNumber)
|
||||
.maybeSingle()
|
||||
@@ -42,6 +48,11 @@ export const GET = withRouteContext(
|
||||
)
|
||||
}
|
||||
|
||||
// cash_accounts.currency is NOT NULL in the schema; the fallback only guards
|
||||
// against a legacy/hand-built row so a missing value can never silently turn
|
||||
// into "no currency" and disable ranking for a SEK company.
|
||||
const accountCurrency = (cashAccount.currency as string | null) ?? 'SEK'
|
||||
|
||||
const lines = await fetchGLLinesForMatching(supabase, companyId, accountNumber, dateFrom, dateTo, includeMatched)
|
||||
|
||||
if (transactionId) {
|
||||
@@ -62,14 +73,45 @@ export const GET = withRouteContext(
|
||||
return NextResponse.json({ data: [] })
|
||||
}
|
||||
|
||||
// The reconciliation currency is the CASH ACCOUNT's, never the
|
||||
// transaction's. Reading it off `tx` made tryReconcileTransaction's
|
||||
// `transaction.currency !== expectedCurrency` guard compare the
|
||||
// transaction against itself: a tautology that passed for every row, so
|
||||
// the guard never rejected anything and a foreign bank line was ranked
|
||||
// against this account's (SEK) vouchers.
|
||||
const txCurrency = (tx.currency as string | null) ?? 'SEK'
|
||||
if (txCurrency !== accountCurrency) {
|
||||
// A bank line in another currency cannot be settled on this account, so
|
||||
// there is no honest ranking to produce. Same posture as the unresolved
|
||||
// transaction above: no candidates beats plausible-looking wrong ones.
|
||||
return NextResponse.json({
|
||||
data: [],
|
||||
not_rankable_reason: 'transaction_currency_mismatch',
|
||||
})
|
||||
}
|
||||
|
||||
// Withhold any candidate that cannot be expressed in the account's
|
||||
// currency. journal_entry_lines.debit_amount / credit_amount are ALWAYS
|
||||
// SEK; `currency` on the line labels the underlying DOCUMENT and
|
||||
// amount_in_currency carries the foreign figure (see ledgerLineAmountIn).
|
||||
// On a SEK account this filter is a no-op: ledgerLineAmountIn never
|
||||
// returns null there, so SEK-only companies see exactly the list they
|
||||
// always did. On a foreign account it currently drops EVERY candidate,
|
||||
// because get_account_gl_lines_for_matching projects neither currency nor
|
||||
// amount_in_currency (verified against prod), so no row carries a rate to
|
||||
// convert with. Ranking them anyway would offer a 1 150 SEK ledger leg as
|
||||
// the settlement for a 1 150 EUR bank line. Written per-line rather than
|
||||
// as an `accountCurrency !== 'SEK'` short-circuit so that ranking resumes
|
||||
// by itself the day the RPC projects those two columns.
|
||||
const rankable = lines.filter((line) => ledgerLineAmountIn(line, accountCurrency) !== null)
|
||||
|
||||
const txDate = tx.date as string
|
||||
const ranked = lines
|
||||
const ranked = rankable
|
||||
.map((line) => {
|
||||
// Score each line in isolation; confidence 0 means "no auto-match
|
||||
// rule fired": the line still appears so the user can pick it
|
||||
// manually (e.g. a salary or Fortnox voucher with a tweaked date).
|
||||
const match = tryReconcileTransaction(tx as unknown as Transaction, [line], txCurrency)
|
||||
const match = tryReconcileTransaction(tx as unknown as Transaction, [line], accountCurrency)
|
||||
return { ...line, confidence: match?.confidence ?? 0 }
|
||||
})
|
||||
.sort((a, b) => {
|
||||
@@ -78,6 +120,15 @@ export const GET = withRouteContext(
|
||||
const db = Math.abs(new Date(b.entry_date).getTime() - new Date(txDate).getTime())
|
||||
return da - db
|
||||
})
|
||||
|
||||
// Say so when candidates were withheld, instead of letting an empty list
|
||||
// read as "this account has no unmatched verifikationer in the period".
|
||||
if (rankable.length !== lines.length) {
|
||||
return NextResponse.json({
|
||||
data: ranked,
|
||||
not_rankable_reason: 'gl_lines_missing_currency_amount',
|
||||
})
|
||||
}
|
||||
return NextResponse.json({ data: ranked })
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { isValidElement } from 'react'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
const mockSupabase = {
|
||||
@@ -36,9 +37,41 @@ vi.mock('@/lib/reports/ar-ledger', () => ({
|
||||
import { GET } from '../route'
|
||||
import { requireAuth } from '@/lib/auth/require-auth'
|
||||
import { generateARLedger } from '@/lib/reports/ar-ledger'
|
||||
import { renderToBuffer } from '@react-pdf/renderer'
|
||||
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
|
||||
/**
|
||||
* The template renders for real (only renderToBuffer is stubbed), so the
|
||||
* document handed to it can be walked for its text. Joining the leaves with ''
|
||||
* reproduces the text of any single <Text>.
|
||||
*/
|
||||
function renderedText(node: unknown): string {
|
||||
const out: string[] = []
|
||||
const walk = (n: unknown): void => {
|
||||
if (n === null || n === undefined || typeof n === 'boolean') return
|
||||
if (typeof n === 'string' || typeof n === 'number') {
|
||||
out.push(String(n))
|
||||
return
|
||||
}
|
||||
if (Array.isArray(n)) {
|
||||
n.forEach(walk)
|
||||
return
|
||||
}
|
||||
if (isValidElement(n)) {
|
||||
walk((n.props as { children?: unknown }).children)
|
||||
}
|
||||
}
|
||||
walk(node)
|
||||
// Intl's sv-SE group separator is a non-breaking space; normalise every
|
||||
// space-like character so the assertions below can use ordinary spaces.
|
||||
return out.join('').replace(/\s/g, ' ')
|
||||
}
|
||||
|
||||
function renderedDocumentText(): string {
|
||||
return renderedText(vi.mocked(renderToBuffer).mock.calls[0][0])
|
||||
}
|
||||
|
||||
function companySettingsQuery(data: unknown) {
|
||||
return {
|
||||
select: vi.fn().mockReturnThis(),
|
||||
@@ -83,6 +116,71 @@ function makeLedger() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One SEK invoice plus one EUR invoice with a rate, plus one USD invoice with
|
||||
* no rate (outstanding_sek null, so it is missing from the aging buckets).
|
||||
* The aging totals are SEK: 1 000 + 11 475.
|
||||
*/
|
||||
function makeFxLedger() {
|
||||
return {
|
||||
entries: [
|
||||
{
|
||||
customer_id: 'cust-1',
|
||||
customer_name: 'Acme AB',
|
||||
invoices: [
|
||||
{
|
||||
invoice_id: 'inv-1',
|
||||
invoice_number: 'F001',
|
||||
invoice_date: '2026-05-01',
|
||||
due_date: '2026-06-01',
|
||||
total: 1000,
|
||||
paid_amount: 0,
|
||||
outstanding: 1000,
|
||||
outstanding_sek: 1000,
|
||||
days_overdue: 14,
|
||||
currency: 'SEK',
|
||||
},
|
||||
{
|
||||
invoice_id: 'inv-2',
|
||||
invoice_number: 'F002',
|
||||
invoice_date: '2026-05-02',
|
||||
due_date: '2026-06-02',
|
||||
total: 1000,
|
||||
paid_amount: 0,
|
||||
outstanding: 1000,
|
||||
outstanding_sek: 11475,
|
||||
days_overdue: 13,
|
||||
currency: 'EUR',
|
||||
},
|
||||
{
|
||||
invoice_id: 'inv-3',
|
||||
invoice_number: 'F003',
|
||||
invoice_date: '2026-05-03',
|
||||
due_date: '2026-06-03',
|
||||
total: 500,
|
||||
paid_amount: 0,
|
||||
outstanding: 500,
|
||||
outstanding_sek: null,
|
||||
days_overdue: 12,
|
||||
currency: 'USD',
|
||||
},
|
||||
],
|
||||
current: 0,
|
||||
days_1_30: 12475,
|
||||
days_31_60: 0,
|
||||
days_61_90: 0,
|
||||
days_90_plus: 0,
|
||||
total_outstanding: 12475,
|
||||
},
|
||||
],
|
||||
total_outstanding: 12475,
|
||||
total_current: 0,
|
||||
total_overdue: 12475,
|
||||
unpaid_count: 3,
|
||||
unconverted_fx_count: 1,
|
||||
}
|
||||
}
|
||||
|
||||
function makeRequest(query = '') {
|
||||
return new Request(`http://localhost/api/reports/ar-ledger/pdf${query}`)
|
||||
}
|
||||
@@ -149,4 +247,39 @@ describe('GET /api/reports/ar-ledger/pdf', () => {
|
||||
const res = await GET(makeRequest('?as_of_date=2026-06-30'), { params: Promise.resolve({}) } as never)
|
||||
expect(res.status).toBe(500)
|
||||
})
|
||||
|
||||
it('forwards outstanding_sek so the PDF carries the SEK bridge column', async () => {
|
||||
vi.mocked(generateARLedger).mockResolvedValue(makeFxLedger() as never)
|
||||
|
||||
const res = await GET(makeRequest('?as_of_date=2026-06-30'), { params: Promise.resolve({}) } as never)
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const text = renderedDocumentText()
|
||||
// The bridge column the XLSX export has ("Utestående (SEK)") now exists here too.
|
||||
expect(text).toContain('Utest. SEK')
|
||||
expect(text).toContain('11 475,00')
|
||||
// ...and the two units on the page are named rather than left implicit.
|
||||
expect(text).toContain('Åldersfördelning per kund (SEK)')
|
||||
expect(text).toContain('Fakturor (fakturans valuta)')
|
||||
})
|
||||
|
||||
it('marks an unconvertible FX invoice instead of dropping it from the PDF', async () => {
|
||||
vi.mocked(generateARLedger).mockResolvedValue(makeFxLedger() as never)
|
||||
|
||||
await GET(makeRequest('?as_of_date=2026-06-30'), { params: Promise.resolve({}) } as never)
|
||||
|
||||
const text = renderedDocumentText()
|
||||
expect(text).toContain('F003')
|
||||
expect(text).toContain('saknas')
|
||||
expect(text).toContain('1 faktura i utländsk valuta saknar växelkurs')
|
||||
})
|
||||
|
||||
it('leaves a SEK-only PDF without the bridge column', async () => {
|
||||
const res = await GET(makeRequest('?as_of_date=2026-06-30'), { params: Promise.resolve({}) } as never)
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const text = renderedDocumentText()
|
||||
expect(text).not.toContain('Utest. SEK')
|
||||
expect(text).toContain('Fakturor (SEK)')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -39,6 +39,12 @@ export const GET = withRouteContext('report.ar_ledger.pdf', async (request, { su
|
||||
invoice_date: inv.invoice_date,
|
||||
due_date: inv.due_date,
|
||||
outstanding: inv.outstanding,
|
||||
// The SEK bridge: `outstanding` is in the invoice's own currency
|
||||
// while the aging table above it is in SEK, so the PDF needs the
|
||||
// converted amount to let a reader tie the two together. `null`
|
||||
// means the FX rate was missing, i.e. the row is absent from the
|
||||
// aging totals. Matches the XLSX export's "Utestående (SEK)".
|
||||
outstanding_sek: inv.outstanding_sek,
|
||||
currency: inv.currency,
|
||||
days_overdue: inv.days_overdue,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* Contract tests for the kassaflödesanalys PDF route.
|
||||
*
|
||||
* Two of these are load-bearing for the client. `KassaflodesanalysClient` now
|
||||
* fetches this route with `downloadFile` instead of assigning
|
||||
* `window.location.href`, which means:
|
||||
*
|
||||
* 1. The client names the saved file itself, as
|
||||
* `kassaflodesanalys-<report.period_start>.pdf`. It reads period_start from
|
||||
* the sibling JSON route, which returns the same generator output this
|
||||
* route renders, so the two names agree. The Content-Disposition assertion
|
||||
* below pins the server half of that agreement: change the filename here
|
||||
* and the archived statutory artefact silently gets a different name than
|
||||
* the one the browser writes.
|
||||
* 2. The route's error bodies are now read and shown in a toast instead of
|
||||
* being rendered as a raw JSON document after the browser navigated the
|
||||
* whole app away. `getErrorMessage` must therefore find a real sentence in
|
||||
* them rather than falling back to the generic HTTP status text, which is
|
||||
* what the last test checks.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
|
||||
const mockSupabase = {
|
||||
auth: { getUser: vi.fn() },
|
||||
from: vi.fn(),
|
||||
}
|
||||
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: () => Promise.resolve(mockSupabase),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/require-auth', () => ({
|
||||
requireAuth: vi.fn(),
|
||||
}))
|
||||
|
||||
// Stub the renderer so no real PDF layout runs. The primitives the template
|
||||
// imports at module load (StyleSheet.create) still have to exist.
|
||||
vi.mock('@react-pdf/renderer', () => ({
|
||||
renderToBuffer: vi.fn().mockResolvedValue(Buffer.from('%PDF-1.4 test')),
|
||||
StyleSheet: { create: (s: unknown) => s },
|
||||
Document: (p: unknown) => p,
|
||||
Page: (p: unknown) => p,
|
||||
Text: (p: unknown) => p,
|
||||
View: (p: unknown) => p,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/reports/kassaflodesanalys', () => ({
|
||||
generateKassaflodesanalys: vi.fn(),
|
||||
}))
|
||||
|
||||
import { GET } from '../route'
|
||||
import { requireAuth } from '@/lib/auth/require-auth'
|
||||
import { generateKassaflodesanalys } from '@/lib/reports/kassaflodesanalys'
|
||||
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
|
||||
const PERIOD = { period_start: '2025-01-01', period_end: '2025-12-31' }
|
||||
|
||||
function makeReport() {
|
||||
return {
|
||||
fiscal_period_id: 'period-1',
|
||||
...PERIOD,
|
||||
lopande: {
|
||||
resultat_efter_finansiella_poster: 120000,
|
||||
avskrivningar: 20000,
|
||||
ovriga_ej_kassaflodesposter: 0,
|
||||
delta_kortfristiga_fordringar: -5000,
|
||||
delta_varulager: 0,
|
||||
delta_kortfristiga_skulder: 3000,
|
||||
skatt_betald: -25000,
|
||||
total: 113000,
|
||||
},
|
||||
investerings: {
|
||||
forvarv_anlaggningar: -40000,
|
||||
avyttring_anlaggningar: 0,
|
||||
total: -40000,
|
||||
},
|
||||
finansierings: {
|
||||
delta_lan: 0,
|
||||
utdelningar: -30000,
|
||||
nyemission: 0,
|
||||
erhallna_aktieagartillskott: 0,
|
||||
total: -30000,
|
||||
},
|
||||
total_cash_flow: 43000,
|
||||
reconciliation: {
|
||||
opening_cash_1xxx: 100000,
|
||||
closing_cash_1xxx: 143000,
|
||||
delta_actual: 43000,
|
||||
delta_calculated: 43000,
|
||||
mismatch_amount: 0,
|
||||
is_reconciled: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** One chain object serves both queries: select/eq chain, single resolves. */
|
||||
function tableQuery(data: unknown) {
|
||||
return {
|
||||
select: vi.fn().mockReturnThis(),
|
||||
eq: vi.fn().mockReturnThis(),
|
||||
single: vi.fn().mockResolvedValue({ data, error: null }),
|
||||
}
|
||||
}
|
||||
|
||||
let periodRow: unknown = PERIOD
|
||||
let companyRow: unknown = { company_name: 'Testbolaget AB', org_number: '5566778899' }
|
||||
|
||||
function makeRequest(query = '') {
|
||||
return new Request(`http://localhost/api/reports/kassaflodesanalys/pdf${query}`)
|
||||
}
|
||||
|
||||
function call(query = '') {
|
||||
return GET(makeRequest(query), { params: Promise.resolve({}) } as never)
|
||||
}
|
||||
|
||||
describe('GET /api/reports/kassaflodesanalys/pdf', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
periodRow = PERIOD
|
||||
companyRow = { company_name: 'Testbolaget AB', org_number: '5566778899' }
|
||||
vi.mocked(requireAuth).mockResolvedValue({
|
||||
user: mockUser as never,
|
||||
supabase: mockSupabase as never,
|
||||
error: null,
|
||||
})
|
||||
mockSupabase.from.mockImplementation((table: string) =>
|
||||
tableQuery(table === 'fiscal_periods' ? periodRow : companyRow),
|
||||
)
|
||||
vi.mocked(generateKassaflodesanalys).mockResolvedValue(makeReport() as never)
|
||||
})
|
||||
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
vi.mocked(requireAuth).mockResolvedValue({
|
||||
user: null as never,
|
||||
supabase: mockSupabase as never,
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
|
||||
const res = await call('?period_id=period-1')
|
||||
expect(res.status).toBe(401)
|
||||
expect(generateKassaflodesanalys).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 400 when period_id is missing', async () => {
|
||||
const res = await call()
|
||||
expect(res.status).toBe(400)
|
||||
expect(generateKassaflodesanalys).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses to render when the fiscal period cannot be read', async () => {
|
||||
// An identifiable period is part of räkenskapsinformation (BFL 7 kap): a
|
||||
// PDF that cannot be archived with the period it refers to is not produced.
|
||||
periodRow = null
|
||||
|
||||
const res = await call('?period_id=missing')
|
||||
expect(res.status).toBe(400)
|
||||
expect(generateKassaflodesanalys).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 404 when company settings are missing', async () => {
|
||||
companyRow = null
|
||||
|
||||
const res = await call('?period_id=period-1')
|
||||
expect(res.status).toBe(404)
|
||||
expect(generateKassaflodesanalys).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('names the file the way the client saves it', async () => {
|
||||
const res = await call('?period_id=period-1')
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers.get('Content-Type')).toBe('application/pdf')
|
||||
// KassaflodesanalysClient builds exactly this name from the report it
|
||||
// already holds. Both sides read period_start from the same generator
|
||||
// output, so this literal is the contract between them.
|
||||
expect(res.headers.get('Content-Disposition')).toBe(
|
||||
'attachment; filename="kassaflodesanalys-2025-01-01.pdf"',
|
||||
)
|
||||
expect(generateKassaflodesanalys).toHaveBeenCalledWith(mockSupabase, 'company-1', 'period-1')
|
||||
})
|
||||
|
||||
it('returns 500 when the generator throws', async () => {
|
||||
vi.mocked(generateKassaflodesanalys).mockRejectedValue(new Error('boom'))
|
||||
|
||||
const res = await call('?period_id=period-1')
|
||||
expect(res.status).toBe(500)
|
||||
})
|
||||
|
||||
it('answers failures with a body the download toast can show', async () => {
|
||||
// The client passes the parsed body to getErrorMessage. If a body carried
|
||||
// no recognisable sentence, the user would get the generic status text
|
||||
// ("Förfrågan innehåller ogiltiga uppgifter") and learn nothing about which
|
||||
// period or setting is actually missing.
|
||||
periodRow = null
|
||||
const badPeriod = await call('?period_id=missing')
|
||||
const badPeriodBody = await badPeriod.json()
|
||||
expect(getErrorMessage(badPeriodBody, { statusCode: 400 })).toContain(
|
||||
'Räkenskapsperioden kunde inte läsas',
|
||||
)
|
||||
|
||||
periodRow = PERIOD
|
||||
companyRow = null
|
||||
const noSettings = await call('?period_id=period-1')
|
||||
const noSettingsBody = await noSettings.json()
|
||||
expect(getErrorMessage(noSettingsBody, { statusCode: 404 })).toBe(
|
||||
'Företagsinställningar saknas',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -105,12 +105,63 @@ const PAID_INVOICES = Array.from({ length: 5 }, () => ({
|
||||
paid_at: '2026-01-11',
|
||||
}))
|
||||
|
||||
function supplierRow(
|
||||
overrides: Partial<{
|
||||
supplier_id: string
|
||||
total: number
|
||||
total_sek: number | null
|
||||
currency: string
|
||||
exchange_rate: number | null
|
||||
supplier: { id: string; name: string }
|
||||
}> = {}
|
||||
) {
|
||||
const supplierId = overrides.supplier_id ?? 'sup-1'
|
||||
return {
|
||||
supplier_id: supplierId,
|
||||
total: 100,
|
||||
total_sek: null,
|
||||
currency: 'SEK',
|
||||
exchange_rate: null,
|
||||
supplier: { id: supplierId, name: 'Leverantören AB' },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const SUPPLIER_ROWS = [
|
||||
{ supplier_id: 'sup-1', total_sek: 400, total: 400, supplier: { id: 'sup-1', name: 'Leverantören AB' } },
|
||||
{ supplier_id: 'sup-1', total_sek: 100, total: 100, supplier: { id: 'sup-1', name: 'Leverantören AB' } },
|
||||
{ supplier_id: 'sup-2', total_sek: 200, total: 200, supplier: { id: 'sup-2', name: 'Andra AB' } },
|
||||
supplierRow({ supplier_id: 'sup-1', total: 400, total_sek: 400 }),
|
||||
supplierRow({ supplier_id: 'sup-1', total: 100, total_sek: 100 }),
|
||||
supplierRow({
|
||||
supplier_id: 'sup-2',
|
||||
total: 200,
|
||||
total_sek: 200,
|
||||
supplier: { id: 'sup-2', name: 'Andra AB' },
|
||||
}),
|
||||
]
|
||||
|
||||
/**
|
||||
* The realistic shape of an ordinary Swedish supplier invoice: currency SEK,
|
||||
* no exchange rate, and `total_sek` NULL because no conversion ever ran.
|
||||
*/
|
||||
const SEK_ONLY_ROWS = [
|
||||
supplierRow({ supplier_id: 'sup-1', total: 1250, supplier: { id: 'sup-1', name: 'Städbolaget AB' } }),
|
||||
supplierRow({ supplier_id: 'sup-1', total: 750.5, supplier: { id: 'sup-1', name: 'Städbolaget AB' } }),
|
||||
supplierRow({
|
||||
supplier_id: 'sup-2',
|
||||
total: 400,
|
||||
supplier: { id: 'sup-2', name: 'Kontorsvaror AB' },
|
||||
}),
|
||||
]
|
||||
|
||||
/** Queue the six responses the hot path consumes after the fiscal period. */
|
||||
function enqueueHotPath(supplierRows: unknown[]) {
|
||||
enqueue({ data: aggPayload() }) // rpc get_kpi_report_aggregates
|
||||
enqueue({ data: [] }) // rpc compute_prior_opening_balances
|
||||
enqueue({ data: CHART }) // chart_of_accounts
|
||||
enqueue({ data: null }) // extension_data prefs
|
||||
enqueue({ data: [] }) // invoices
|
||||
enqueue({ data: supplierRows }) // supplier_invoices
|
||||
}
|
||||
|
||||
function kpiRequest(searchParams: Record<string, string> = { period_id: 'period-1' }) {
|
||||
return createMockRequest('/api/reports/kpi', { searchParams })
|
||||
}
|
||||
@@ -186,6 +237,7 @@ describe('GET /api/reports/kpi', () => {
|
||||
{ supplier_id: 'sup-1', supplier_name: 'Leverantören AB', total: 500 },
|
||||
{ supplier_id: 'sup-2', supplier_name: 'Andra AB', total: 200 },
|
||||
],
|
||||
topSuppliersUnconvertedFxCount: 0,
|
||||
})
|
||||
|
||||
expect(supabase.rpc).toHaveBeenCalledWith('get_kpi_report_aggregates', {
|
||||
@@ -279,6 +331,83 @@ describe('GET /api/reports/kpi', () => {
|
||||
expect(supabase.rpc).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('populates Största leverantörer for a SEK-only company whose total_sek is NULL', async () => {
|
||||
// Headline regression: total_sek is only written when a conversion runs,
|
||||
// so an ordinary Swedish supplier invoice has it NULL. Reading total_sek
|
||||
// alone left the panel permanently empty for these companies.
|
||||
enqueue({ data: makePeriod() }) // fiscal_periods
|
||||
enqueueHotPath(SEK_ONLY_ROWS)
|
||||
|
||||
const res = await GET(kpiRequest(), noParams)
|
||||
const { status, body } = await parseJsonResponse<{ data: KPIReport }>(res)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.topSuppliers).toEqual([
|
||||
{ supplier_id: 'sup-1', supplier_name: 'Städbolaget AB', total: 2000.5 },
|
||||
{ supplier_id: 'sup-2', supplier_name: 'Kontorsvaror AB', total: 400 },
|
||||
])
|
||||
expect(body.data.topSuppliersUnconvertedFxCount).toBe(0)
|
||||
})
|
||||
|
||||
it('aggregates a mixed SEK/EUR company in SEK', async () => {
|
||||
enqueue({ data: makePeriod() }) // fiscal_periods
|
||||
enqueueHotPath([
|
||||
// SEK invoice: total is the SEK total, exactly.
|
||||
supplierRow({ supplier_id: 'sup-1', total: 1000, supplier: { id: 'sup-1', name: 'Svensk Lev AB' } }),
|
||||
// EUR invoice with a rate: converted at 100 * 11.4567 = 1145.67.
|
||||
supplierRow({
|
||||
supplier_id: 'sup-1',
|
||||
total: 100,
|
||||
currency: 'EUR',
|
||||
exchange_rate: 11.4567,
|
||||
supplier: { id: 'sup-1', name: 'Svensk Lev AB' },
|
||||
}),
|
||||
// EUR invoice with a stored SEK total: that value wins.
|
||||
supplierRow({
|
||||
supplier_id: 'sup-2',
|
||||
total: 200,
|
||||
total_sek: 2300,
|
||||
currency: 'EUR',
|
||||
exchange_rate: 11.5,
|
||||
supplier: { id: 'sup-2', name: 'Euro Supplier GmbH' },
|
||||
}),
|
||||
])
|
||||
|
||||
const res = await GET(kpiRequest(), noParams)
|
||||
const { status, body } = await parseJsonResponse<{ data: KPIReport }>(res)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.topSuppliers).toEqual([
|
||||
{ supplier_id: 'sup-2', supplier_name: 'Euro Supplier GmbH', total: 2300 },
|
||||
{ supplier_id: 'sup-1', supplier_name: 'Svensk Lev AB', total: 2145.67 },
|
||||
])
|
||||
expect(body.data.topSuppliersUnconvertedFxCount).toBe(0)
|
||||
})
|
||||
|
||||
it('reports an unconvertible FX invoice instead of silently dropping it', async () => {
|
||||
enqueue({ data: makePeriod() }) // fiscal_periods
|
||||
enqueueHotPath([
|
||||
supplierRow({ supplier_id: 'sup-1', total: 1000, supplier: { id: 'sup-1', name: 'Svensk Lev AB' } }),
|
||||
// USD, no rate and no SEK total: cannot be expressed in SEK.
|
||||
supplierRow({
|
||||
supplier_id: 'sup-2',
|
||||
total: 500,
|
||||
currency: 'USD',
|
||||
supplier: { id: 'sup-2', name: 'US Vendor Inc' },
|
||||
}),
|
||||
])
|
||||
|
||||
const res = await GET(kpiRequest(), noParams)
|
||||
const { status, body } = await parseJsonResponse<{ data: KPIReport }>(res)
|
||||
|
||||
expect(status).toBe(200)
|
||||
// The raw 500 USD is neither added as SEK nor hidden: it is counted.
|
||||
expect(body.data.topSuppliers).toEqual([
|
||||
{ supplier_id: 'sup-1', supplier_name: 'Svensk Lev AB', total: 1000 },
|
||||
])
|
||||
expect(body.data.topSuppliersUnconvertedFxCount).toBe(1)
|
||||
})
|
||||
|
||||
it('returns 500 when the aggregates RPC fails', async () => {
|
||||
enqueue({ data: makePeriod() }) // fiscal_periods
|
||||
enqueue({ data: null, error: { message: 'connection reset' } }) // rpc get_kpi_report_aggregates
|
||||
|
||||
@@ -23,6 +23,9 @@ import {
|
||||
calculateExpenseRatio,
|
||||
calculateAvgPaymentDays,
|
||||
calculateVatLiability,
|
||||
aggregateTopSuppliers,
|
||||
fetchTopSupplierInvoices,
|
||||
type KpiSupplierInvoiceRow,
|
||||
} from '@/lib/reports/kpi'
|
||||
import { mergeWithDefaults } from '@/lib/reports/kpi-definitions'
|
||||
import { parseDimensionFilterParams } from '@/lib/reports/dimension-filter'
|
||||
@@ -79,14 +82,10 @@ export const GET = withRouteContext('report.kpi', async (request, { supabase, co
|
||||
.eq('company_id', companyId)
|
||||
.eq('status', 'paid')
|
||||
.not('paid_at', 'is', null)
|
||||
// Paginated: awaiting the bare query capped the rows at PostgREST's 1000
|
||||
// default and silently corrupted the supplier totals for large companies.
|
||||
const topSuppliersQuery = () =>
|
||||
supabase
|
||||
.from('supplier_invoices')
|
||||
.select('supplier_id, total_sek, total, supplier:suppliers(id, name)')
|
||||
.eq('company_id', companyId)
|
||||
.gte('invoice_date', period.period_start)
|
||||
.lte('invoice_date', period.period_end)
|
||||
.neq('status', 'credited')
|
||||
fetchTopSupplierInvoices(supabase, companyId, period.period_start, period.period_end)
|
||||
|
||||
let prefsValue: unknown
|
||||
let incomeStatement: IncomeStatementReport
|
||||
@@ -252,40 +251,17 @@ export const GET = withRouteContext('report.kpi', async (request, { supabase, co
|
||||
.sort((a, b) => b.total - a.total)
|
||||
.slice(0, 5)
|
||||
|
||||
// Top suppliers by spend within the fiscal period. Sum total_sek to avoid
|
||||
// mixing currencies. Drop FX invoices without a SEK conversion (total_sek
|
||||
// null): they would otherwise inflate a supplier's total with raw
|
||||
// foreign-currency amounts.
|
||||
type SupplierInvoiceRow = {
|
||||
supplier_id: string | null
|
||||
total_sek: number | null
|
||||
total: number | null
|
||||
supplier: { id: string; name: string } | { id: string; name: string }[] | null
|
||||
}
|
||||
// Top suppliers by spend within the fiscal period, in SEK. The per-row SEK
|
||||
// resolution and the FX exclusion count both live in aggregateTopSuppliers,
|
||||
// which the xlsx export calls with the same query, so the two reports cannot
|
||||
// disagree about the same company.
|
||||
if (topSuppliersResult.error) {
|
||||
// Surface the failure rather than silently rendering an empty chart that
|
||||
// matches the legitimate "no supplier invoices" empty state.
|
||||
console.error('[kpi] topSuppliersResult error:', topSuppliersResult.error)
|
||||
}
|
||||
const supplierTotals = new Map<string, { name: string; total: number }>()
|
||||
for (const row of (topSuppliersResult.data ?? []) as SupplierInvoiceRow[]) {
|
||||
if (!row.supplier_id) continue
|
||||
const supplier = Array.isArray(row.supplier) ? row.supplier[0] : row.supplier
|
||||
if (!supplier?.name) continue
|
||||
const amount = row.total_sek ?? null
|
||||
if (amount == null) continue
|
||||
const existing = supplierTotals.get(row.supplier_id)
|
||||
if (existing) existing.total += amount
|
||||
else supplierTotals.set(row.supplier_id, { name: supplier.name, total: amount })
|
||||
}
|
||||
const topSuppliers = Array.from(supplierTotals.entries())
|
||||
.map(([supplier_id, v]) => ({
|
||||
supplier_id,
|
||||
supplier_name: v.name,
|
||||
total: Math.round(v.total * 100) / 100,
|
||||
}))
|
||||
.sort((a, b) => b.total - a.total)
|
||||
.slice(0, 7)
|
||||
const { suppliers: topSuppliers, unconvertedFxCount: topSuppliersUnconvertedFxCount } =
|
||||
aggregateTopSuppliers((topSuppliersResult.data ?? []) as KpiSupplierInvoiceRow[])
|
||||
|
||||
const report: KPIReport = {
|
||||
netResult: incomeStatement.net_result,
|
||||
@@ -309,6 +285,7 @@ export const GET = withRouteContext('report.kpi', async (request, { supabase, co
|
||||
},
|
||||
topExpenseAccounts,
|
||||
topSuppliers,
|
||||
topSuppliersUnconvertedFxCount,
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: report })
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import * as XLSX from 'xlsx'
|
||||
import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
|
||||
import type { KPIReport } from '@/types'
|
||||
|
||||
const { supabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
|
||||
const requireAuthMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-auth', () => ({
|
||||
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
// The heavy report generators are stubbed: this suite is about the supplier
|
||||
// figures. The pure builders the KPI JSON hot path uses stay real so the
|
||||
// cross-route consistency test compares two genuinely computed reports.
|
||||
vi.mock('@/lib/reports/trial-balance', () => ({
|
||||
generateTrialBalance: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/lib/reports/ar-ledger', () => ({
|
||||
generateARLedger: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/lib/reports/income-statement', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/reports/income-statement')>()
|
||||
return { ...actual, generateIncomeStatement: vi.fn() }
|
||||
})
|
||||
vi.mock('@/lib/reports/monthly-breakdown', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/reports/monthly-breakdown')>()
|
||||
return { ...actual, generateMonthlyBreakdown: vi.fn() }
|
||||
})
|
||||
|
||||
import { GET } from '../route'
|
||||
import { GET as GET_JSON } from '../../route'
|
||||
import { generateTrialBalance } from '@/lib/reports/trial-balance'
|
||||
import { generateARLedger } from '@/lib/reports/ar-ledger'
|
||||
import { generateIncomeStatement } from '@/lib/reports/income-statement'
|
||||
import { generateMonthlyBreakdown } from '@/lib/reports/monthly-breakdown'
|
||||
|
||||
const mockTrialBalance = vi.mocked(generateTrialBalance)
|
||||
const mockARLedger = vi.mocked(generateARLedger)
|
||||
const mockIncomeStatement = vi.mocked(generateIncomeStatement)
|
||||
const mockMonthlyBreakdown = vi.mocked(generateMonthlyBreakdown)
|
||||
|
||||
const noParams = { params: Promise.resolve({}) }
|
||||
|
||||
function authed() {
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
|
||||
}
|
||||
|
||||
function unauthed() {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: null,
|
||||
supabase,
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
}
|
||||
|
||||
const PERIOD = {
|
||||
period_start: '2026-01-01',
|
||||
period_end: '2026-03-31',
|
||||
is_closed: false,
|
||||
}
|
||||
|
||||
function supplierRow(
|
||||
overrides: Partial<{
|
||||
supplier_id: string
|
||||
total: number
|
||||
total_sek: number | null
|
||||
currency: string
|
||||
exchange_rate: number | null
|
||||
supplier: { id: string; name: string }
|
||||
}> = {}
|
||||
) {
|
||||
const supplierId = overrides.supplier_id ?? 'sup-1'
|
||||
return {
|
||||
supplier_id: supplierId,
|
||||
total: 100,
|
||||
total_sek: null,
|
||||
currency: 'SEK',
|
||||
exchange_rate: null,
|
||||
supplier: { id: supplierId, name: 'Leverantören AB' },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An ordinary Swedish supplier invoice: currency SEK, no exchange rate and
|
||||
* `total_sek` NULL because no conversion ever ran.
|
||||
*/
|
||||
const SEK_ONLY_ROWS = [
|
||||
supplierRow({ supplier_id: 'sup-1', total: 1250, supplier: { id: 'sup-1', name: 'Städbolaget AB' } }),
|
||||
supplierRow({ supplier_id: 'sup-1', total: 750.5, supplier: { id: 'sup-1', name: 'Städbolaget AB' } }),
|
||||
supplierRow({
|
||||
supplier_id: 'sup-2',
|
||||
total: 400,
|
||||
supplier: { id: 'sup-2', name: 'Kontorsvaror AB' },
|
||||
}),
|
||||
]
|
||||
|
||||
const MIXED_ROWS = [
|
||||
supplierRow({ supplier_id: 'sup-1', total: 1000, supplier: { id: 'sup-1', name: 'Svensk Lev AB' } }),
|
||||
supplierRow({
|
||||
supplier_id: 'sup-1',
|
||||
total: 100,
|
||||
currency: 'EUR',
|
||||
exchange_rate: 11.4567,
|
||||
supplier: { id: 'sup-1', name: 'Svensk Lev AB' },
|
||||
}),
|
||||
supplierRow({
|
||||
supplier_id: 'sup-2',
|
||||
total: 200,
|
||||
total_sek: 2300,
|
||||
currency: 'EUR',
|
||||
exchange_rate: 11.5,
|
||||
supplier: { id: 'sup-2', name: 'Euro Supplier GmbH' },
|
||||
}),
|
||||
// USD without a rate and without a SEK total: unconvertible.
|
||||
supplierRow({
|
||||
supplier_id: 'sup-3',
|
||||
total: 500,
|
||||
currency: 'USD',
|
||||
supplier: { id: 'sup-3', name: 'US Vendor Inc' },
|
||||
}),
|
||||
]
|
||||
|
||||
function xlsxRequest(searchParams: Record<string, string> = { period_id: 'period-1' }) {
|
||||
return createMockRequest('/api/reports/kpi/xlsx', { searchParams })
|
||||
}
|
||||
|
||||
/** Queue the four responses the xlsx route consumes, in call order. */
|
||||
function enqueueXlsx(supplierRows: unknown[]) {
|
||||
enqueue({ data: PERIOD }) // fiscal_periods
|
||||
enqueue({ data: { company_name: 'Acme AB' } }) // company_settings
|
||||
enqueue({ data: [] }) // invoices (paid)
|
||||
enqueue({ data: supplierRows }) // supplier_invoices
|
||||
}
|
||||
|
||||
/** Parse the produced workbook. The response body can only be read once. */
|
||||
async function readWorkbook(res: Response): Promise<XLSX.WorkBook> {
|
||||
const buf = Buffer.from(await res.arrayBuffer())
|
||||
return XLSX.read(new Uint8Array(buf), { type: 'array' })
|
||||
}
|
||||
|
||||
/** Read one sheet back out of the workbook as label/value pairs. */
|
||||
function sheetRows(wb: XLSX.WorkBook, sheetName: string): [string, unknown][] {
|
||||
const sheet = wb.Sheets[sheetName]
|
||||
expect(sheet, `sheet ${sheetName} missing`).toBeDefined()
|
||||
const rows = XLSX.utils.sheet_to_json<unknown[]>(sheet, { header: 1 })
|
||||
// Drop the header row.
|
||||
return rows.slice(1).map((r) => [String(r[0]), r[1]] as [string, unknown])
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
authed()
|
||||
mockARLedger.mockResolvedValue({ total_outstanding: 0, total_overdue: 0 } as never)
|
||||
mockIncomeStatement.mockResolvedValue({
|
||||
revenue_sections: [],
|
||||
total_revenue: 0,
|
||||
expense_sections: [],
|
||||
total_expenses: 0,
|
||||
financial_sections: [],
|
||||
total_financial: 0,
|
||||
net_result: 0,
|
||||
period: { start: '2026-01-01', end: '2026-03-31' },
|
||||
})
|
||||
mockTrialBalance.mockResolvedValue({ rows: [], totalDebit: 0, totalCredit: 0, isBalanced: true })
|
||||
mockMonthlyBreakdown.mockResolvedValue({ months: [] })
|
||||
})
|
||||
|
||||
describe('GET /api/reports/kpi/xlsx', () => {
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
unauthed()
|
||||
const res = await GET(xlsxRequest(), noParams)
|
||||
expect(res.status).toBe(401)
|
||||
expect(supabase.from).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 400 when period_id is missing', async () => {
|
||||
const res = await GET(xlsxRequest({}), noParams)
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 404 for an unknown fiscal period', async () => {
|
||||
enqueue({ data: null }) // fiscal_periods
|
||||
enqueue({ data: null }) // company_settings
|
||||
const res = await GET(xlsxRequest(), noParams)
|
||||
const { status } = await parseJsonResponse(res)
|
||||
expect(status).toBe(404)
|
||||
})
|
||||
|
||||
it('fills Topp leverantörer for a SEK-only company whose total_sek is NULL', async () => {
|
||||
// Headline regression: reading total_sek alone produced an empty sheet for
|
||||
// every ordinary Swedish company.
|
||||
enqueueXlsx(SEK_ONLY_ROWS)
|
||||
|
||||
const res = await GET(xlsxRequest(), noParams)
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
expect(sheetRows(await readWorkbook(res), 'Topp leverantörer')).toEqual([
|
||||
['Städbolaget AB', 2000.5],
|
||||
['Kontorsvaror AB', 400],
|
||||
])
|
||||
})
|
||||
|
||||
it('aggregates mixed SEK/EUR rows and reports the unconvertible FX invoice', async () => {
|
||||
enqueueXlsx(MIXED_ROWS)
|
||||
|
||||
const res = await GET(xlsxRequest(), noParams)
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const wb = await readWorkbook(res)
|
||||
expect(sheetRows(wb, 'Topp leverantörer')).toEqual([
|
||||
['Euro Supplier GmbH', 2300],
|
||||
['Svensk Lev AB', 2145.67],
|
||||
])
|
||||
|
||||
// The 500 USD row is neither converted at a made-up rate nor dropped in
|
||||
// silence: the export states how many rows it could not convert.
|
||||
expect(sheetRows(wb, 'Nyckeltal (övrigt)')).toContainEqual([
|
||||
'Ej omräknade valutafakturor (leverantörer)',
|
||||
1,
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('KPI JSON and xlsx agree', () => {
|
||||
it('reports identical supplier totals for the same company and period', async () => {
|
||||
// JSON route (hot path) queue order.
|
||||
enqueue({ data: { id: 'period-1', company_id: 'company-1', ...PERIOD, opening_balance_entry_id: null } })
|
||||
enqueue({ data: { tb: [], tb_ex_year_end: [], ob: [], monthly: [] } }) // aggregates RPC
|
||||
enqueue({ data: [] }) // compute_prior_opening_balances
|
||||
enqueue({ data: [] }) // chart_of_accounts
|
||||
enqueue({ data: null }) // extension_data prefs
|
||||
enqueue({ data: [] }) // invoices
|
||||
enqueue({ data: MIXED_ROWS }) // supplier_invoices
|
||||
|
||||
const jsonRes = await GET_JSON(
|
||||
createMockRequest('/api/reports/kpi', { searchParams: { period_id: 'period-1' } }),
|
||||
noParams
|
||||
)
|
||||
const { body } = await parseJsonResponse<{ data: KPIReport }>(jsonRes)
|
||||
|
||||
// xlsx route over the identical supplier rows.
|
||||
enqueueXlsx(MIXED_ROWS)
|
||||
const xlsxRes = await GET(xlsxRequest(), noParams)
|
||||
const wb = await readWorkbook(xlsxRes)
|
||||
const suppliers = sheetRows(wb, 'Topp leverantörer')
|
||||
const other = sheetRows(wb, 'Nyckeltal (övrigt)')
|
||||
|
||||
expect(suppliers).toEqual(
|
||||
body.data.topSuppliers.map((s) => [s.supplier_name, s.total])
|
||||
)
|
||||
expect(other).toContainEqual([
|
||||
'Ej omräknade valutafakturor (leverantörer)',
|
||||
body.data.topSuppliersUnconvertedFxCount,
|
||||
])
|
||||
expect(body.data.topSuppliersUnconvertedFxCount).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -10,6 +10,9 @@ import {
|
||||
calculateExpenseRatio,
|
||||
calculateAvgPaymentDays,
|
||||
calculateVatLiability,
|
||||
aggregateTopSuppliers,
|
||||
fetchTopSupplierInvoices,
|
||||
type KpiSupplierInvoiceRow,
|
||||
} from '@/lib/reports/kpi'
|
||||
import {
|
||||
reportToWorkbook,
|
||||
@@ -87,13 +90,9 @@ export const GET = withRouteContext('report.kpi.xlsx', async (request, { supabas
|
||||
.eq('company_id', companyId)
|
||||
.eq('status', 'paid')
|
||||
.not('paid_at', 'is', null),
|
||||
supabase
|
||||
.from('supplier_invoices')
|
||||
.select('supplier_id, total_sek, total, supplier:suppliers(id, name)')
|
||||
.eq('company_id', companyId)
|
||||
.gte('invoice_date', period.period_start)
|
||||
.lte('invoice_date', period.period_end)
|
||||
.neq('status', 'credited'),
|
||||
// Paginated past PostgREST's 1000-row cap so the export sums every
|
||||
// supplier invoice in the period, same as the KPI JSON route.
|
||||
fetchTopSupplierInvoices(supabase, companyId, period.period_start, period.period_end),
|
||||
])
|
||||
|
||||
const cashPosition = calculateCashPosition(trialBalanceResult.rows)
|
||||
@@ -119,30 +118,12 @@ export const GET = withRouteContext('report.kpi.xlsx', async (request, { supabas
|
||||
{ class4: 0, class5: 0, class6: 0, class7: 0 },
|
||||
)
|
||||
|
||||
type SupplierInvoiceRow = {
|
||||
supplier_id: string | null
|
||||
total_sek: number | null
|
||||
total: number | null
|
||||
supplier: { id: string; name: string } | { id: string; name: string }[] | null
|
||||
}
|
||||
const supplierTotals = new Map<string, { name: string; total: number }>()
|
||||
for (const row of (topSuppliersResult.data ?? []) as SupplierInvoiceRow[]) {
|
||||
if (!row.supplier_id) continue
|
||||
const supplier = Array.isArray(row.supplier) ? row.supplier[0] : row.supplier
|
||||
if (!supplier?.name) continue
|
||||
const amount = row.total_sek ?? null
|
||||
if (amount == null) continue
|
||||
const existing = supplierTotals.get(row.supplier_id)
|
||||
if (existing) existing.total += amount
|
||||
else supplierTotals.set(row.supplier_id, { name: supplier.name, total: amount })
|
||||
}
|
||||
const topSuppliers = Array.from(supplierTotals.values())
|
||||
.map((v) => ({
|
||||
supplier_name: v.name,
|
||||
total: Math.round(v.total * 100) / 100,
|
||||
}))
|
||||
.sort((a, b) => b.total - a.total)
|
||||
.slice(0, 7)
|
||||
// Same query, same aggregation as the KPI JSON route: both go through
|
||||
// topSupplierInvoicesQuery + aggregateTopSuppliers, so the export and the
|
||||
// in-app panel report identical supplier totals for a given period.
|
||||
const { suppliers: topSuppliers, unconvertedFxCount } = aggregateTopSuppliers(
|
||||
(topSuppliersResult.data ?? []) as KpiSupplierInvoiceRow[],
|
||||
)
|
||||
|
||||
// Sheet 1: scalar KPIs, label + value. Currency by default; percent rows
|
||||
// are split into a separate sheet so the formatting is unambiguous.
|
||||
@@ -166,6 +147,9 @@ export const GET = withRouteContext('report.kpi.xlsx', async (request, { supabas
|
||||
|
||||
const integerKpis: KpiKv[] = [
|
||||
{ label: 'Genomsnittliga betaldagar', value: calculateAvgPaymentDays(paidInvoices) },
|
||||
// Antal valutafakturor som saknar både SEK-belopp och kurs och därför
|
||||
// inte kan räknas in i "Topp leverantörer". 0 = inget är exkluderat.
|
||||
{ label: 'Ej omräknade valutafakturor (leverantörer)', value: unconvertedFxCount },
|
||||
]
|
||||
|
||||
const monthRows: MonthRow[] = monthlyBreakdown.months
|
||||
|
||||
+223
-6
@@ -13,7 +13,14 @@ vi.mock('@/lib/company/context', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/bookkeeping/currency-utils', () => ({
|
||||
resolveSekAmount: vi.fn((amount: number) => amount),
|
||||
resolveSekAmount: vi.fn(
|
||||
(
|
||||
amount: number,
|
||||
_amountSek: number | null,
|
||||
currency: string | null,
|
||||
rate: number | null
|
||||
) => (currency && currency !== 'SEK' && rate ? amount * rate : amount)
|
||||
),
|
||||
}))
|
||||
|
||||
import { GET } from '../route'
|
||||
@@ -23,12 +30,20 @@ interface QueryResult {
|
||||
error: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* The invoices query is paginated via fetchAllRows (.range per page), so the
|
||||
* mock serves one page result per .range() call. A single QueryResult is a
|
||||
* one-page company; pass an array to exercise multi-page paging.
|
||||
*/
|
||||
function buildSupabase(
|
||||
supplier: { id: string; name: string } | null,
|
||||
invoicesResult: QueryResult,
|
||||
invoicesResults: QueryResult | QueryResult[],
|
||||
entriesResult: QueryResult
|
||||
) {
|
||||
return {
|
||||
const pages = Array.isArray(invoicesResults) ? [...invoicesResults] : [invoicesResults]
|
||||
const rangeCalls: Array<[number, number]> = []
|
||||
const orderCalls: unknown[][] = []
|
||||
const supabase = {
|
||||
from: vi.fn().mockImplementation((table: string) => {
|
||||
if (table === 'suppliers') {
|
||||
return {
|
||||
@@ -42,9 +57,14 @@ function buildSupabase(
|
||||
select: vi.fn().mockReturnThis(),
|
||||
eq: vi.fn().mockReturnThis(),
|
||||
in: vi.fn().mockReturnThis(),
|
||||
order: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn().mockReturnThis(),
|
||||
then: (resolve: (v: QueryResult) => void) => resolve(invoicesResult),
|
||||
order: vi.fn().mockImplementation(function (this: unknown, ...args: unknown[]) {
|
||||
orderCalls.push(args)
|
||||
return this
|
||||
}),
|
||||
range: vi.fn().mockImplementation((from: number, to: number) => {
|
||||
rangeCalls.push([from, to])
|
||||
return Promise.resolve(pages.shift() ?? { data: [], error: null })
|
||||
}),
|
||||
}
|
||||
}
|
||||
// journal_entries
|
||||
@@ -56,6 +76,7 @@ function buildSupabase(
|
||||
}
|
||||
}),
|
||||
}
|
||||
return Object.assign(supabase, { rangeCalls, orderCalls })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -149,4 +170,200 @@ describe('GET /api/reports/supplier-ledger/supplier/[supplierId]/invoices', () =
|
||||
expect(body.data.lines[0].voucher_number).toBe(33)
|
||||
expect(body.data.lines[0].credit).toBe(2500)
|
||||
})
|
||||
|
||||
it('never reports a foreign amount as SEK when the exchange rate is missing', async () => {
|
||||
// 1 000 EUR with no rate: there is no SEK figure for the 2440 balance, so
|
||||
// the Kredit column (always SEK) must not be handed the EUR number.
|
||||
const invoices = [
|
||||
{
|
||||
id: 'si-fx',
|
||||
supplier_invoice_number: 'EUR-1',
|
||||
invoice_date: '2026-05-10',
|
||||
due_date: '2026-06-10',
|
||||
total: 1000,
|
||||
paid_amount: 0,
|
||||
remaining_amount: 1000,
|
||||
currency: 'EUR',
|
||||
exchange_rate: null,
|
||||
registration_journal_entry_id: null,
|
||||
},
|
||||
]
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: { id: 'user-1' },
|
||||
supabase: buildSupabase(
|
||||
{ id: 'sup-1', name: 'Euro Supply GmbH' },
|
||||
{ data: invoices, error: null },
|
||||
{ data: [], error: null }
|
||||
),
|
||||
error: null,
|
||||
})
|
||||
const req = createMockRequest(
|
||||
'/api/reports/supplier-ledger/supplier/sup-1/invoices'
|
||||
)
|
||||
const res = await GET(req, createMockRouteParams({ supplierId: 'sup-1' }))
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
unconverted_fx_count: number
|
||||
lines: Array<{
|
||||
credit: number
|
||||
remaining: number
|
||||
remaining_sek: number | null
|
||||
currency: string
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
||||
const line = body.data.lines[0]
|
||||
// The EUR amount must not leak into the SEK column.
|
||||
expect(line.credit).not.toBe(1000)
|
||||
expect(line.credit).toBe(0)
|
||||
// ... but the invoice stays visible, with its own-currency amount intact.
|
||||
expect(line.remaining).toBe(1000)
|
||||
expect(line.currency).toBe('EUR')
|
||||
// Explicit null marker + count, same contract as the ledger report.
|
||||
expect(line.remaining_sek).toBeNull()
|
||||
expect(body.data.unconverted_fx_count).toBe(1)
|
||||
})
|
||||
|
||||
it('lets the consumer tell an unconverted row from a settled one', async () => {
|
||||
const invoices = [
|
||||
// Unconvertible: SEK value unknown.
|
||||
{
|
||||
id: 'si-fx',
|
||||
supplier_invoice_number: 'EUR-1',
|
||||
invoice_date: '2026-05-10',
|
||||
due_date: '2026-06-10',
|
||||
total: 1000,
|
||||
paid_amount: 0,
|
||||
remaining_amount: 1000,
|
||||
currency: 'EUR',
|
||||
exchange_rate: null,
|
||||
registration_journal_entry_id: null,
|
||||
},
|
||||
// Convertible: 100 EUR at 11.50.
|
||||
{
|
||||
id: 'si-fx-rate',
|
||||
supplier_invoice_number: 'EUR-2',
|
||||
invoice_date: '2026-05-11',
|
||||
due_date: '2026-06-11',
|
||||
total: 100,
|
||||
paid_amount: 0,
|
||||
remaining_amount: 100,
|
||||
currency: 'EUR',
|
||||
exchange_rate: 11.5,
|
||||
registration_journal_entry_id: null,
|
||||
},
|
||||
// Genuinely settled: nothing left on 2440.
|
||||
{
|
||||
id: 'si-settled',
|
||||
supplier_invoice_number: 'SEK-1',
|
||||
invoice_date: '2026-05-12',
|
||||
due_date: '2026-06-12',
|
||||
total: 500,
|
||||
paid_amount: 500,
|
||||
remaining_amount: 0,
|
||||
currency: 'SEK',
|
||||
exchange_rate: null,
|
||||
registration_journal_entry_id: null,
|
||||
},
|
||||
]
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: { id: 'user-1' },
|
||||
supabase: buildSupabase(
|
||||
{ id: 'sup-1', name: 'Euro Supply GmbH' },
|
||||
{ data: invoices, error: null },
|
||||
{ data: [], error: null }
|
||||
),
|
||||
error: null,
|
||||
})
|
||||
const req = createMockRequest(
|
||||
'/api/reports/supplier-ledger/supplier/sup-1/invoices'
|
||||
)
|
||||
const res = await GET(req, createMockRouteParams({ supplierId: 'sup-1' }))
|
||||
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
unconverted_fx_count: number
|
||||
lines: Array<{
|
||||
supplier_invoice_id: string
|
||||
credit: number
|
||||
remaining_sek: number | null
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
||||
const byId = new Map(body.data.lines.map((l) => [l.supplier_invoice_id, l]))
|
||||
// Unknown SEK value: null, not 0.
|
||||
expect(byId.get('si-fx')!.remaining_sek).toBeNull()
|
||||
// Settled: 0, not null.
|
||||
expect(byId.get('si-settled')!.remaining_sek).toBe(0)
|
||||
// Converted rows are unaffected: still the SEK amount in the Kredit column.
|
||||
expect(byId.get('si-fx-rate')!.remaining_sek).toBe(1150)
|
||||
expect(byId.get('si-fx-rate')!.credit).toBe(1150)
|
||||
expect(body.data.unconverted_fx_count).toBe(1)
|
||||
})
|
||||
|
||||
it('paginates past the old hardcoded cap and counts FX rows over the full set', async () => {
|
||||
// 1000 SEK invoices fill page one; page two carries one more SEK invoice
|
||||
// plus an unconvertible FX invoice. The old 500-row limit truncated both
|
||||
// the lines AND unconverted_fx_count while next_cursor: null claimed the
|
||||
// list was complete.
|
||||
const makeInvoice = (i: number) => ({
|
||||
id: `si-${i}`,
|
||||
supplier_invoice_number: `INV-${i}`,
|
||||
invoice_date: '2026-05-10',
|
||||
due_date: '2026-06-10',
|
||||
total: 100,
|
||||
paid_amount: 0,
|
||||
remaining_amount: 100,
|
||||
currency: 'SEK',
|
||||
exchange_rate: null,
|
||||
registration_journal_entry_id: null,
|
||||
})
|
||||
const page1 = Array.from({ length: 1000 }, (_, i) => makeInvoice(i))
|
||||
const page2 = [
|
||||
makeInvoice(1000),
|
||||
{ ...makeInvoice(1001), currency: 'EUR', exchange_rate: null },
|
||||
]
|
||||
|
||||
const supabase = buildSupabase(
|
||||
{ id: 'sup-1', name: 'Volym AB' },
|
||||
[
|
||||
{ data: page1, error: null },
|
||||
{ data: page2, error: null },
|
||||
],
|
||||
{ data: [], error: null }
|
||||
)
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
|
||||
|
||||
const res = await GET(
|
||||
createMockRequest('/api/reports/supplier-ledger/supplier/sup-1/invoices'),
|
||||
createMockRouteParams({ supplierId: 'sup-1' })
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
lines: Array<{ remaining_sek: number | null }>
|
||||
unconverted_fx_count: number
|
||||
next_cursor: null
|
||||
}
|
||||
}
|
||||
|
||||
// Every row made it through, not just the first page.
|
||||
expect(body.data.lines).toHaveLength(1002)
|
||||
expect(supabase.rangeCalls).toEqual([
|
||||
[0, 999],
|
||||
[1000, 1999],
|
||||
])
|
||||
// Stable paging order: invoice_date is not unique, so id must break ties.
|
||||
expect(supabase.orderCalls).toContainEqual(['invoice_date', { ascending: true }])
|
||||
expect(supabase.orderCalls).toContainEqual(['id', { ascending: true }])
|
||||
// The honesty counter sees the FX row on page two.
|
||||
expect(body.data.unconverted_fx_count).toBe(1)
|
||||
// The shape is unchanged, and the null cursor is now truthful.
|
||||
expect(body.data.next_cursor).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import type { ReportSourceLine } from '@/lib/reports/source-lines'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
|
||||
@@ -10,9 +11,16 @@ import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-m
|
||||
* Returns the supplier invoices behind a supplier's outstanding balance.
|
||||
* Each row's `journal_entry_id` points at the registration journal entry
|
||||
* (when posted) so the UI can link to `/bookkeeping/[id]`.
|
||||
*
|
||||
* Currency contract (mirrors `generateSupplierLedger`): `credit` is the open
|
||||
* balance on 2440 and is therefore always SEK. A foreign-currency invoice with
|
||||
* no `exchange_rate` has no known SEK amount, so it gets `remaining_sek: null`
|
||||
* and `credit: 0` instead of its raw foreign amount, and is counted in
|
||||
* `unconverted_fx_count`. A consumer tells the two apart on `remaining_sek`:
|
||||
* `null` means "SEK value unknown, excluded from the ledger totals", `0` means
|
||||
* "genuinely settled". `remaining` (+ `currency`) always carries the invoice's
|
||||
* own-currency amount, so the row stays visible and readable either way.
|
||||
*/
|
||||
const PAGE_LIMIT = 500
|
||||
|
||||
export const GET = withRouteContext<{ params: Promise<{ supplierId: string }> }>(
|
||||
'report.supplier_ledger.invoices',
|
||||
async (request, { supabase, companyId }, { params }) => {
|
||||
@@ -31,32 +39,42 @@ export const GET = withRouteContext<{ params: Promise<{ supplierId: string }> }>
|
||||
|
||||
// Mirror `generateSupplierLedger`'s filter: registered/approved/partially
|
||||
// paid/overdue invoices that still have an outstanding balance.
|
||||
const { data, error } = await supabase
|
||||
.from('supplier_invoices')
|
||||
.select(`
|
||||
id,
|
||||
supplier_invoice_number,
|
||||
invoice_date,
|
||||
due_date,
|
||||
total,
|
||||
paid_amount,
|
||||
remaining_amount,
|
||||
currency,
|
||||
exchange_rate,
|
||||
registration_journal_entry_id
|
||||
`)
|
||||
.eq('company_id', companyId)
|
||||
.eq('supplier_id', supplierId)
|
||||
.in('status', ['registered', 'approved', 'partially_paid', 'overdue'])
|
||||
.order('invoice_date', { ascending: true })
|
||||
.limit(PAGE_LIMIT)
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
|
||||
}
|
||||
|
||||
//
|
||||
// fetchAllRows: bounded by one supplier's OPEN invoices, so the volume is
|
||||
// naturally small, but the previous hardcoded 500-row .limit() silently
|
||||
// truncated the page for outliers while next_cursor: null claimed the list
|
||||
// was complete, and `unconverted_fx_count` was computed over the truncated
|
||||
// page (the honesty counter under-reported). The secondary .order('id')
|
||||
// gives .range() paging the stable total order it needs (invoice_date is
|
||||
// not unique).
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const invoices = (data || []) as any[]
|
||||
let invoices: any[]
|
||||
try {
|
||||
invoices = await fetchAllRows(({ from, to }) =>
|
||||
supabase
|
||||
.from('supplier_invoices')
|
||||
.select(`
|
||||
id,
|
||||
supplier_invoice_number,
|
||||
invoice_date,
|
||||
due_date,
|
||||
total,
|
||||
paid_amount,
|
||||
remaining_amount,
|
||||
currency,
|
||||
exchange_rate,
|
||||
registration_journal_entry_id
|
||||
`)
|
||||
.eq('company_id', companyId)
|
||||
.eq('supplier_id', supplierId)
|
||||
.in('status', ['registered', 'approved', 'partially_paid', 'overdue'])
|
||||
.order('invoice_date', { ascending: true })
|
||||
.order('id', { ascending: true })
|
||||
.range(from, to)
|
||||
)
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: getUserErrorMessage(err) }, { status: 500 })
|
||||
}
|
||||
|
||||
// Pull the registration entries in one batch to get voucher numbers.
|
||||
const entryIds = invoices
|
||||
@@ -86,6 +104,9 @@ export const GET = withRouteContext<{ params: Promise<{ supplierId: string }> }>
|
||||
const lines: (ReportSourceLine & {
|
||||
supplier_invoice_id: string
|
||||
supplier_invoice_number: string
|
||||
/** Open amount in the invoice's own currency. Display only: never summed. */
|
||||
remaining: number
|
||||
/** `remaining` in SEK, or `null` when the FX rate is missing. */
|
||||
remaining_sek: number | null
|
||||
currency: string
|
||||
paid_amount: number
|
||||
@@ -112,10 +133,15 @@ export const GET = withRouteContext<{ params: Promise<{ supplierId: string }> }>
|
||||
entry?.description ??
|
||||
`Leverantörsfaktura ${inv.supplier_invoice_number || ''}`,
|
||||
debit: 0,
|
||||
// For an unpaid AP entry, the open balance is a credit on 2440.
|
||||
credit: remainingSek ?? remaining,
|
||||
// For an unpaid AP entry, the open balance is a credit on 2440, which is
|
||||
// posted in SEK. With no rate there is no SEK figure to show: fall back
|
||||
// to 0 rather than to the foreign amount, which would render as kronor in
|
||||
// the Kredit column. `remaining_sek: null` is what marks the difference
|
||||
// between "unknown" and "settled".
|
||||
credit: remainingSek ?? 0,
|
||||
supplier_invoice_id: inv.id,
|
||||
supplier_invoice_number: inv.supplier_invoice_number || '',
|
||||
remaining,
|
||||
remaining_sek: remainingSek,
|
||||
currency: inv.currency || 'SEK',
|
||||
paid_amount: Number(inv.paid_amount) || 0,
|
||||
@@ -128,6 +154,12 @@ export const GET = withRouteContext<{ params: Promise<{ supplierId: string }> }>
|
||||
supplier_id: supplier.id,
|
||||
supplier_name: supplier.name,
|
||||
lines,
|
||||
// Same contract as the ledger report itself: the rows are listed, but
|
||||
// their SEK value is unknown and missing from every SEK total. Computed
|
||||
// over the FULL row set now that the query paginates.
|
||||
unconverted_fx_count: lines.filter((l) => l.remaining_sek === null).length,
|
||||
// Kept for response-shape compatibility. Truthful now: every open
|
||||
// invoice for the supplier is in `lines`, so there is never a next page.
|
||||
next_cursor: null,
|
||||
},
|
||||
})
|
||||
|
||||
+49
-18
@@ -18,36 +18,67 @@ interface SupabaseShape {
|
||||
from: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
interface EmbedShapedLine {
|
||||
debit_amount: number
|
||||
credit_amount: number
|
||||
journal_entry_id: string
|
||||
dimensions?: Record<string, string> | null
|
||||
journal_entries: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* The route 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 (< the 1000-row PAGE_SIZE)
|
||||
* ends the loop.
|
||||
*
|
||||
* Fixtures stay embed-shaped; the mock splits them into the two row sets, so
|
||||
* the route sees exactly what the old `journal_entries!inner` embed returned.
|
||||
*/
|
||||
function buildSupabase(
|
||||
account: { account_number: string; account_name: string } | null,
|
||||
linesResult: { data: unknown; error: unknown }
|
||||
): SupabaseShape {
|
||||
const fixtures = (linesResult.data ?? []) as EmbedShapedLine[]
|
||||
const entries = linesResult.error
|
||||
? []
|
||||
: [...new Map(fixtures.map((l) => [l.journal_entry_id, l.journal_entries])).values()]
|
||||
const bareLines = linesResult.error
|
||||
? []
|
||||
: fixtures.map((l, i) => ({
|
||||
id: `line-${String(i).padStart(5, '0')}`,
|
||||
journal_entry_id: l.journal_entry_id,
|
||||
debit_amount: l.debit_amount,
|
||||
credit_amount: l.credit_amount,
|
||||
dimensions: l.dimensions ?? null,
|
||||
}))
|
||||
|
||||
const rowsChain = (rows: unknown[]) => ({
|
||||
select: vi.fn().mockReturnThis(),
|
||||
eq: vi.fn().mockReturnThis(),
|
||||
in: vi.fn().mockReturnThis(),
|
||||
gte: vi.fn().mockReturnThis(),
|
||||
lte: vi.fn().mockReturnThis(),
|
||||
contains: vi.fn().mockReturnThis(),
|
||||
order: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn().mockReturnThis(),
|
||||
or: vi.fn().mockReturnThis(),
|
||||
range: vi.fn().mockResolvedValue({ data: rows, error: linesResult.error }),
|
||||
then: (resolve: (v: unknown) => void) => resolve({ data: rows, error: linesResult.error }),
|
||||
})
|
||||
|
||||
return {
|
||||
from: vi.fn().mockImplementation((table: string) => {
|
||||
if (table === 'chart_of_accounts') {
|
||||
const chain = {
|
||||
return {
|
||||
select: vi.fn().mockReturnThis(),
|
||||
eq: vi.fn().mockReturnThis(),
|
||||
maybeSingle: vi.fn().mockResolvedValue({ data: account, error: null }),
|
||||
}
|
||||
return chain
|
||||
}
|
||||
// journal_entry_lines: terminates on `.range()` (fetchAllRows), which
|
||||
// resolves to the line result. `data.length < PAGE_SIZE` so a single
|
||||
// page is fetched.
|
||||
const chain = {
|
||||
select: vi.fn().mockReturnThis(),
|
||||
eq: vi.fn().mockReturnThis(),
|
||||
in: vi.fn().mockReturnThis(),
|
||||
gte: vi.fn().mockReturnThis(),
|
||||
lte: vi.fn().mockReturnThis(),
|
||||
order: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn().mockReturnThis(),
|
||||
or: vi.fn().mockReturnThis(),
|
||||
range: vi.fn().mockResolvedValue(linesResult),
|
||||
then: (resolve: (v: unknown) => void) => resolve(linesResult),
|
||||
}
|
||||
return chain
|
||||
return rowsChain(table === 'journal_entries' ? entries : bareLines)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import { fetchEntryLines, type EntryLinesQuery } from '@/lib/bookkeeping/entry-lines'
|
||||
import { parseDimensionFilterParams } from '@/lib/reports/dimension-filter'
|
||||
import type { ReportSourceLine } from '@/lib/reports/source-lines'
|
||||
|
||||
@@ -77,48 +77,38 @@ export const GET = withRouteContext<{ params: Promise<{ accountNumber: string }>
|
||||
// cannot give us a chronological parent order. Without a stable parent order
|
||||
// a raw `.limit()` returns an arbitrary subset that varies between identical
|
||||
// requests, which surfaced as the trial-balance drill-down showing
|
||||
// "different rows on every reload" for high-volume accounts. We instead page
|
||||
// on the line PK (`id`) for a stable total order (see fetch-all.ts) and do
|
||||
// the chronological sort here, mirroring `generateGeneralLedger`.
|
||||
const rows = await fetchAllRows<{
|
||||
// "different rows on every reload" for high-volume accounts. The two-step
|
||||
// fetch pages both sides on their PK for a stable total order (see
|
||||
// fetch-all.ts) and we do the chronological sort here, mirroring
|
||||
// `generateGeneralLedger`.
|
||||
//
|
||||
// The scope filters used to live 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); driving from the entry side keeps the
|
||||
// work inside this company's period.
|
||||
const rows = await fetchEntryLines<{
|
||||
id: string
|
||||
debit_amount: number
|
||||
credit_amount: number
|
||||
dimensions: Record<string, string> | null
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
journal_entries: any
|
||||
}>(({ from, to }) => {
|
||||
let query = supabase
|
||||
.from('journal_entry_lines')
|
||||
.select(`
|
||||
id,
|
||||
debit_amount,
|
||||
credit_amount,
|
||||
dimensions,
|
||||
journal_entry_id,
|
||||
journal_entries!inner(
|
||||
id,
|
||||
voucher_number,
|
||||
voucher_series,
|
||||
entry_date,
|
||||
description,
|
||||
status,
|
||||
company_id,
|
||||
fiscal_period_id
|
||||
)
|
||||
`)
|
||||
.eq('account_number', accountNumber)
|
||||
.eq('journal_entries.company_id', companyId)
|
||||
.eq('journal_entries.fiscal_period_id', fiscalPeriodId)
|
||||
.in('journal_entries.status', ['posted', 'reversed'])
|
||||
|
||||
if (dimFilter.dimensions) {
|
||||
}>({
|
||||
supabase,
|
||||
entryColumns: 'id, voucher_number, voucher_series, entry_date, description, status, company_id, fiscal_period_id',
|
||||
lineColumns: 'id, debit_amount, credit_amount, dimensions, journal_entry_id',
|
||||
filterEntries: (q: EntryLinesQuery) =>
|
||||
q
|
||||
.eq('company_id', companyId)
|
||||
.eq('fiscal_period_id', fiscalPeriodId)
|
||||
.in('status', ['posted', 'reversed']),
|
||||
filterLines: (q: EntryLinesQuery) => {
|
||||
const scoped = q.eq('account_number', accountNumber)
|
||||
// jsonb containment (@>): served by idx_jel_dimensions_gin.
|
||||
query = query.contains('dimensions', dimFilter.dimensions)
|
||||
}
|
||||
|
||||
return query.order('id', { ascending: true }).range(from, to)
|
||||
}, { dedupeBy: (r) => r.id })
|
||||
return dimFilter.dimensions ? scoped.contains('dimensions', dimFilter.dimensions) : scoped
|
||||
},
|
||||
})
|
||||
|
||||
// Map then sort in JS (date ASC, voucher_number ASC, journal_entry_id ASC as
|
||||
// a final deterministic tiebreak for lines sharing a date and voucher number
|
||||
|
||||
@@ -25,6 +25,8 @@ vi.mock('@/lib/auth/require-auth', () => ({
|
||||
|
||||
import { GET } from '../route'
|
||||
import { requireAuth } from '@/lib/auth/require-auth'
|
||||
import { rcInputTotalsFromDeclaration } from '@/lib/reports/vat-declaration'
|
||||
import { runVatDeclarationChecks } from '@/lib/reports/vat-declaration-checks'
|
||||
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
|
||||
@@ -161,6 +163,65 @@ describe('GET /api/reports/vat-declaration', () => {
|
||||
)
|
||||
})
|
||||
|
||||
// The declaration is what the momsdeklaration UI runs its local
|
||||
// "Kontroll av underlaget" checks on, and that client has no ledger access of
|
||||
// its own. Without the 2645/2647 pair on the response it could only compare
|
||||
// rutor 30-32 against the ruta 48 aggregate, where ordinary debiterad ingående
|
||||
// moms on 2641 hides a missing beräknad ingående moms completely.
|
||||
describe('rcInputAccountTotals travels with the response', () => {
|
||||
/** The masking ledger: 50 000 kr RC output, underlag booked, no 2645/2647. */
|
||||
function maskedRcPayload() {
|
||||
return {
|
||||
totals: [
|
||||
{ account_number: '4535', debit: 200000, credit: 0 }, // ruta 21 basis
|
||||
{ account_number: '2614', debit: 0, credit: 50000 }, // ruta 30
|
||||
{ account_number: '2641', debit: 60000, credit: 0 }, // ordinary input VAT
|
||||
],
|
||||
settlement_shaped_entries: [],
|
||||
source_type_counts: {},
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchDeclaration() {
|
||||
const res = await GET(
|
||||
makeRequest('?periodType=monthly&year=2026&period=1'),
|
||||
{ params: Promise.resolve({}) },
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
return (await res.json()).data
|
||||
}
|
||||
|
||||
it('carries both accounts, zeros included, on an ordinary SEK period', async () => {
|
||||
const data = await fetchDeclaration()
|
||||
// The default fixture has no reverse charge at all: the pair is still
|
||||
// present, so a client can tell "no RC activity" from "field missing".
|
||||
expect(data.rcInputAccountTotals).toEqual({
|
||||
'2645': { debit: 0, credit: 0 },
|
||||
'2647': { debit: 0, credit: 0 },
|
||||
})
|
||||
expect(runVatDeclarationChecks(data.rutor, rcInputTotalsFromDeclaration(data))).toEqual([])
|
||||
})
|
||||
|
||||
it('lets the client run the sharp RC input check off the response alone', async () => {
|
||||
mockSupabase.rpc.mockResolvedValue({ data: maskedRcPayload(), error: null })
|
||||
const data = await fetchDeclaration()
|
||||
|
||||
expect(data.rutor.ruta30).toBe(50000)
|
||||
expect(data.rutor.ruta48).toBe(60000)
|
||||
expect(data.rcInputAccountTotals['2645']).toEqual({ debit: 0, credit: 0 })
|
||||
|
||||
// Same call the momsdeklaration view makes.
|
||||
const checks = runVatDeclarationChecks(data.rutor, rcInputTotalsFromDeclaration(data))
|
||||
const mismatch = 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/)
|
||||
|
||||
// Ruta 48 alone: silent, which is the state the wiring replaced.
|
||||
expect(runVatDeclarationChecks(data.rutor)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
it('returns the VAT_REPORT_GENERATION_FAILED envelope when the RPC errors', async () => {
|
||||
mockSupabase.rpc.mockResolvedValue({
|
||||
data: null,
|
||||
|
||||
@@ -13,14 +13,21 @@ vi.mock('@/lib/company/context', () => ({
|
||||
}))
|
||||
|
||||
import { GET } from '../route'
|
||||
import { resolvePeriodDates } from '@/lib/reports/vat-declaration'
|
||||
|
||||
interface SupabaseShape {
|
||||
from: ReturnType<typeof vi.fn>
|
||||
rpc: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
/**
|
||||
* `fiscalPeriodResult` is what a `fiscal_periods` lookup resolves to: both the
|
||||
* explicit-id lookup and the "räkenskapsår ending in `year`" lookup that
|
||||
* `resolvePeriodDates` performs for helårsmoms.
|
||||
*/
|
||||
function buildSupabase(
|
||||
linesResult: { data: unknown; error: unknown }
|
||||
linesResult: { data: unknown; error: unknown },
|
||||
fiscalPeriodResult: { data: unknown; error: unknown } = { data: null, error: null }
|
||||
): SupabaseShape {
|
||||
return {
|
||||
rpc: vi.fn().mockResolvedValue(linesResult),
|
||||
@@ -33,13 +40,19 @@ function buildSupabase(
|
||||
order: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn().mockReturnThis(),
|
||||
or: vi.fn().mockReturnThis(),
|
||||
maybeSingle: vi.fn().mockResolvedValue({ data: null, error: null }),
|
||||
maybeSingle: vi.fn().mockResolvedValue(fiscalPeriodResult),
|
||||
range: vi.fn().mockResolvedValue(linesResult),
|
||||
then: (resolve: (v: unknown) => void) => resolve(linesResult),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/** The { p_start, p_end } the route handed to get_vat_ruta_source_lines. */
|
||||
function rpcPeriod(supabase: SupabaseShape): { start: string; end: string } {
|
||||
const args = supabase.rpc.mock.calls[0][1] as { p_start: string; p_end: string }
|
||||
return { start: args.p_start, end: args.p_end }
|
||||
}
|
||||
|
||||
function authOk(supabase: SupabaseShape) {
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
|
||||
}
|
||||
@@ -193,3 +206,124 @@ describe('GET /api/reports/vat-declaration/ruta/[ruta]/sources', () => {
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /api/reports/vat-declaration/ruta/[ruta]/sources: period resolution', () => {
|
||||
// A first räkenskapsår may run up to 18 months (BFL 3 kap 3 §), and
|
||||
// helårsmoms is filed per räkenskapsår, not per calendar year
|
||||
// (SFL 26 kap 10-11 §§).
|
||||
const EXTENDED_FIRST_YEAR = { period_start: '2025-07-03', period_end: '2026-12-31' }
|
||||
const CALENDAR_YEAR = { period_start: '2026-01-01', period_end: '2026-12-31' }
|
||||
|
||||
const noLines = () => ({ data: [], error: null })
|
||||
|
||||
function get(searchParams: Record<string, string>, ruta = '05') {
|
||||
const req = createMockRequest(
|
||||
`/api/reports/vat-declaration/ruta/${ruta}/sources`,
|
||||
{ searchParams }
|
||||
)
|
||||
return GET(req, createMockRouteParams({ ruta }))
|
||||
}
|
||||
|
||||
it('yearly: drills into the räkenskapsår, agreeing with the declaration resolver', async () => {
|
||||
const supabase = buildSupabase(noLines(), { data: EXTENDED_FIRST_YEAR, error: null })
|
||||
authOk(supabase)
|
||||
|
||||
const res = await get({ periodType: 'yearly', year: '2026', period: '1' })
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
// The old calendar-span behaviour would have started 2026-01-01 and hidden
|
||||
// every verifikat from 2025-07-03 to 2025-12-31 that the declaration counts.
|
||||
expect(rpcPeriod(supabase)).toEqual({ start: '2025-07-03', end: '2026-12-31' })
|
||||
|
||||
// And it is the exact span `calculateVatDeclaration` computes the figure
|
||||
// from: both go through resolvePeriodDates with the same arguments.
|
||||
const declarationPeriod = await resolvePeriodDates(
|
||||
buildSupabase(noLines(), {
|
||||
data: EXTENDED_FIRST_YEAR,
|
||||
error: null,
|
||||
}) as unknown as Parameters<typeof resolvePeriodDates>[0],
|
||||
'company-1',
|
||||
'yearly',
|
||||
2026,
|
||||
1
|
||||
)
|
||||
expect(rpcPeriod(supabase)).toEqual(declarationPeriod)
|
||||
})
|
||||
|
||||
it('yearly: an explicit fiscal_period_id selects that räkenskapsår', async () => {
|
||||
const supabase = buildSupabase(noLines(), { data: EXTENDED_FIRST_YEAR, error: null })
|
||||
authOk(supabase)
|
||||
|
||||
const res = await get({
|
||||
periodType: 'yearly',
|
||||
year: '2026',
|
||||
period: '1',
|
||||
fiscal_period_id: '11111111-1111-4111-8111-111111111111',
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(rpcPeriod(supabase)).toEqual({ start: '2025-07-03', end: '2026-12-31' })
|
||||
})
|
||||
|
||||
it('yearly: calendar-year company is unchanged (Jan-Dec)', async () => {
|
||||
const supabase = buildSupabase(noLines(), { data: CALENDAR_YEAR, error: null })
|
||||
authOk(supabase)
|
||||
|
||||
const res = await get({ periodType: 'yearly', year: '2026', period: '1' })
|
||||
expect(res.status).toBe(200)
|
||||
expect(rpcPeriod(supabase)).toEqual({ start: '2026-01-01', end: '2026-12-31' })
|
||||
})
|
||||
|
||||
it('yearly: falls back to the calendar span when no fiscal period is found', async () => {
|
||||
const supabase = buildSupabase(noLines(), { data: null, error: null })
|
||||
authOk(supabase)
|
||||
|
||||
const res = await get({ periodType: 'yearly', year: '2026', period: '1' })
|
||||
expect(res.status).toBe(200)
|
||||
expect(rpcPeriod(supabase)).toEqual({ start: '2026-01-01', end: '2026-12-31' })
|
||||
})
|
||||
|
||||
it('monthly: stays a calendar month even for a broken fiscal year', async () => {
|
||||
// kalendermånad per SFL 26 kap: fiscal_period_id must not widen the span.
|
||||
const supabase = buildSupabase(noLines(), { data: EXTENDED_FIRST_YEAR, error: null })
|
||||
authOk(supabase)
|
||||
|
||||
const res = await get({
|
||||
periodType: 'monthly',
|
||||
year: '2026',
|
||||
period: '5',
|
||||
fiscal_period_id: '11111111-1111-4111-8111-111111111111',
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(rpcPeriod(supabase)).toEqual({ start: '2026-05-01', end: '2026-05-31' })
|
||||
})
|
||||
|
||||
it('quarterly: stays a calendar quarter', async () => {
|
||||
const supabase = buildSupabase(noLines(), { data: EXTENDED_FIRST_YEAR, error: null })
|
||||
authOk(supabase)
|
||||
|
||||
const res = await get({ periodType: 'quarterly', year: '2026', period: '2' })
|
||||
expect(res.status).toBe(200)
|
||||
expect(rpcPeriod(supabase)).toEqual({ start: '2026-04-01', end: '2026-06-30' })
|
||||
})
|
||||
|
||||
it('returns 400 for an unknown periodType', async () => {
|
||||
authOk(buildSupabase(noLines()))
|
||||
const res = await get({ periodType: 'weekly', year: '2026', period: '5' })
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('fiscal_period_id alone still selects the period by its own bounds', async () => {
|
||||
const supabase = buildSupabase(noLines(), { data: EXTENDED_FIRST_YEAR, error: null })
|
||||
authOk(supabase)
|
||||
|
||||
const res = await get({ fiscal_period_id: '11111111-1111-4111-8111-111111111111' })
|
||||
expect(res.status).toBe(200)
|
||||
expect(rpcPeriod(supabase)).toEqual({ start: '2025-07-03', end: '2026-12-31' })
|
||||
})
|
||||
|
||||
it('returns 404 when fiscal_period_id alone matches no period', async () => {
|
||||
authOk(buildSupabase(noLines(), { data: null, error: null }))
|
||||
const res = await get({ fiscal_period_id: '11111111-1111-4111-8111-111111111111' })
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { NextResponse } from 'next/server'
|
||||
import {
|
||||
ACCOUNT_RUTA,
|
||||
calculatePeriodDates,
|
||||
resolvePeriodDates,
|
||||
} from '@/lib/reports/vat-declaration'
|
||||
import type { ReportSourceLine } from '@/lib/reports/source-lines'
|
||||
import type { VatDeclarationRutor, VatPeriodType } from '@/types'
|
||||
@@ -15,10 +15,13 @@ import type { VatDeclarationRutor, VatPeriodType } from '@/types'
|
||||
* `ACCOUNT_RUTA` in `lib/reports/vat-declaration.ts`.
|
||||
*
|
||||
* Period can be specified either via:
|
||||
* ?periodType=monthly|quarterly|yearly&year=2026&period=5
|
||||
* ?periodType=monthly|quarterly|yearly&year=2026&period=5[&fiscal_period_id=<uuid>]
|
||||
* ?fiscal_period_id=<uuid>
|
||||
*
|
||||
* The periodType form mirrors the way the main VAT report is fetched.
|
||||
* The periodType form mirrors the way the main VAT report is fetched, down to
|
||||
* the period resolution itself: it goes through `resolvePeriodDates`, the same
|
||||
* helper the declaration uses, so the drill-down can never answer a query
|
||||
* string with a different span than the figure it drills into.
|
||||
*/
|
||||
const PAGE_LIMIT = 500
|
||||
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
||||
@@ -48,11 +51,43 @@ export const GET = withRouteContext<{ params: Promise<{ ruta: string }> }>(
|
||||
)
|
||||
}
|
||||
|
||||
// Resolve the period: either by fiscal_period_id or periodType/year/period.
|
||||
let start: string | null = null
|
||||
let end: string | null = null
|
||||
// Resolve the period. The periodType form is primary and resolves through
|
||||
// `resolvePeriodDates`, the same helper the declaration itself uses, with
|
||||
// fiscal_period_id threaded through exactly as the sibling vat-declaration
|
||||
// routes do. Helårsmoms is filed per räkenskapsår (SFL 26 kap 10-11 §§) and
|
||||
// a räkenskapsår is not always a calendar year (a first/changed year runs up
|
||||
// to 18 months, BFL 3 kap 3 §), so a plain Jan-Dec span would list a
|
||||
// different set of verifikat than the declaration was computed from.
|
||||
// Monthly/quarterly are calendar periods and resolve identically to before.
|
||||
// The fiscal_period_id-only form (no periodType) still selects the span from
|
||||
// the fiscal period's own bounds.
|
||||
const periodType = searchParams.get('periodType') as VatPeriodType | null
|
||||
const yearStr = searchParams.get('year')
|
||||
const periodStr = searchParams.get('period')
|
||||
const fiscalPeriodId = searchParams.get('fiscal_period_id')
|
||||
if (fiscalPeriodId) {
|
||||
|
||||
let start: string
|
||||
let end: string
|
||||
if (periodType && yearStr && periodStr) {
|
||||
if (!['monthly', 'quarterly', 'yearly'].includes(periodType)) {
|
||||
return NextResponse.json({ error: 'Invalid periodType' }, { status: 400 })
|
||||
}
|
||||
const year = parseInt(yearStr, 10)
|
||||
const periodNum = parseInt(periodStr, 10)
|
||||
if (isNaN(year) || isNaN(periodNum)) {
|
||||
return NextResponse.json({ error: 'Invalid period' }, { status: 400 })
|
||||
}
|
||||
const dates = await resolvePeriodDates(
|
||||
supabase,
|
||||
companyId,
|
||||
periodType,
|
||||
year,
|
||||
periodNum,
|
||||
fiscalPeriodId ?? undefined
|
||||
)
|
||||
start = dates.start
|
||||
end = dates.end
|
||||
} else if (fiscalPeriodId) {
|
||||
const { data: period } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('period_start, period_end')
|
||||
@@ -65,23 +100,10 @@ export const GET = withRouteContext<{ params: Promise<{ ruta: string }> }>(
|
||||
start = period.period_start
|
||||
end = period.period_end
|
||||
} else {
|
||||
const periodType = searchParams.get('periodType') as VatPeriodType | null
|
||||
const yearStr = searchParams.get('year')
|
||||
const periodStr = searchParams.get('period')
|
||||
if (!periodType || !yearStr || !periodStr) {
|
||||
return NextResponse.json(
|
||||
{ error: 'periodType/year/period or fiscal_period_id is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
const year = parseInt(yearStr, 10)
|
||||
const periodNum = parseInt(periodStr, 10)
|
||||
if (isNaN(year) || isNaN(periodNum)) {
|
||||
return NextResponse.json({ error: 'Invalid period' }, { status: 400 })
|
||||
}
|
||||
const dates = calculatePeriodDates(periodType, year, periodNum)
|
||||
start = dates.start
|
||||
end = dates.end
|
||||
return NextResponse.json(
|
||||
{ error: 'periodType/year/period or fiscal_period_id is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// New cursors include entry and line IDs so multiple rows with the same
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* PATCH /api/salary/employees/[id]: the personnummer wipe guard.
|
||||
*
|
||||
* The route used to build its update payload with `{ ...body }` and then apply
|
||||
* the encrypt branch only under `if (body.personnummer)`. For an empty string
|
||||
* that branch is skipped, but the spread has already put '' into the payload:
|
||||
* the UPDATE would overwrite the AES-256-GCM ciphertext with an empty string and
|
||||
* leave personnummer_last4 pointing at the old value. Nothing in the route
|
||||
* stopped it. The only thing standing in the way was the 12-digit regex in
|
||||
* UpdateEmployeeSchema, i.e. a guard in a DIFFERENT file, and house style in
|
||||
* lib/api/schemas.ts adds `.or(z.literal(''))` to optional string fields
|
||||
* (see clearing_number, account_number, bankgiro, tax_contact_email, ...).
|
||||
* One such edit to the employee schema would have turned a routine "clear this
|
||||
* field" idiom into silent destruction of encrypted PII.
|
||||
*
|
||||
* These tests deliberately mock @/lib/api/schemas so the personnummer field is
|
||||
* waved through validation and the request reaches the route body. They therefore
|
||||
* assert the defence that lives in the write path, not the one that lives in the
|
||||
* schema. `captured.updates` staying null means no UPDATE was issued at all, so
|
||||
* the ciphertext survives.
|
||||
*
|
||||
* Fixtures are obviously synthetic: no real personnummer appears here.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { createMockRequest } from '@/tests/helpers'
|
||||
|
||||
const requireAuthMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-auth', () => ({
|
||||
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
getCompanyEntityType: vi.fn().mockResolvedValue('aktiebolag'),
|
||||
}))
|
||||
|
||||
const requireWriteMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
|
||||
|
||||
// A schema that validates everything EXCEPT the personnummer shape. The point
|
||||
// is to prove the route defends on its own, so the field is waved through here
|
||||
// and the only remaining guard is the one in the write path. The concrete
|
||||
// production edit that produces the empty-string case is a single
|
||||
// `.or(z.literal(''))` on the real schema's personnummer field, matching how
|
||||
// clearing_number, account_number, bankgiro and tax_contact_email are already
|
||||
// written in lib/api/schemas.ts.
|
||||
vi.mock('@/lib/api/schemas', async () => {
|
||||
const { z } = await import('zod')
|
||||
return {
|
||||
UpdateEmployeeSchema: z.object({
|
||||
first_name: z.string().min(1).max(200).optional(),
|
||||
personnummer: z.string().nullable().optional(),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
import { PATCH } from '../route'
|
||||
import { encryptPersonnummer } from '@/lib/salary/personnummer'
|
||||
|
||||
const params = { params: Promise.resolve({ id: 'emp-1' }) } as never
|
||||
|
||||
const STORED_CIPHERTEXT = encryptPersonnummer('190203040000')
|
||||
|
||||
const EXISTING_ROW = {
|
||||
id: 'emp-1',
|
||||
company_id: 'company-1',
|
||||
first_name: 'Test',
|
||||
last_name: 'Testsson',
|
||||
personnummer: STORED_CIPHERTEXT,
|
||||
personnummer_last4: '0000',
|
||||
employment_type: 'employee',
|
||||
salary_type: 'monthly',
|
||||
monthly_salary: 30000,
|
||||
f_skatt_status: 'a_skatt',
|
||||
is_sidoinkomst: false,
|
||||
tax_table_number: 34,
|
||||
}
|
||||
|
||||
function employeeSupabase() {
|
||||
const captured: { updates: Record<string, unknown> | null } = { updates: null }
|
||||
|
||||
function chainFor(state: { isUpdate: boolean }): unknown {
|
||||
const handler: ProxyHandler<object> = {
|
||||
get(_target, prop) {
|
||||
if (prop === 'then') {
|
||||
const data = state.isUpdate
|
||||
? { ...EXISTING_ROW, ...(captured.updates ?? {}) }
|
||||
: EXISTING_ROW
|
||||
return (resolve: (v: unknown) => void) => resolve({ data, error: null })
|
||||
}
|
||||
return (...args: unknown[]) => {
|
||||
if (prop === 'update') {
|
||||
state.isUpdate = true
|
||||
captured.updates = args[0] as Record<string, unknown>
|
||||
}
|
||||
return chainFor(state)
|
||||
}
|
||||
},
|
||||
}
|
||||
return new Proxy({}, handler)
|
||||
}
|
||||
|
||||
return { supabase: { from: vi.fn(() => chainFor({ isUpdate: false })) }, captured }
|
||||
}
|
||||
|
||||
describe('PATCH /api/salary/employees/[id]: personnummer cannot be wiped', () => {
|
||||
let captured: { updates: Record<string, unknown> | null }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
const mock = employeeSupabase()
|
||||
captured = mock.captured
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase: mock.supabase })
|
||||
requireWriteMock.mockResolvedValue({ ok: true })
|
||||
})
|
||||
|
||||
it('rejects an empty-string personnummer instead of overwriting the ciphertext', async () => {
|
||||
const response = await PATCH(
|
||||
createMockRequest('/api/salary/employees/emp-1', {
|
||||
method: 'PATCH',
|
||||
body: { personnummer: '' },
|
||||
}),
|
||||
params,
|
||||
)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
// No UPDATE at all: the ciphertext and personnummer_last4 both survive.
|
||||
expect(captured.updates).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects a null personnummer (the column is NOT NULL and AGI/KU need it)', async () => {
|
||||
const response = await PATCH(
|
||||
createMockRequest('/api/salary/employees/emp-1', {
|
||||
method: 'PATCH',
|
||||
body: { personnummer: null },
|
||||
}),
|
||||
params,
|
||||
)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(captured.updates).toBeNull()
|
||||
})
|
||||
|
||||
it('refuses the whole PATCH rather than applying the other fields silently', async () => {
|
||||
// Failing loudly matters more than partial success: a caller that thought it
|
||||
// was clearing the personnummer must learn that it did not happen.
|
||||
const response = await PATCH(
|
||||
createMockRequest('/api/salary/employees/emp-1', {
|
||||
method: 'PATCH',
|
||||
body: { first_name: 'Ny', personnummer: '' },
|
||||
}),
|
||||
params,
|
||||
)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(captured.updates).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects a masked personnummer that reached the route past a loose schema', async () => {
|
||||
// validatePersonnummer() strips non-digits, so a decorated value carrying 12
|
||||
// real digits would sail through it. The explicit mask check is what makes
|
||||
// the refusal deterministic.
|
||||
const response = await PATCH(
|
||||
createMockRequest('/api/salary/employees/emp-1', {
|
||||
method: 'PATCH',
|
||||
body: { personnummer: '190203040000-XXXX' },
|
||||
}),
|
||||
params,
|
||||
)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(captured.updates).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -5,6 +5,17 @@
|
||||
* its auth/company/write dependencies and injecting a queued Supabase mock via
|
||||
* requireAuth. Covers 401 (unauth), 403 (viewer role), and a DELETE happy path
|
||||
* (soft delete, BFL retention).
|
||||
*
|
||||
* Plus the personnummer contract on this route, which handles encrypted PII on
|
||||
* both the read and the write side:
|
||||
* - reads expose the mask under `personnummer_masked`, never under the
|
||||
* writable `personnummer` key (no mask round-trip into the encrypt path),
|
||||
* - a PATCH that omits personnummer must leave the ciphertext and
|
||||
* personnummer_last4 completely untouched,
|
||||
* - a masked value offered as a write is refused,
|
||||
* - a genuine new value is stored encrypted, never plaintext.
|
||||
* The empty-string / null wipe guard is pinned in personnummer-write-guard.test.ts,
|
||||
* which loosens the Zod schema to prove the defence lives in the route.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
@@ -30,10 +41,67 @@ vi.mock('@/lib/auth/require-write', () => ({
|
||||
|
||||
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
|
||||
|
||||
import { DELETE } from '../route'
|
||||
import { DELETE, GET, PATCH } from '../route'
|
||||
import { decryptPersonnummer, encryptPersonnummer } from '@/lib/salary/personnummer'
|
||||
|
||||
const params = { params: Promise.resolve({ id: 'emp-1' }) } as never
|
||||
|
||||
// Obviously synthetic fixtures. STORED_PNR is only ever masked, so it needs no
|
||||
// check digit; NEW_PNR goes through validatePersonnummer, so its Luhn check
|
||||
// digit is genuinely correct (sum over 0001010008 = 10).
|
||||
const STORED_PNR = '190203040000'
|
||||
const STORED_CIPHERTEXT = encryptPersonnummer(STORED_PNR)
|
||||
const NEW_PNR = '190001010008'
|
||||
|
||||
const EXISTING_ROW = {
|
||||
id: 'emp-1',
|
||||
company_id: 'company-1',
|
||||
first_name: 'Test',
|
||||
last_name: 'Testsson',
|
||||
personnummer: STORED_CIPHERTEXT,
|
||||
personnummer_last4: '0000',
|
||||
employment_type: 'employee',
|
||||
salary_type: 'monthly',
|
||||
monthly_salary: 30000,
|
||||
f_skatt_status: 'a_skatt',
|
||||
is_sidoinkomst: false,
|
||||
tax_table_number: 34,
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Supabase double that returns EXISTING_ROW on reads, records the payload handed
|
||||
* to `.update()`, and resolves the update chain with the merged row. `captured.updates`
|
||||
* staying null is the assertion that no write was attempted at all.
|
||||
*/
|
||||
function employeeSupabase(existing: Record<string, unknown> = { ...EXISTING_ROW }) {
|
||||
const captured: { updates: Record<string, unknown> | null } = { updates: null }
|
||||
|
||||
function chainFor(state: { isUpdate: boolean }): unknown {
|
||||
const handler: ProxyHandler<object> = {
|
||||
get(_target, prop) {
|
||||
if (prop === 'then') {
|
||||
const data = state.isUpdate ? { ...existing, ...(captured.updates ?? {}) } : existing
|
||||
return (resolve: (v: unknown) => void) => resolve({ data, error: null })
|
||||
}
|
||||
return (...args: unknown[]) => {
|
||||
if (prop === 'update') {
|
||||
state.isUpdate = true
|
||||
captured.updates = args[0] as Record<string, unknown>
|
||||
}
|
||||
return chainFor(state)
|
||||
}
|
||||
},
|
||||
}
|
||||
return new Proxy({}, handler)
|
||||
}
|
||||
|
||||
return { supabase: { from: vi.fn(() => chainFor({ isUpdate: false })) }, captured }
|
||||
}
|
||||
|
||||
function patchRequest(body: Record<string, unknown>) {
|
||||
return createMockRequest('/api/salary/employees/emp-1', { method: 'PATCH', body })
|
||||
}
|
||||
|
||||
describe('DELETE /api/salary/employees/[id]', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -73,3 +141,72 @@ describe('DELETE /api/salary/employees/[id]', () => {
|
||||
expect(body.data).toEqual({ id: 'emp-1', is_active: false })
|
||||
})
|
||||
})
|
||||
|
||||
describe('personnummer contract on /api/salary/employees/[id]', () => {
|
||||
let captured: { updates: Record<string, unknown> | null }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
const mock = employeeSupabase()
|
||||
captured = mock.captured
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase: mock.supabase })
|
||||
requireWriteMock.mockResolvedValue({ ok: true })
|
||||
})
|
||||
|
||||
it('GET returns the mask under personnummer_masked and drops the ciphertext', async () => {
|
||||
const response = await GET(createMockRequest('/api/salary/employees/emp-1'), params)
|
||||
const { status, body } = await parseJsonResponse<{ data: Record<string, unknown> }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.personnummer_masked).toBe('19020304-XXXX')
|
||||
// The writable key name must not carry the mask: a client that reads this
|
||||
// object and writes it back would otherwise send the mask to the encrypt path.
|
||||
expect('personnummer' in body.data).toBe(false)
|
||||
// personnummer_last4 must not ride along either: the mask is
|
||||
// YYYYMMDD-XXXX, so mask + last4 reassembles the full personnummer.
|
||||
expect('personnummer_last4' in body.data).toBe(false)
|
||||
// Neither the plaintext nor the stored ciphertext may leave the server.
|
||||
expect(JSON.stringify(body)).not.toContain(STORED_PNR)
|
||||
expect(JSON.stringify(body)).not.toContain(STORED_CIPHERTEXT)
|
||||
})
|
||||
|
||||
it('PATCH without personnummer leaves the ciphertext and last4 untouched', async () => {
|
||||
const response = await PATCH(patchRequest({ first_name: 'Ny' }), params)
|
||||
const { status, body } = await parseJsonResponse<{ data: Record<string, unknown> }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(captured.updates).toEqual({ first_name: 'Ny' })
|
||||
// Absent key means "identity unchanged": neither column may appear.
|
||||
expect(captured.updates).not.toHaveProperty('personnummer')
|
||||
expect(captured.updates).not.toHaveProperty('personnummer_last4')
|
||||
expect(body.data.personnummer_masked).toBe('19020304-XXXX')
|
||||
expect('personnummer' in body.data).toBe(false)
|
||||
expect('personnummer_last4' in body.data).toBe(false)
|
||||
})
|
||||
|
||||
it('PATCH refuses a masked personnummer instead of encrypting the mask', async () => {
|
||||
const response = await PATCH(patchRequest({ personnummer: '19020304-XXXX' }), params)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(captured.updates).toBeNull()
|
||||
})
|
||||
|
||||
it('PATCH stores a genuine new personnummer encrypted, never plaintext', async () => {
|
||||
const response = await PATCH(patchRequest({ personnummer: NEW_PNR }), params)
|
||||
const { status, body } = await parseJsonResponse<{ data: Record<string, unknown> }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
const stored = captured.updates?.personnummer as string
|
||||
expect(stored).not.toBe(NEW_PNR)
|
||||
expect(decryptPersonnummer(stored)).toBe(NEW_PNR)
|
||||
expect(captured.updates?.personnummer_last4).toBe('0008')
|
||||
// The response echoes only the mask, under the read-only key. The updated
|
||||
// last4 is written to the row but must not appear in the response.
|
||||
expect(body.data.personnummer_masked).toBe('19000101-XXXX')
|
||||
expect('personnummer' in body.data).toBe(false)
|
||||
expect('personnummer_last4' in body.data).toBe(false)
|
||||
expect(JSON.stringify(body)).not.toContain(NEW_PNR)
|
||||
expect(JSON.stringify(body)).not.toContain('"0008"')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
* Auth-wiring tests for /api/salary/employees/[id]/benefits/[benefitId]
|
||||
* (PATCH/DELETE). Runs through the real withRouteContext wrapper; mocks auth/
|
||||
* company/write and injects a queued Supabase mock via requireAuth. Covers 401,
|
||||
* 403 (viewer), and a DELETE happy path.
|
||||
* 403 (viewer), a DELETE happy path, and the validity-period contract on PATCH
|
||||
* (CHECK (valid_to IS NULL OR valid_to >= valid_from), migration
|
||||
* 20260512200100), including the error-mapping split that used to report a
|
||||
* check_violation as 404 "Förmån hittades inte".
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
@@ -27,7 +30,7 @@ vi.mock('@/lib/auth/require-write', () => ({
|
||||
|
||||
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
|
||||
|
||||
import { DELETE } from '../route'
|
||||
import { DELETE, PATCH } from '../route'
|
||||
|
||||
const params = { params: Promise.resolve({ id: 'emp-1', benefitId: 'ben-1' }) } as never
|
||||
|
||||
@@ -35,6 +38,18 @@ function del() {
|
||||
return createMockRequest('/api/salary/employees/emp-1/benefits/ben-1', { method: 'DELETE' })
|
||||
}
|
||||
|
||||
function patch(body: unknown) {
|
||||
return createMockRequest('/api/salary/employees/emp-1/benefits/ben-1', { method: 'PATCH', body })
|
||||
}
|
||||
|
||||
/** Stored row as the PATCH route fetches it before writing. */
|
||||
const storedBenefit = {
|
||||
benefit_type: 'other',
|
||||
metadata: {},
|
||||
valid_from: '2026-06-01',
|
||||
valid_to: '2026-12-31',
|
||||
}
|
||||
|
||||
describe('DELETE /api/salary/employees/[id]/benefits/[benefitId]', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -74,3 +89,185 @@ describe('DELETE /api/salary/employees/[id]/benefits/[benefitId]', () => {
|
||||
expect(body.data).toEqual({ id: 'ben-1', deleted: true })
|
||||
})
|
||||
})
|
||||
|
||||
describe('PATCH /api/salary/employees/[id]/benefits/[benefitId]', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase })
|
||||
requireWriteMock.mockResolvedValue({ ok: true })
|
||||
})
|
||||
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: null,
|
||||
supabase,
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
|
||||
const response = await PATCH(patch({ monthly_value: 100 }), params)
|
||||
expect(response.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 403 for a viewer (no write permission)', async () => {
|
||||
requireWriteMock.mockResolvedValue({
|
||||
ok: false,
|
||||
response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }),
|
||||
})
|
||||
|
||||
const response = await PATCH(patch({ monthly_value: 100 }), params)
|
||||
expect(response.status).toBe(403)
|
||||
})
|
||||
|
||||
it('updates the benefit (happy path)', async () => {
|
||||
enqueue({ data: storedBenefit }) // existence check
|
||||
enqueue({ data: { id: 'ben-1', monthly_value: 100 } }) // update
|
||||
|
||||
const response = await PATCH(patch({ monthly_value: 100 }), params)
|
||||
const { status, body } = await parseJsonResponse<{ data: { id: string } }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.id).toBe('ben-1')
|
||||
})
|
||||
|
||||
it('returns 404 when the benefit does not exist in the company', async () => {
|
||||
enqueue({ data: null, error: { code: 'PGRST116', message: 'no rows' } })
|
||||
|
||||
const response = await PATCH(patch({ monthly_value: 100 }), params)
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(404)
|
||||
expect(body.error).toBe('Förmån hittades inte')
|
||||
})
|
||||
|
||||
describe('valid_from / valid_to ordering', () => {
|
||||
// Both dates in the body: the schema can compare them itself, so this 400
|
||||
// lands before any DB call.
|
||||
it('rejects both-dates-in-body with valid_to before valid_from', async () => {
|
||||
const response = await PATCH(
|
||||
patch({ valid_from: '2026-06-01', valid_to: '2026-05-31' }),
|
||||
params,
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
error: string
|
||||
errors: { field: string }[]
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.errors).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ field: 'valid_to' })]),
|
||||
)
|
||||
expect(body.error).toContain('Gäller till')
|
||||
expect(supabase.from).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Single-field PATCH: the schema cannot see the stored half, so the route
|
||||
// compares the merged pair against the fetched row and answers 400 itself.
|
||||
// This is the case that used to reach Postgres and return 404.
|
||||
it('rejects a valid_to-only patch that predates the stored valid_from', async () => {
|
||||
enqueue({ data: storedBenefit }) // stored valid_from 2026-06-01
|
||||
|
||||
const response = await PATCH(patch({ valid_to: '2026-05-31' }), params)
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error).toContain('Gäller till')
|
||||
expect(body.error).not.toContain('hittades inte')
|
||||
// Fetched once, never written.
|
||||
expect(supabase.from).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('rejects a valid_from-only patch that postdates the stored valid_to', async () => {
|
||||
enqueue({ data: storedBenefit }) // stored valid_to 2026-12-31
|
||||
|
||||
const response = await PATCH(patch({ valid_from: '2027-01-01' }), params)
|
||||
expect(response.status).toBe(400)
|
||||
expect(supabase.from).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('accepts a valid_to-only patch that is still on or after the stored valid_from', async () => {
|
||||
enqueue({ data: storedBenefit })
|
||||
enqueue({ data: { id: 'ben-1', valid_to: '2026-06-01' } })
|
||||
|
||||
// Equal to the stored valid_from: legal, the bound is inclusive.
|
||||
const response = await PATCH(patch({ valid_to: '2026-06-01' }), params)
|
||||
expect(response.status).toBe(200)
|
||||
})
|
||||
|
||||
it('accepts both dates equal (the bound is inclusive)', async () => {
|
||||
enqueue({ data: storedBenefit })
|
||||
enqueue({ data: { id: 'ben-1' } })
|
||||
|
||||
const response = await PATCH(
|
||||
patch({ valid_from: '2026-06-01', valid_to: '2026-06-01' }),
|
||||
params,
|
||||
)
|
||||
expect(response.status).toBe(200)
|
||||
})
|
||||
|
||||
it('accepts valid_to: null (clearing the end date keeps the benefit open-ended)', async () => {
|
||||
enqueue({ data: storedBenefit })
|
||||
enqueue({ data: { id: 'ben-1', valid_to: null } })
|
||||
|
||||
const response = await PATCH(patch({ valid_to: null }), params)
|
||||
expect(response.status).toBe(200)
|
||||
})
|
||||
|
||||
it('accepts a valid_from-only patch when the stored valid_to is null', async () => {
|
||||
enqueue({ data: { ...storedBenefit, valid_to: null } })
|
||||
enqueue({ data: { id: 'ben-1' } })
|
||||
|
||||
const response = await PATCH(patch({ valid_from: '2030-01-01' }), params)
|
||||
expect(response.status).toBe(200)
|
||||
})
|
||||
})
|
||||
|
||||
describe('write-error mapping (not "hittades inte")', () => {
|
||||
it('maps a check_violation on the update to 400 with the period message', async () => {
|
||||
enqueue({ data: storedBenefit })
|
||||
enqueue({
|
||||
data: null,
|
||||
error: { code: '23514', message: 'violates check constraint "employee_benefits_check"' },
|
||||
})
|
||||
|
||||
const response = await PATCH(patch({ monthly_value: 100 }), params)
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error).toContain('Gäller till')
|
||||
expect(body.error).not.toContain('hittades inte')
|
||||
})
|
||||
|
||||
it('maps a real DB failure on the update to 500, not 404', async () => {
|
||||
enqueue({ data: storedBenefit })
|
||||
enqueue({ data: null, error: { code: '08006', message: 'connection failure' } })
|
||||
|
||||
const response = await PATCH(patch({ monthly_value: 100 }), params)
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(500)
|
||||
expect(body.error).not.toContain('hittades inte')
|
||||
})
|
||||
|
||||
it('keeps 404 when the row disappears between the fetch and the update', async () => {
|
||||
enqueue({ data: storedBenefit })
|
||||
enqueue({ data: null, error: { code: 'PGRST116', message: 'no rows' } })
|
||||
|
||||
const response = await PATCH(patch({ monthly_value: 100 }), params)
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(404)
|
||||
expect(body.error).toBe('Förmån hittades inte')
|
||||
})
|
||||
|
||||
it('maps a failed existence lookup (non-PGRST116) to 500, not 404', async () => {
|
||||
enqueue({ data: null, error: { code: '08006', message: 'connection failure' } })
|
||||
|
||||
const response = await PATCH(patch({ monthly_value: 100 }), params)
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(500)
|
||||
expect(body.error).not.toContain('hittades inte')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { UpdateEmployeeBenefitSchema } from '@/lib/api/schemas'
|
||||
import { UpdateEmployeeBenefitSchema, BENEFIT_PERIOD_ORDER_MESSAGE } from '@/lib/api/schemas'
|
||||
import { calculateBikeBenefit } from '@/lib/salary/benefits'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
|
||||
@@ -19,16 +19,36 @@ export const PATCH = withRouteContext<{ params: Promise<{ id: string; benefitId:
|
||||
|
||||
const { data: existing, error: fetchError } = await supabase
|
||||
.from('employee_benefits')
|
||||
.select('benefit_type, metadata')
|
||||
.select('benefit_type, metadata, valid_from, valid_to')
|
||||
.eq('id', benefitId)
|
||||
.eq('employee_id', id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (fetchError || !existing) {
|
||||
// Only zero rows (PGRST116) means the benefit really isn't there. A
|
||||
// transport/DB failure is not a missing record and must not be reported as
|
||||
// one.
|
||||
if (fetchError && fetchError.code !== 'PGRST116') {
|
||||
return NextResponse.json({ error: getUserErrorMessage(fetchError) }, { status: 500 })
|
||||
}
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'Förmån hittades inte' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Validity period against the MERGED state. UpdateEmployeeBenefitSchema can
|
||||
// only compare the two dates when the body carries both; when only one is
|
||||
// patched, the other half lives on the row we just fetched. Without this the
|
||||
// CHECK (valid_to IS NULL OR valid_to >= valid_from) fired in Postgres and
|
||||
// the error branch below dressed it up as "Förmån hittades inte".
|
||||
// Inclusive bound, and a null/cleared valid_to stays legal.
|
||||
const mergedValidFrom = (body.valid_from ?? existing.valid_from ?? null) as string | null
|
||||
const mergedValidTo = (
|
||||
body.valid_to !== undefined ? body.valid_to : existing.valid_to ?? null
|
||||
) as string | null
|
||||
if (mergedValidFrom !== null && mergedValidTo !== null && mergedValidTo < mergedValidFrom) {
|
||||
return NextResponse.json({ error: BENEFIT_PERIOD_ORDER_MESSAGE }, { status: 400 })
|
||||
}
|
||||
|
||||
const updates: Record<string, unknown> = { ...body }
|
||||
|
||||
if (body.annual_market_value !== undefined) {
|
||||
@@ -59,7 +79,29 @@ export const PATCH = withRouteContext<{ params: Promise<{ id: string; benefitId:
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error || !data) {
|
||||
if (error) {
|
||||
// The row's existence was already established above, so `error` here is a
|
||||
// write failure, not a lookup miss. Collapsing every error into 404
|
||||
// "Förmån hittades inte" sent users hunting for a record that exists: a
|
||||
// check_violation on valid_to >= valid_from is the one they actually hit.
|
||||
// PGRST116 (zero rows) is the only shape that still means not-found: the
|
||||
// row was deleted or moved out of the company between fetch and update.
|
||||
if (error.code === 'PGRST116') {
|
||||
return NextResponse.json({ error: 'Förmån hittades inte' }, { status: 404 })
|
||||
}
|
||||
// employee_benefits has exactly three CHECKs (migration 20260512200100):
|
||||
// benefit_type IN (...) and monthly_value >= 0 are unreachable from this
|
||||
// body (benefit_type is not patchable; the schema and the bike calc both
|
||||
// keep monthly_value non-negative), leaving the validity-period range as
|
||||
// the only one an UPDATE can trip: a concurrent write that moved the other
|
||||
// date after the merged check above.
|
||||
if (error.code === '23514') {
|
||||
return NextResponse.json({ error: BENEFIT_PERIOD_ORDER_MESSAGE }, { status: 400 })
|
||||
}
|
||||
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return NextResponse.json({ error: 'Förmån hittades inte' }, { status: 404 })
|
||||
}
|
||||
|
||||
|
||||
@@ -89,4 +89,76 @@ describe('POST /api/salary/employees/[id]/benefits', () => {
|
||||
const response = await POST(post(validBenefit), params)
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
|
||||
// Validity period: mirrors CHECK (valid_to IS NULL OR valid_to >= valid_from)
|
||||
// on employee_benefits (migration 20260512200100). Before the schema mirrored
|
||||
// it, an end date before the start date reached Postgres and came back as an
|
||||
// opaque 500.
|
||||
describe('valid_from / valid_to ordering', () => {
|
||||
it('rejects valid_to before valid_from with an actionable 400', async () => {
|
||||
// Queued as the DB would answer without the schema mirror: the employee
|
||||
// resolves, then the insert trips the CHECK. The route must never get
|
||||
// there; before the mirror this exact input returned an opaque 500.
|
||||
enqueue({ data: { id: 'emp-1' } })
|
||||
enqueue({
|
||||
data: null,
|
||||
error: { code: '23514', message: 'violates check constraint "employee_benefits_check"' },
|
||||
})
|
||||
|
||||
const response = await POST(
|
||||
post({ ...validBenefit, valid_from: '2026-06-01', valid_to: '2026-05-31' }),
|
||||
params,
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
error: string
|
||||
errors: { field: string; message: string }[]
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.errors).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ field: 'valid_to' })]),
|
||||
)
|
||||
// Names both fields as labelled in the form and how to express open-ended.
|
||||
expect(body.error).toContain('Gäller till')
|
||||
expect(body.error).toContain('Gäller från')
|
||||
expect(body.error).toContain('löpande')
|
||||
// Nothing was attempted against the DB.
|
||||
expect(supabase.from).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('accepts valid_to equal to valid_from (the bound is inclusive)', async () => {
|
||||
enqueue({ data: { id: 'emp-1' } })
|
||||
enqueue({ data: { id: 'ben-1' } })
|
||||
|
||||
const response = await POST(
|
||||
post({ ...validBenefit, valid_from: '2026-06-01', valid_to: '2026-06-01' }),
|
||||
params,
|
||||
)
|
||||
expect(response.status).toBe(201)
|
||||
})
|
||||
|
||||
it('accepts an omitted valid_to (open-ended benefit stays legal)', async () => {
|
||||
enqueue({ data: { id: 'emp-1' } })
|
||||
enqueue({ data: { id: 'ben-1' } })
|
||||
|
||||
const response = await POST(post(validBenefit), params)
|
||||
expect(response.status).toBe(201)
|
||||
})
|
||||
|
||||
it('maps a check_violation from the insert to 400, not 500', async () => {
|
||||
enqueue({ data: { id: 'emp-1' } })
|
||||
enqueue({ data: null, error: { code: '23514', message: 'violates check constraint' } })
|
||||
|
||||
const response = await POST(post(validBenefit), params)
|
||||
expect(response.status).toBe(400)
|
||||
})
|
||||
|
||||
it('still reports a genuine DB failure as 500', async () => {
|
||||
enqueue({ data: { id: 'emp-1' } })
|
||||
enqueue({ data: null, error: { code: '08006', message: 'connection failure' } })
|
||||
|
||||
const response = await POST(post(validBenefit), params)
|
||||
expect(response.status).toBe(500)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -76,7 +76,15 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
|
||||
if (error) {
|
||||
// A CHECK violation is bad input, not a server fault. The create schema
|
||||
// now mirrors every CHECK on the table (benefit_type, monthly_value >= 0,
|
||||
// valid_to >= valid_from), so this is only the backstop for non-schema
|
||||
// callers; answering 500 told the user to retry an insert that can never
|
||||
// succeed.
|
||||
const status = error.code === '23514' ? 400 : 500
|
||||
return NextResponse.json({ error: getUserErrorMessage(error) }, { status })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data }, { status: 201 })
|
||||
},
|
||||
|
||||
@@ -4,13 +4,20 @@ import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { UpdateEmployeeSchema } from '@/lib/api/schemas'
|
||||
import { getCompanyEntityType } from '@/lib/company/context'
|
||||
import { decryptPersonnummer, encryptPersonnummer, extractLast4, maskPersonnummer, validatePersonnummer } from '@/lib/salary/personnummer'
|
||||
import { encryptPersonnummer, extractLast4, maskEmployeeForResponse, validatePersonnummer } from '@/lib/salary/personnummer'
|
||||
import { isEmploymentTypeAllowedForEntity, EF_OWNER_EMPLOYMENT_ERROR } from '@/lib/salary/employment-rules'
|
||||
import { validateEmployeeBankAccount } from '@/lib/salary/payment/bank-account'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
// Response shaping lives in the shared maskEmployeeForResponse: it drops the
|
||||
// ciphertext AND personnummer_last4 (mask + last4 reassembles the full
|
||||
// personnummer) and returns the mask under the read-only `personnummer_masked`
|
||||
// key, never the writable `personnummer` key. This route both reads and writes
|
||||
// the same object shape, so a mask under the write key would round-trip
|
||||
// 'ÅÅÅÅMMDD-XXXX' straight into the encrypt path.
|
||||
|
||||
export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
'salary.employees.get',
|
||||
async (request, { supabase, companyId }, { params }) => {
|
||||
@@ -27,12 +34,7 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
...employee,
|
||||
personnummer: maskPersonnummer(decryptPersonnummer(employee.personnummer)),
|
||||
},
|
||||
})
|
||||
return NextResponse.json({ data: maskEmployeeForResponse(employee) })
|
||||
},
|
||||
)
|
||||
|
||||
@@ -100,17 +102,59 @@ export const PATCH = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
}
|
||||
}
|
||||
|
||||
// Build update object
|
||||
const updates: Record<string, unknown> = { ...body }
|
||||
// Build update object. `personnummer` is destructured OUT of the spread and
|
||||
// handled explicitly below: spreading it verbatim would let any
|
||||
// falsy-but-present value reach the row and overwrite the AES-256-GCM
|
||||
// ciphertext with it (`if (body.personnummer)` skips the encrypt branch for
|
||||
// an empty string, but `{ ...body }` has already put the empty string in
|
||||
// `updates`). The only thing that stopped that today was the 12-digit regex
|
||||
// in UpdateEmployeeSchema, i.e. a guard in another file: house style there
|
||||
// adds `.or(z.literal(''))` to optional string fields, and one such edit
|
||||
// would have turned this spread into a silent wipe of encrypted PII plus a
|
||||
// stale `personnummer_last4`. The guard belongs in the write path.
|
||||
const { personnummer: pnrInput, ...bodyWithoutPnr } = body
|
||||
const updates: Record<string, unknown> = { ...bodyWithoutPnr }
|
||||
|
||||
// Handle personnummer update if provided
|
||||
if (body.personnummer) {
|
||||
const pnrValidation = validatePersonnummer(body.personnummer)
|
||||
// personnummer semantics on PATCH:
|
||||
// • key absent / undefined → identity unchanged; ciphertext and
|
||||
// personnummer_last4 are preserved untouched.
|
||||
// • '' or null → 400. There is no "clear the personnummer"
|
||||
// operation: the column is NOT NULL, and AGI/KU filing (Skatteverket
|
||||
// FK215) cannot be produced without it. Omit the key to leave it alone.
|
||||
// • masked display value → 400. Read surfaces return the mask under
|
||||
// `personnummer_masked` so it cannot be written back by accident, but
|
||||
// reject it here too so a hand-built round-trip fails loudly instead of
|
||||
// encrypting 'ÅÅÅÅMMDD-XXXX' as somebody's identity. Note that
|
||||
// validatePersonnummer() strips non-digits, so a decorated value
|
||||
// carrying 12 real digits would otherwise pass.
|
||||
// • 12 digits → validate (format, date range, Luhn) and
|
||||
// re-encrypt, refreshing personnummer_last4 in the same update.
|
||||
const rawPnr: unknown = pnrInput
|
||||
if (rawPnr !== undefined) {
|
||||
if (typeof rawPnr !== 'string' || rawPnr.trim() === '') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'Personnummer kan inte tömmas. Utelämna fältet för att lämna det oförändrat.',
|
||||
},
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
if (/x/i.test(rawPnr)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'Maskerat personnummer kan inte sparas. Skicka hela personnummret (12 siffror) eller utelämna fältet.',
|
||||
},
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
const pnrValidation = validatePersonnummer(rawPnr)
|
||||
if (!pnrValidation.valid) {
|
||||
return NextResponse.json({ error: pnrValidation.error }, { status: 400 })
|
||||
}
|
||||
updates.personnummer = encryptPersonnummer(body.personnummer)
|
||||
updates.personnummer_last4 = extractLast4(body.personnummer)
|
||||
updates.personnummer = encryptPersonnummer(rawPnr)
|
||||
updates.personnummer_last4 = extractLast4(rawPnr)
|
||||
}
|
||||
|
||||
const { data: updated, error } = await supabase
|
||||
@@ -128,12 +172,7 @@ export const PATCH = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
...updated,
|
||||
personnummer: maskPersonnummer(decryptPersonnummer(updated.personnummer)),
|
||||
},
|
||||
})
|
||||
return NextResponse.json({ data: maskEmployeeForResponse(updated) })
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Supabase mock that records what each query actually did.
|
||||
*
|
||||
* `createQueuedMockSupabase()` in tests/helpers.ts throws call arguments away,
|
||||
* so it cannot tell "the route wrote start_time" apart from "the route silently
|
||||
* dropped it": both produce the same 201. The worked-hours bug was exactly that
|
||||
* shape (validated, documented, never written), so these tests assert on the
|
||||
* payload handed to `.insert()` and on the column list handed to `.select()`.
|
||||
*
|
||||
* Result queue semantics match createQueuedMockSupabase: one enqueued result is
|
||||
* consumed per `.from()` / `.rpc()` call, in order.
|
||||
*
|
||||
* Not a `*.test.ts` file, so Vitest does not collect it as a suite.
|
||||
*/
|
||||
import { vi } from 'vitest'
|
||||
|
||||
/** One `.from()` chain, flattened. */
|
||||
export interface RecordedOp {
|
||||
table: string
|
||||
/** First data verb seen on the chain: select / insert / update / upsert / delete. */
|
||||
verb: string
|
||||
/** Column list passed to `.select('...')`, if any. */
|
||||
columns: string | null
|
||||
/** Payload passed to `.insert()` / `.update()` / `.upsert()`, if any. */
|
||||
payload: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
const DATA_VERBS = new Set(['select', 'insert', 'update', 'upsert', 'delete'])
|
||||
|
||||
export function createRecordingSupabase() {
|
||||
const queue: { data: unknown; error: unknown }[] = []
|
||||
const ops: RecordedOp[] = []
|
||||
|
||||
const enqueue = (result: { data?: unknown; error?: unknown } = {}) => {
|
||||
queue.push({ data: result.data ?? null, error: result.error ?? null })
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
queue.length = 0
|
||||
ops.length = 0
|
||||
}
|
||||
|
||||
const buildChain = (op: RecordedOp, result: { data: unknown; error: unknown }): unknown =>
|
||||
new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_target, prop) {
|
||||
if (prop === 'then') {
|
||||
return (resolve: (v: unknown) => void) => resolve(result)
|
||||
}
|
||||
const name = String(prop)
|
||||
return (...args: unknown[]) => {
|
||||
if (DATA_VERBS.has(name)) {
|
||||
if (op.verb === '') op.verb = name
|
||||
if (name === 'select' && typeof args[0] === 'string') {
|
||||
op.columns = args[0]
|
||||
}
|
||||
if (
|
||||
(name === 'insert' || name === 'update' || name === 'upsert') &&
|
||||
args[0] !== undefined
|
||||
) {
|
||||
op.payload = args[0] as Record<string, unknown>
|
||||
}
|
||||
}
|
||||
return buildChain(op, result)
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
const start = (table: string): unknown => {
|
||||
const op: RecordedOp = { table, verb: '', columns: null, payload: null }
|
||||
ops.push(op)
|
||||
return buildChain(op, queue.shift() ?? { data: null, error: null })
|
||||
}
|
||||
|
||||
const supabase = {
|
||||
from: vi.fn((table: string) => start(table)),
|
||||
rpc: vi.fn((fn: string) => start(`rpc:${fn}`)),
|
||||
auth: { getUser: vi.fn() },
|
||||
}
|
||||
|
||||
/** Payloads of every recorded insert, in call order. */
|
||||
const insertedRows = () =>
|
||||
ops.filter((op) => op.verb === 'insert' && op.payload).map((op) => op.payload!)
|
||||
|
||||
/** Column lists of every recorded read (select-first chains), in call order. */
|
||||
const selectedColumns = () =>
|
||||
ops.filter((op) => op.verb === 'select').map((op) => op.columns)
|
||||
|
||||
return { supabase, enqueue, reset, ops, insertedRows, selectedColumns }
|
||||
}
|
||||
@@ -1,15 +1,26 @@
|
||||
/**
|
||||
* Auth-wiring tests for /api/salary/employees/[id]/worked-hours (POST upsert).
|
||||
* Tests for /api/salary/employees/[id]/worked-hours (GET list, POST upsert).
|
||||
*
|
||||
* Runs the route through the real withRouteContext wrapper; mocks auth/company/
|
||||
* write and injects a queued Supabase mock via requireAuth. Covers 401, 403
|
||||
* (viewer), and a POST happy path.
|
||||
* Runs the routes through the real withRouteContext wrapper; mocks auth/company/
|
||||
* write and injects a recording Supabase mock via requireAuth. Covers 401, 403
|
||||
* (viewer), 400, 404 and the happy path, plus the shift-window round trip.
|
||||
*
|
||||
* The shift-window tests are the point of this file. `start_time` / `end_time`
|
||||
* are validated and documented by UpsertWorkedDaySchema, but used to be dropped
|
||||
* on the way to the database. A row without times makes the shift-premium engine
|
||||
* assume a default 08:00-17:00 day, so OB-tillägg for night and weekend work
|
||||
* could never trigger and a night shift was paid as office hours. The last two
|
||||
* tests therefore feed the payload the route actually inserts into the real
|
||||
* engine and assert the premium appears.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
|
||||
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
|
||||
import { createRecordingSupabase } from './recording-supabase'
|
||||
import { computePremiumLines } from '@/lib/salary/shift-premium-engine'
|
||||
import type { ShiftPremiumRule } from '@/types'
|
||||
|
||||
const { supabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
const { supabase, enqueue, reset, insertedRows, selectedColumns } = createRecordingSupabase()
|
||||
|
||||
const requireAuthMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-auth', () => ({
|
||||
@@ -28,7 +39,7 @@ vi.mock('@/lib/auth/require-write', () => ({
|
||||
|
||||
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
|
||||
|
||||
import { POST } from '../route'
|
||||
import { GET, POST } from '../route'
|
||||
|
||||
const params = { params: Promise.resolve({ id: 'emp-1' }) } as never
|
||||
|
||||
@@ -36,6 +47,31 @@ function post(body: unknown) {
|
||||
return createMockRequest('/api/salary/employees/emp-1/worked-hours', { method: 'POST', body })
|
||||
}
|
||||
|
||||
function get(searchParams: Record<string, string>) {
|
||||
return createMockRequest('/api/salary/employees/emp-1/worked-hours', { searchParams })
|
||||
}
|
||||
|
||||
function makeRule(overrides: Partial<ShiftPremiumRule> = {}): ShiftPremiumRule {
|
||||
return {
|
||||
id: 'rule-1',
|
||||
company_id: 'company-1',
|
||||
name: 'OB natt',
|
||||
applies_to_all_employees: true,
|
||||
applies_to_employee_ids: [],
|
||||
day_of_week: [1, 2, 3, 4, 5, 6, 7],
|
||||
start_time: '22:00',
|
||||
end_time: '06:00',
|
||||
premium_percent: 70,
|
||||
item_type: 'ob_night',
|
||||
priority: 10,
|
||||
is_active: true,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:00:00Z',
|
||||
created_by: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('POST /api/salary/employees/[id]/worked-hours', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -65,6 +101,18 @@ describe('POST /api/salary/employees/[id]/worked-hours', () => {
|
||||
expect(response.status).toBe(403)
|
||||
})
|
||||
|
||||
it('returns 400 when only one half of the shift window is given', async () => {
|
||||
enqueue({ data: { id: 'emp-1', salary_type: 'hourly' } }) // loadEmployee
|
||||
|
||||
const response = await POST(
|
||||
post({ work_date: '2026-07-01', hours: 8, start_time: '22:00' }),
|
||||
params,
|
||||
)
|
||||
expect(response.status).toBe(400)
|
||||
// Nothing was written.
|
||||
expect(insertedRows()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('upserts a worked day (happy path)', async () => {
|
||||
enqueue({ data: { id: 'emp-1', salary_type: 'hourly' } }) // loadEmployee
|
||||
enqueue({ data: null }) // delete existing
|
||||
@@ -83,4 +131,190 @@ describe('POST /api/salary/employees/[id]/worked-hours', () => {
|
||||
const response = await POST(post({ work_date: '2026-07-01', hours: 8 }), params)
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
|
||||
it('writes start_time and end_time to the database', async () => {
|
||||
enqueue({ data: { id: 'emp-1', salary_type: 'hourly' } }) // loadEmployee
|
||||
enqueue({ data: null }) // delete existing
|
||||
enqueue({
|
||||
data: {
|
||||
id: 'wd-1',
|
||||
work_date: '2026-07-01',
|
||||
hours: 8,
|
||||
start_time: '22:00',
|
||||
end_time: '06:00',
|
||||
},
|
||||
}) // insert
|
||||
|
||||
const response = await POST(
|
||||
post({ work_date: '2026-07-01', hours: 8, start_time: '22:00', end_time: '06:00' }),
|
||||
params,
|
||||
)
|
||||
|
||||
expect(response.status).toBe(201)
|
||||
const rows = insertedRows()
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0]).toMatchObject({
|
||||
work_date: '2026-07-01',
|
||||
hours: 8,
|
||||
start_time: '22:00',
|
||||
end_time: '06:00',
|
||||
})
|
||||
})
|
||||
|
||||
it('stores NULL times when the caller omits the shift window', async () => {
|
||||
enqueue({ data: { id: 'emp-1', salary_type: 'hourly' } })
|
||||
enqueue({ data: null })
|
||||
enqueue({ data: { id: 'wd-1' } })
|
||||
|
||||
await POST(post({ work_date: '2026-07-01', hours: 8 }), params)
|
||||
|
||||
expect(insertedRows()[0]).toMatchObject({ start_time: null, end_time: null })
|
||||
})
|
||||
|
||||
it('makes a night shift trigger OB natt (regression: times used to be dropped)', async () => {
|
||||
// Wednesday 2026-07-01, 22:00 -> 06:00. A pure-night OB rule (22:00-06:00)
|
||||
// has zero overlap with the engine's 08:00-17:00 fallback, so before the
|
||||
// times were persisted this employee got no OB-tillägg at all.
|
||||
enqueue({ data: { id: 'emp-1', salary_type: 'hourly' } })
|
||||
enqueue({ data: null })
|
||||
enqueue({ data: { id: 'wd-1' } })
|
||||
|
||||
await POST(
|
||||
post({ work_date: '2026-07-01', hours: 8, start_time: '22:00', end_time: '06:00' }),
|
||||
params,
|
||||
)
|
||||
|
||||
const inserted = insertedRows()[0]
|
||||
const lines = computePremiumLines({
|
||||
employeeId: 'emp-1',
|
||||
baseHourlyRate: 200,
|
||||
workedDays: [
|
||||
{
|
||||
work_date: inserted.work_date as string,
|
||||
hours: inserted.hours as number,
|
||||
start_time: inserted.start_time as string | null,
|
||||
end_time: inserted.end_time as string | null,
|
||||
},
|
||||
],
|
||||
rules: [makeRule()],
|
||||
})
|
||||
|
||||
expect(lines).toHaveLength(1)
|
||||
expect(lines[0].itemType).toBe('ob_night')
|
||||
// 22:00-24:00 plus 00:00-06:00 = 8 h at 70 % of 200 SEK/h.
|
||||
expect(lines[0].hours).toBe(8)
|
||||
expect(lines[0].amount).toBe(1120)
|
||||
})
|
||||
|
||||
it('prices a Saturday evening shift on its real hours, not the fabricated day', async () => {
|
||||
// Saturday 2026-07-04, 18:00 -> 23:00 = 5 h. With a full-day weekend rule the
|
||||
// 08:00-17:00 fallback would have billed 9 h of the wrong hours instead.
|
||||
enqueue({ data: { id: 'emp-1', salary_type: 'hourly' } })
|
||||
enqueue({ data: null })
|
||||
enqueue({ data: { id: 'wd-1' } })
|
||||
|
||||
await POST(
|
||||
post({ work_date: '2026-07-04', hours: 5, start_time: '18:00', end_time: '23:00' }),
|
||||
params,
|
||||
)
|
||||
|
||||
const inserted = insertedRows()[0]
|
||||
const lines = computePremiumLines({
|
||||
employeeId: 'emp-1',
|
||||
baseHourlyRate: 200,
|
||||
workedDays: [
|
||||
{
|
||||
work_date: inserted.work_date as string,
|
||||
hours: inserted.hours as number,
|
||||
start_time: inserted.start_time as string | null,
|
||||
end_time: inserted.end_time as string | null,
|
||||
},
|
||||
],
|
||||
rules: [
|
||||
makeRule({
|
||||
id: 'rule-weekend',
|
||||
name: 'OB helg',
|
||||
item_type: 'ob_weekend',
|
||||
day_of_week: [6, 7],
|
||||
start_time: '00:00',
|
||||
end_time: '00:00', // full 24 h on Saturday and Sunday
|
||||
premium_percent: 100,
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
expect(lines).toHaveLength(1)
|
||||
expect(lines[0].itemType).toBe('ob_weekend')
|
||||
expect(lines[0].hours).toBe(5)
|
||||
expect(lines[0].amount).toBe(1000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /api/salary/employees/[id]/worked-hours', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase })
|
||||
requireWriteMock.mockResolvedValue({ ok: true })
|
||||
})
|
||||
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: null,
|
||||
supabase,
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
|
||||
const response = await GET(get({ from: '2026-07-01', to: '2026-07-31' }), params)
|
||||
expect(response.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 404 when the employee is not in the company', async () => {
|
||||
enqueue({ data: null }) // loadEmployee → not found
|
||||
|
||||
const response = await GET(get({ from: '2026-07-01', to: '2026-07-31' }), params)
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
|
||||
it('returns 400 when the range is inverted', async () => {
|
||||
enqueue({ data: { id: 'emp-1', salary_type: 'hourly' } }) // loadEmployee
|
||||
|
||||
const response = await GET(get({ from: '2026-07-31', to: '2026-07-01' }), params)
|
||||
expect(response.status).toBe(400)
|
||||
})
|
||||
|
||||
it('selects the shift window back and returns it', async () => {
|
||||
enqueue({ data: { id: 'emp-1', salary_type: 'hourly' } }) // loadEmployee
|
||||
enqueue({
|
||||
data: [
|
||||
{
|
||||
id: 'wd-1',
|
||||
work_date: '2026-07-01',
|
||||
hours: 8,
|
||||
notes: null,
|
||||
salary_run_employee_id: null,
|
||||
start_time: '22:00:00',
|
||||
end_time: '06:00:00',
|
||||
created_at: '2026-07-01T00:00:00Z',
|
||||
updated_at: '2026-07-01T00:00:00Z',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const response = await GET(get({ from: '2026-07-01', to: '2026-07-31' }), params)
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data: Array<{ start_time: string | null; end_time: string | null }>
|
||||
total_hours: number
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
// The read path has to ask for the columns, otherwise the round trip is
|
||||
// silently lossy no matter what the write path stored.
|
||||
const readColumns = selectedColumns().find((cols) => cols?.includes('work_date'))
|
||||
expect(readColumns).toContain('start_time')
|
||||
expect(readColumns).toContain('end_time')
|
||||
expect(body.data[0].start_time).toBe('22:00:00')
|
||||
expect(body.data[0].end_time).toBe('06:00:00')
|
||||
expect(body.total_hours).toBe(8)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
/**
|
||||
* Auth-wiring tests for /api/salary/employees/[id]/worked-hours/batch (POST).
|
||||
* Tests for /api/salary/employees/[id]/worked-hours/batch (POST).
|
||||
*
|
||||
* Runs the route through the real withRouteContext wrapper; mocks auth/company/
|
||||
* write and injects a queued Supabase mock via requireAuth. Covers 401, 403
|
||||
* (viewer), and a POST happy path (bulk insert).
|
||||
* write and injects a recording Supabase mock via requireAuth. Covers 401, 403
|
||||
* (viewer), 400, 404 and the happy path.
|
||||
*
|
||||
* The rest of the file guards the delete-and-reinsert. The batch carries one
|
||||
* shared body for N dates, so anything it omits (per-day notes, per-day shift
|
||||
* windows, per-day run links) has to survive the replace. It used to write the
|
||||
* shared value or NULL over every day, which wiped notes the user had written
|
||||
* and shift windows that made OB-tillägg computable.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
|
||||
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
|
||||
import { createRecordingSupabase } from '../../__tests__/recording-supabase'
|
||||
|
||||
const { supabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
const { supabase, enqueue, reset, insertedRows, ops } = createRecordingSupabase()
|
||||
|
||||
const requireAuthMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-auth', () => ({
|
||||
@@ -33,11 +40,21 @@ import { POST } from '../route'
|
||||
const params = { params: Promise.resolve({ id: 'emp-1' }) } as never
|
||||
|
||||
function post(body: unknown) {
|
||||
return createMockRequest('/api/salary/employees/emp-1/worked-hours/batch', { method: 'POST', body })
|
||||
return createMockRequest('/api/salary/employees/emp-1/worked-hours/batch', {
|
||||
method: 'POST',
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
const validBatch = { dates: ['2026-07-01', '2026-07-02'], hours: 8 }
|
||||
|
||||
/** Queue the reads the route does before its inserts: employee, prefetch, delete. */
|
||||
function enqueuePreamble(existing: unknown[] = []) {
|
||||
enqueue({ data: { id: 'emp-1' } }) // employee ownership check
|
||||
enqueue({ data: existing }) // prefetch of rows about to be replaced
|
||||
enqueue({ data: null }) // bulk delete
|
||||
}
|
||||
|
||||
describe('POST /api/salary/employees/[id]/worked-hours/batch', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -67,14 +84,31 @@ describe('POST /api/salary/employees/[id]/worked-hours/batch', () => {
|
||||
expect(response.status).toBe(403)
|
||||
})
|
||||
|
||||
it('bulk-inserts worked days (happy path)', async () => {
|
||||
it('returns 400 for an empty date list', async () => {
|
||||
enqueue({ data: { id: 'emp-1' } }) // employee ownership check
|
||||
enqueue({ data: null }) // bulk delete
|
||||
|
||||
const response = await POST(post({ dates: [], hours: 8 }), params)
|
||||
expect(response.status).toBe(400)
|
||||
expect(insertedRows()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('returns 400 when only one half of the shift window is given', async () => {
|
||||
enqueue({ data: { id: 'emp-1' } }) // employee ownership check
|
||||
|
||||
const response = await POST(post({ ...validBatch, end_time: '06:00' }), params)
|
||||
expect(response.status).toBe(400)
|
||||
expect(insertedRows()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('bulk-inserts worked days (happy path)', async () => {
|
||||
enqueuePreamble()
|
||||
enqueue({ data: null }) // insert date 1
|
||||
enqueue({ data: null }) // insert date 2
|
||||
|
||||
const response = await POST(post(validBatch), params)
|
||||
const { status, body } = await parseJsonResponse<{ data: { inserted: number; conflicts: unknown[] } }>(response)
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data: { inserted: number; conflicts: unknown[] }
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(201)
|
||||
expect(body.data.inserted).toBe(2)
|
||||
@@ -87,4 +121,249 @@ describe('POST /api/salary/employees/[id]/worked-hours/batch', () => {
|
||||
const response = await POST(post(validBatch), params)
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
|
||||
it('keeps each day its own note when the batch carries none', async () => {
|
||||
enqueuePreamble([
|
||||
{
|
||||
work_date: '2026-07-01',
|
||||
notes: 'Inventering lager',
|
||||
salary_run_employee_id: null,
|
||||
start_time: null,
|
||||
end_time: null,
|
||||
},
|
||||
{
|
||||
work_date: '2026-07-02',
|
||||
notes: 'Kundbesök Göteborg',
|
||||
salary_run_employee_id: null,
|
||||
start_time: null,
|
||||
end_time: null,
|
||||
},
|
||||
])
|
||||
enqueue({ data: null })
|
||||
enqueue({ data: null })
|
||||
|
||||
const response = await POST(post(validBatch), params)
|
||||
expect(response.status).toBe(201)
|
||||
|
||||
const rows = insertedRows()
|
||||
expect(rows).toHaveLength(2)
|
||||
expect(rows[0]).toMatchObject({ work_date: '2026-07-01', notes: 'Inventering lager' })
|
||||
expect(rows[1]).toMatchObject({ work_date: '2026-07-02', notes: 'Kundbesök Göteborg' })
|
||||
})
|
||||
|
||||
it('lets an explicit batch note override the stored per-day notes', async () => {
|
||||
enqueuePreamble([
|
||||
{
|
||||
work_date: '2026-07-01',
|
||||
notes: 'Inventering lager',
|
||||
salary_run_employee_id: null,
|
||||
start_time: null,
|
||||
end_time: null,
|
||||
},
|
||||
])
|
||||
enqueue({ data: null })
|
||||
enqueue({ data: null })
|
||||
|
||||
await POST(post({ ...validBatch, notes: 'Projektvecka' }), params)
|
||||
|
||||
const rows = insertedRows()
|
||||
expect(rows[0]).toMatchObject({ work_date: '2026-07-01', notes: 'Projektvecka' })
|
||||
expect(rows[1]).toMatchObject({ work_date: '2026-07-02', notes: 'Projektvecka' })
|
||||
})
|
||||
|
||||
it('writes the shift window when the batch supplies one', async () => {
|
||||
enqueuePreamble()
|
||||
enqueue({ data: null })
|
||||
enqueue({ data: null })
|
||||
|
||||
await POST(post({ ...validBatch, start_time: '22:00', end_time: '06:00' }), params)
|
||||
|
||||
for (const row of insertedRows()) {
|
||||
expect(row).toMatchObject({ start_time: '22:00', end_time: '06:00' })
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a stored shift window when the batch supplies none', async () => {
|
||||
// Re-marking hours must not silently erase the times: that would send the
|
||||
// day back to the engine's 08:00-17:00 fallback and drop the OB-tillägg.
|
||||
enqueuePreamble([
|
||||
{
|
||||
work_date: '2026-07-01',
|
||||
notes: null,
|
||||
salary_run_employee_id: null,
|
||||
start_time: '22:00:00',
|
||||
end_time: '06:00:00',
|
||||
},
|
||||
])
|
||||
enqueue({ data: null })
|
||||
enqueue({ data: null })
|
||||
|
||||
await POST(post(validBatch), params)
|
||||
|
||||
const rows = insertedRows()
|
||||
expect(rows[0]).toMatchObject({ start_time: '22:00:00', end_time: '06:00:00' })
|
||||
// The untouched day has no stored window, so it stays NULL.
|
||||
expect(rows[1]).toMatchObject({ start_time: null, end_time: null })
|
||||
})
|
||||
|
||||
it('keeps a stored salary_run_employee_id when the batch supplies none', async () => {
|
||||
enqueuePreamble([
|
||||
{
|
||||
work_date: '2026-07-01',
|
||||
notes: null,
|
||||
salary_run_employee_id: 'sre-1',
|
||||
start_time: null,
|
||||
end_time: null,
|
||||
},
|
||||
])
|
||||
enqueue({ data: null })
|
||||
enqueue({ data: null })
|
||||
|
||||
await POST(post(validBatch), params)
|
||||
|
||||
const rows = insertedRows()
|
||||
expect(rows[0]).toMatchObject({ salary_run_employee_id: 'sre-1' })
|
||||
expect(rows[1]).toMatchObject({ salary_run_employee_id: null })
|
||||
})
|
||||
|
||||
it('reads the existing rows before deleting them', async () => {
|
||||
enqueuePreamble()
|
||||
enqueue({ data: null })
|
||||
enqueue({ data: null })
|
||||
|
||||
await POST(post(validBatch), params)
|
||||
|
||||
const workedDayOps = ops.filter((op) => op.table === 'salary_worked_days')
|
||||
expect(workedDayOps[0].verb).toBe('select')
|
||||
expect(workedDayOps[0].columns).toContain('notes')
|
||||
expect(workedDayOps[0].columns).toContain('start_time')
|
||||
// hours must be captured too: it is what makes a destroyed row restorable
|
||||
// when a reinsert fails after the bulk delete already ran.
|
||||
expect(workedDayOps[0].columns).toContain('hours')
|
||||
expect(workedDayOps[1].verb).toBe('delete')
|
||||
})
|
||||
|
||||
it('restores the pre-existing row when a date conflicts on the 24h cap', async () => {
|
||||
// The conflict is reported as "nothing changed" for that date, so the row
|
||||
// the bulk delete destroyed must be put back verbatim: original hours,
|
||||
// notes and shift window, not the batch's values.
|
||||
enqueuePreamble([
|
||||
{
|
||||
work_date: '2026-07-01',
|
||||
hours: 5,
|
||||
notes: 'Halvdag',
|
||||
salary_run_employee_id: 'sre-1',
|
||||
start_time: '08:00:00',
|
||||
end_time: '13:00:00',
|
||||
},
|
||||
])
|
||||
enqueue({ error: { message: 'Total tid över 24h', code: '23514' } }) // insert date 1 trips the cap
|
||||
enqueue({ data: null }) // restore of date 1
|
||||
enqueue({ data: null }) // insert date 2
|
||||
|
||||
const response = await POST(post(validBatch), params)
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data: { inserted: number; conflicts: { date: string }[] }
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(207)
|
||||
expect(body.data.inserted).toBe(1)
|
||||
expect(body.data.conflicts).toHaveLength(1)
|
||||
expect(body.data.conflicts[0].date).toBe('2026-07-01')
|
||||
|
||||
const rows = insertedRows()
|
||||
// 1: failed replacement attempt, 2: restore, 3: date-2 replacement.
|
||||
expect(rows).toHaveLength(3)
|
||||
expect(rows[1]).toMatchObject({
|
||||
work_date: '2026-07-01',
|
||||
hours: 5,
|
||||
notes: 'Halvdag',
|
||||
salary_run_employee_id: 'sre-1',
|
||||
start_time: '08:00:00',
|
||||
end_time: '13:00:00',
|
||||
})
|
||||
expect(rows[2]).toMatchObject({ work_date: '2026-07-02', hours: 8 })
|
||||
})
|
||||
|
||||
it('does not attempt a restore for a conflicting date that had no prior row', async () => {
|
||||
enqueuePreamble() // nothing stored on either date
|
||||
enqueue({ error: { message: 'Total tid över 24h', code: '23514' } }) // insert date 1
|
||||
enqueue({ data: null }) // insert date 2
|
||||
|
||||
const response = await POST(post(validBatch), params)
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data: { inserted: number; conflicts: unknown[] }
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(207)
|
||||
expect(body.data.inserted).toBe(1)
|
||||
expect(body.data.conflicts).toHaveLength(1)
|
||||
// Only the two replacement attempts: no phantom restore insert.
|
||||
expect(insertedRows()).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('restores the remaining dates when an unexpected error aborts the batch', async () => {
|
||||
// Three dates; the bulk delete destroyed all three stored rows. Date 1
|
||||
// replaces fine, date 2 hits an unexpected error: dates 2 and 3 must be
|
||||
// put back before the 500 goes out, otherwise their rows are simply gone.
|
||||
const threeDates = { dates: ['2026-07-01', '2026-07-02', '2026-07-03'], hours: 8 }
|
||||
enqueuePreamble([
|
||||
{
|
||||
work_date: '2026-07-02',
|
||||
hours: 4,
|
||||
notes: 'Halvdag',
|
||||
salary_run_employee_id: null,
|
||||
start_time: null,
|
||||
end_time: null,
|
||||
},
|
||||
{
|
||||
work_date: '2026-07-03',
|
||||
hours: 6,
|
||||
notes: 'Kundbesök',
|
||||
salary_run_employee_id: 'sre-9',
|
||||
start_time: '10:00:00',
|
||||
end_time: '16:00:00',
|
||||
},
|
||||
])
|
||||
enqueue({ data: null }) // insert date 1: ok
|
||||
enqueue({ error: { message: 'connection reset', code: '08006' } }) // insert date 2: unexpected
|
||||
enqueue({ data: null }) // restore date 2
|
||||
enqueue({ data: null }) // restore date 3
|
||||
|
||||
const response = await POST(post(threeDates), params)
|
||||
const { status, body } = await parseJsonResponse<{ inserted: number }>(response)
|
||||
|
||||
expect(status).toBe(500)
|
||||
expect(body.inserted).toBe(1)
|
||||
|
||||
const rows = insertedRows()
|
||||
// 1: date-1 replacement, 2: failed date-2 attempt, 3-4: restores.
|
||||
expect(rows).toHaveLength(4)
|
||||
expect(rows[2]).toMatchObject({ work_date: '2026-07-02', hours: 4, notes: 'Halvdag' })
|
||||
expect(rows[3]).toMatchObject({
|
||||
work_date: '2026-07-03',
|
||||
hours: 6,
|
||||
notes: 'Kundbesök',
|
||||
salary_run_employee_id: 'sre-9',
|
||||
start_time: '10:00:00',
|
||||
end_time: '16:00:00',
|
||||
})
|
||||
// Date 1 was successfully replaced: it must NOT be clobbered by a restore.
|
||||
expect(rows.filter((r) => r.work_date === '2026-07-01')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('aborts without deleting anything when the prefetch fails', async () => {
|
||||
enqueue({ data: { id: 'emp-1' } }) // employee ownership check
|
||||
enqueue({ error: { message: 'connection reset', code: '08006' } }) // prefetch fails
|
||||
|
||||
const response = await POST(post(validBatch), params)
|
||||
|
||||
expect(response.status).toBe(500)
|
||||
expect(insertedRows()).toHaveLength(0)
|
||||
// The only salary_worked_days query issued was the failed read: had the
|
||||
// delete run first, the batch would have destroyed rows it never replaced.
|
||||
const workedDayOps = ops.filter((op) => op.table === 'salary_worked_days')
|
||||
expect(workedDayOps).toHaveLength(1)
|
||||
expect(workedDayOps[0].verb).toBe('select')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,9 +12,24 @@ interface BatchConflict {
|
||||
reason: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-day values the delete-and-reinsert below must not destroy. `hours`
|
||||
* is captured too: it is not merged into the replacement rows (the batch's
|
||||
* whole point is to overwrite hours), but it is what makes a destroyed row
|
||||
* restorable when the replacement insert fails after the delete already ran.
|
||||
*/
|
||||
interface ExistingWorkedDay {
|
||||
work_date: string
|
||||
hours: number
|
||||
notes: string | null
|
||||
salary_run_employee_id: string | null
|
||||
start_time: string | null
|
||||
end_time: string | null
|
||||
}
|
||||
|
||||
export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
'salary.employees.worked_hours.batch',
|
||||
async (request, { supabase, companyId }, { params }) => {
|
||||
async (request, { supabase, companyId, log }, { params }) => {
|
||||
const { id: employeeId } = await params
|
||||
|
||||
const { data: employee } = await supabase
|
||||
@@ -35,6 +50,36 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
// (e.g. shift-clicking over the same date twice).
|
||||
const uniqueDates = Array.from(new Set(body.dates))
|
||||
|
||||
// Read the rows we are about to replace BEFORE deleting them. The batch
|
||||
// carries one shared value for N dates, so it cannot express per-day notes,
|
||||
// per-day shift windows or per-day run links: anything the body omits has to
|
||||
// survive the replace. Without this, "mark Mon-Fri as 8 h" silently wipes
|
||||
// every note the user wrote on those days and every shift window that made
|
||||
// OB-tillägg computable. Read first so a failure here aborts before we have
|
||||
// deleted anything.
|
||||
const { data: existingRows, error: existingError } = await supabase
|
||||
.from('salary_worked_days')
|
||||
.select('work_date, hours, notes, salary_run_employee_id, start_time, end_time')
|
||||
.eq('company_id', companyId)
|
||||
.eq('employee_id', employeeId)
|
||||
.in('work_date', uniqueDates)
|
||||
|
||||
if (existingError) {
|
||||
return NextResponse.json({ error: getUserErrorMessage(existingError) }, { status: 500 })
|
||||
}
|
||||
|
||||
const existingByDate = new Map<string, ExistingWorkedDay>(
|
||||
((existingRows ?? []) as ExistingWorkedDay[]).map((row) => [row.work_date, row]),
|
||||
)
|
||||
|
||||
// A supplied shift window applies to every date in the batch; an omitted one
|
||||
// leaves each day's stored window alone. Resolved as a pair so a body start
|
||||
// time is never mixed with a stored end time (the schema pairs them too).
|
||||
const bodyShiftWindow =
|
||||
body.start_time != null && body.end_time != null
|
||||
? { start_time: body.start_time, end_time: body.end_time }
|
||||
: null
|
||||
|
||||
// Bulk delete existing rows on these dates first so the per-row insert step
|
||||
// is a clean replace. Stays within RLS via company_id + employee_id filter.
|
||||
const { error: deleteError } = await supabase
|
||||
@@ -48,30 +93,98 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
return NextResponse.json({ error: getUserErrorMessage(deleteError) }, { status: 500 })
|
||||
}
|
||||
|
||||
// Reinsert a captured pre-existing row verbatim. The bulk delete above has
|
||||
// already destroyed the stored row for every date in the batch, so when a
|
||||
// date's replacement insert fails, this is what turns "row destroyed" back
|
||||
// into "nothing changed". The original row passed the 24h-cap trigger when
|
||||
// it was first written, so restoring the identical values is expected to
|
||||
// pass it again.
|
||||
const restoreDay = async (existing: ExistingWorkedDay) => {
|
||||
const { error } = await supabase.from('salary_worked_days').insert({
|
||||
company_id: companyId,
|
||||
employee_id: employeeId,
|
||||
work_date: existing.work_date,
|
||||
hours: existing.hours,
|
||||
notes: existing.notes,
|
||||
salary_run_employee_id: existing.salary_run_employee_id,
|
||||
start_time: existing.start_time,
|
||||
end_time: existing.end_time,
|
||||
})
|
||||
return error
|
||||
}
|
||||
|
||||
// Best-effort restore of every not-yet-processed date's pre-existing row,
|
||||
// used when an unexpected error aborts the loop: without it, every date
|
||||
// after the failure point would have been deleted and never reinserted.
|
||||
const restoreRemaining = async (fromIndex: number) => {
|
||||
for (let j = fromIndex; j < uniqueDates.length; j++) {
|
||||
const existing = existingByDate.get(uniqueDates[j])
|
||||
if (!existing) continue
|
||||
const restoreError = await restoreDay(existing)
|
||||
if (restoreError) {
|
||||
log.error('worked-hours batch: failed to restore a deleted row after an aborted batch', {
|
||||
employeeId,
|
||||
workDate: existing.work_date,
|
||||
error: restoreError.message,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Per-row insert so we can isolate trigger failures (24h cap on a date with
|
||||
// existing absence) without aborting the whole batch. A single multi-row
|
||||
// insert would fail-fast and surface only the first conflict.
|
||||
const conflicts: BatchConflict[] = []
|
||||
let inserted = 0
|
||||
|
||||
for (const date of uniqueDates) {
|
||||
for (let i = 0; i < uniqueDates.length; i++) {
|
||||
const date = uniqueDates[i]
|
||||
const existing = existingByDate.get(date)
|
||||
const shiftWindow = bodyShiftWindow ?? {
|
||||
start_time: existing?.start_time ?? null,
|
||||
end_time: existing?.end_time ?? null,
|
||||
}
|
||||
const { error } = await supabase
|
||||
.from('salary_worked_days')
|
||||
.insert({
|
||||
company_id: companyId,
|
||||
employee_id: employeeId,
|
||||
work_date: date,
|
||||
// hours is the point of the batch: it always overwrites.
|
||||
hours: body.hours,
|
||||
notes: body.notes ?? null,
|
||||
salary_run_employee_id: body.salary_run_employee_id ?? null,
|
||||
// Everything else: the body wins when it carries a value, otherwise
|
||||
// the day keeps what it already had.
|
||||
notes: body.notes ?? existing?.notes ?? null,
|
||||
salary_run_employee_id:
|
||||
body.salary_run_employee_id ?? existing?.salary_run_employee_id ?? null,
|
||||
start_time: shiftWindow.start_time,
|
||||
end_time: shiftWindow.end_time,
|
||||
})
|
||||
if (error) {
|
||||
// 24h cap trigger uses ERRCODE check_violation (23514) and a Swedish
|
||||
// message starting with "Total tid". Other failures are unexpected.
|
||||
if (error.message?.includes('Total tid') || error.code === '23514') {
|
||||
// The conflict report says "nothing changed for this date": make
|
||||
// that true by reinserting the pre-existing row the bulk delete
|
||||
// destroyed. A date with no prior row has nothing to restore.
|
||||
if (existing) {
|
||||
const restoreError = await restoreDay(existing)
|
||||
if (restoreError) {
|
||||
// The restore itself failed: the date's data IS lost unless the
|
||||
// remaining dates are put back and the caller is told loudly.
|
||||
await restoreRemaining(i + 1)
|
||||
return NextResponse.json(
|
||||
{ error: getUserErrorMessage(restoreError), inserted, conflicts },
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
}
|
||||
conflicts.push({ date, reason: getUserErrorMessage(error) })
|
||||
continue
|
||||
}
|
||||
// Unexpected error: put back the captured rows for this date and every
|
||||
// date the loop never reached, then surface the failure.
|
||||
await restoreRemaining(i)
|
||||
return NextResponse.json(
|
||||
{ error: getUserErrorMessage(error), inserted, conflicts },
|
||||
{ status: 500 },
|
||||
|
||||
@@ -43,7 +43,9 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('salary_worked_days')
|
||||
.select('id, work_date, hours, notes, salary_run_employee_id, created_at, updated_at')
|
||||
.select(
|
||||
'id, work_date, hours, notes, salary_run_employee_id, start_time, end_time, created_at, updated_at',
|
||||
)
|
||||
.eq('company_id', companyId)
|
||||
.eq('employee_id', employeeId)
|
||||
.gte('work_date', query.data.from)
|
||||
@@ -80,6 +82,11 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
// Upsert via DELETE+INSERT on the natural key (employee, date). Worked days
|
||||
// have one row per date: re-marking overwrites. Mirrors the absence route's
|
||||
// pattern so behaviour stays predictable across the two calendars.
|
||||
//
|
||||
// Replace semantics are deliberate here: this endpoint addresses exactly one
|
||||
// day and the caller can describe every field of it, so an omitted field
|
||||
// means "not set". The batch endpoint cannot make that claim (one shared
|
||||
// body for N dates), which is why it carries omitted values forward instead.
|
||||
const { error: deleteError } = await supabase
|
||||
.from('salary_worked_days')
|
||||
.delete()
|
||||
@@ -100,6 +107,13 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
hours: body.hours,
|
||||
notes: body.notes ?? null,
|
||||
salary_run_employee_id: body.salary_run_employee_id ?? null,
|
||||
// The shift window. Persisting it is what lets the shift-premium engine
|
||||
// intersect the real hours with the OB rule windows; a row without times
|
||||
// falls back to an assumed 08:00-17:00 day, so a night shift would be
|
||||
// paid as office hours and OB-tillägg would never trigger. The schema
|
||||
// guarantees both fields are present or both absent.
|
||||
start_time: body.start_time ?? null,
|
||||
end_time: body.end_time ?? null,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
@@ -26,7 +26,7 @@ vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}))
|
||||
|
||||
import { GET } from '../route'
|
||||
import { GET, POST } from '../route'
|
||||
import { requireAuth } from '@/lib/auth/require-auth'
|
||||
import { encryptPersonnummer } from '@/lib/salary/personnummer'
|
||||
|
||||
@@ -77,8 +77,8 @@ describe('GET /api/salary/employees', () => {
|
||||
it('does not 500 on a mixed plaintext + encrypted roster; masks both', async () => {
|
||||
authed(
|
||||
supabaseWithRows([
|
||||
{ id: 'e1', last_name: 'A', personnummer: PLAINTEXT_PNR },
|
||||
{ id: 'e2', last_name: 'B', personnummer: ENCRYPTED_PNR },
|
||||
{ id: 'e1', last_name: 'A', personnummer: PLAINTEXT_PNR, personnummer_last4: '0000' },
|
||||
{ id: 'e2', last_name: 'B', personnummer: ENCRYPTED_PNR, personnummer_last4: '0000' },
|
||||
]),
|
||||
)
|
||||
|
||||
@@ -86,10 +86,93 @@ describe('GET /api/salary/employees', () => {
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
// Both rows masked birthdate-visible, last-4 hidden.
|
||||
expect(body.data[0].personnummer).toBe('19000101-XXXX')
|
||||
expect(body.data[1].personnummer).toBe('19020304-XXXX')
|
||||
expect(body.data[0].personnummer_masked).toBe('19000101-XXXX')
|
||||
expect(body.data[1].personnummer_masked).toBe('19020304-XXXX')
|
||||
// Neither the plaintext nor the stored ciphertext may leak.
|
||||
expect(JSON.stringify(body)).not.toContain(PLAINTEXT_PNR)
|
||||
expect(JSON.stringify(body)).not.toContain(ENCRYPTED_PNR)
|
||||
})
|
||||
|
||||
it('never returns the mask under the writable `personnummer` key', async () => {
|
||||
// The roster feeds edit forms. If the mask came back as `personnummer`, a
|
||||
// client that reads a row and writes it back would post 'ÅÅÅÅMMDD-XXXX'
|
||||
// into the encrypt path. The masked value lives under `personnummer_masked`
|
||||
// so the read key and the write key can never be confused.
|
||||
authed(supabaseWithRows([{ id: 'e1', last_name: 'A', personnummer: ENCRYPTED_PNR }]))
|
||||
|
||||
const res = await GET(req(), params)
|
||||
const body = await res.json()
|
||||
expect(body.data[0].personnummer).toBeUndefined()
|
||||
expect('personnummer' in body.data[0]).toBe(false)
|
||||
expect(body.data[0].personnummer_masked).toBe('19020304-XXXX')
|
||||
})
|
||||
|
||||
it('strips personnummer_last4 so the mask cannot be reassembled', async () => {
|
||||
// The mask is YYYYMMDD-XXXX. A response carrying the mask AND the last
|
||||
// four digits hands the client the full personnummer by concatenation, so
|
||||
// personnummer_last4 must never ride along with the roster rows.
|
||||
authed(
|
||||
supabaseWithRows([
|
||||
{ id: 'e1', last_name: 'A', personnummer: ENCRYPTED_PNR, personnummer_last4: '0000' },
|
||||
]),
|
||||
)
|
||||
|
||||
const res = await GET(req(), params)
|
||||
const body = await res.json()
|
||||
expect(body.data[0]).not.toHaveProperty('personnummer_last4')
|
||||
expect(body.data[0]).not.toHaveProperty('personnummer')
|
||||
expect(body.data[0].personnummer_masked).toBe('19020304-XXXX')
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/salary/employees', () => {
|
||||
// Luhn-valid synthetic personnummer (checksum verified in personnummer.test.ts).
|
||||
const NEW_PNR = '199001019802'
|
||||
|
||||
function supabaseWithInsert(returned: Record<string, unknown>) {
|
||||
const single = vi.fn(() => Promise.resolve({ data: returned, error: null }))
|
||||
const select = vi.fn(() => ({ single }))
|
||||
const insert = vi.fn(() => ({ select }))
|
||||
return { supabase: { from: vi.fn(() => ({ insert })) }, insert }
|
||||
}
|
||||
|
||||
it('create response carries the mask only: no ciphertext, no last4', async () => {
|
||||
const inserted = {
|
||||
id: 'emp-new',
|
||||
company_id: 'company-1',
|
||||
first_name: 'Test',
|
||||
last_name: 'Testsson',
|
||||
personnummer: encryptPersonnummer(NEW_PNR),
|
||||
personnummer_last4: '9802',
|
||||
employment_type: 'employee',
|
||||
}
|
||||
const { supabase } = supabaseWithInsert(inserted)
|
||||
authed(supabase)
|
||||
|
||||
const res = await POST(
|
||||
new Request('https://x.test/api/salary/employees', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
first_name: 'Test',
|
||||
last_name: 'Testsson',
|
||||
personnummer: NEW_PNR,
|
||||
employment_start: '2026-01-01',
|
||||
monthly_salary: 30000,
|
||||
tax_table_number: 34,
|
||||
tax_municipality: 'Stockholm',
|
||||
}),
|
||||
}),
|
||||
params,
|
||||
)
|
||||
expect(res.status).toBe(201)
|
||||
const body = await res.json()
|
||||
expect(body.data.personnummer_masked).toBe('19900101-XXXX')
|
||||
expect(body.data).not.toHaveProperty('personnummer')
|
||||
expect(body.data).not.toHaveProperty('personnummer_last4')
|
||||
// Neither the full personnummer nor its suffix may appear anywhere in the
|
||||
// serialized response: mask + last4 would reassemble the identity.
|
||||
expect(JSON.stringify(body)).not.toContain(NEW_PNR)
|
||||
expect(JSON.stringify(body)).not.toContain('9802')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,7 +4,7 @@ import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { CreateEmployeeSchema } from '@/lib/api/schemas'
|
||||
import { getCompanyEntityType } from '@/lib/company/context'
|
||||
import { decryptPersonnummer, encryptPersonnummer, extractLast4, maskPersonnummer, validatePersonnummer } from '@/lib/salary/personnummer'
|
||||
import { encryptPersonnummer, extractLast4, maskEmployeeForResponse, maskPersonnummer, validatePersonnummer } from '@/lib/salary/personnummer'
|
||||
import { isEmploymentTypeAllowedForEntity, EF_OWNER_EMPLOYMENT_ERROR } from '@/lib/salary/employment-rules'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
|
||||
@@ -29,11 +29,10 @@ export const GET = withRouteContext('salary.employees.list', async (request, { s
|
||||
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
|
||||
}
|
||||
|
||||
// Mask personnummer: show birthdate, hide the 4-digit suffix
|
||||
const masked = (data || []).map(emp => ({
|
||||
...emp,
|
||||
personnummer: maskPersonnummer(decryptPersonnummer(emp.personnummer)),
|
||||
}))
|
||||
// Mask personnummer: show birthdate, hide the 4-digit suffix. The shared
|
||||
// helper also strips personnummer_last4: mask + last4 in the same payload
|
||||
// would reassemble the full personnummer.
|
||||
const masked = (data || []).map((emp) => maskEmployeeForResponse(emp))
|
||||
|
||||
return NextResponse.json({ data: masked })
|
||||
})
|
||||
@@ -114,10 +113,15 @@ export const POST = withRouteContext('salary.employees.create', async (request,
|
||||
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
|
||||
}
|
||||
|
||||
// Same rule as the read surfaces: the create response carries the mask under
|
||||
// `personnummer_masked` only. Spreading the inserted row would otherwise echo
|
||||
// the stored ciphertext under `personnummer`, and personnummer_last4 must not
|
||||
// ride along with the mask (mask + last4 = the full personnummer).
|
||||
const { personnummer: _storedPnr, personnummer_last4: _storedLast4, ...created } = employee
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
...employee,
|
||||
personnummer: maskPersonnummer(body.personnummer),
|
||||
...created,
|
||||
personnummer_masked: maskPersonnummer(body.personnummer),
|
||||
},
|
||||
}, { status: 201 })
|
||||
}, { requireWrite: true })
|
||||
|
||||
@@ -28,13 +28,9 @@ vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/salary/personnummer', () => ({
|
||||
decryptPersonnummer: vi.fn((v: string) => v),
|
||||
maskPersonnummer: vi.fn(() => '19900101-****'),
|
||||
}))
|
||||
|
||||
import { DELETE, GET } from '../route'
|
||||
import { requireAuth } from '@/lib/auth/require-auth'
|
||||
import { encryptPersonnummer } from '@/lib/salary/personnummer'
|
||||
|
||||
// ── Test data ────────────────────────────────────────────────
|
||||
|
||||
@@ -228,6 +224,55 @@ describe('GET /api/salary/runs/[id] — additive detail fields', () => {
|
||||
expect(body.data.previous_run).toBeNull()
|
||||
})
|
||||
|
||||
it('masks embedded employees: no ciphertext, no personnummer_last4', async () => {
|
||||
// The mask is YYYYMMDD-XXXX; a payload carrying the mask AND the last four
|
||||
// digits would reassemble the full personnummer by concatenation.
|
||||
const STORED_PNR = '190203040000' // synthetic
|
||||
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
||||
authed(supabase)
|
||||
|
||||
enqueueMany([
|
||||
{ data: GET_RUN },
|
||||
{
|
||||
data: [
|
||||
{
|
||||
id: 'sre-1',
|
||||
gross_salary: 30000,
|
||||
employee: {
|
||||
id: 'emp-1',
|
||||
first_name: 'Test',
|
||||
last_name: 'Testsson',
|
||||
personnummer: encryptPersonnummer(STORED_PNR),
|
||||
personnummer_last4: '0000',
|
||||
employment_type: 'employee',
|
||||
default_dimensions: {},
|
||||
},
|
||||
line_items: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ data: null }, // settings
|
||||
{ data: null }, // no previous booked run
|
||||
{ data: [] }, // deliveries
|
||||
])
|
||||
|
||||
const response = await GET(
|
||||
createMockRequest('/api/salary/runs/run-2'),
|
||||
createMockRouteParams({ id: 'run-2' }),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data: { employees: Array<{ employee: Record<string, unknown> }> }
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
const employee = body.data.employees[0].employee
|
||||
expect(employee.personnummer_masked).toBe('19020304-XXXX')
|
||||
expect(employee).not.toHaveProperty('personnummer')
|
||||
expect(employee).not.toHaveProperty('personnummer_last4')
|
||||
// Neither the plaintext nor the suffix may appear anywhere in the payload.
|
||||
expect(JSON.stringify(body)).not.toContain(STORED_PNR)
|
||||
})
|
||||
|
||||
it('exposes corrected_by_run_id on corrected originals and counts latest deliveries', async () => {
|
||||
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
||||
authed(supabase)
|
||||
|
||||
@@ -20,14 +20,10 @@ vi.mock('@/lib/company/context', () => ({
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}))
|
||||
vi.mock('@/lib/salary/personnummer', () => ({
|
||||
decryptPersonnummer: (x: string) => x,
|
||||
maskPersonnummer: (x: string) => x,
|
||||
}))
|
||||
|
||||
import { PATCH } from '../route'
|
||||
import { GET, PATCH } from '../route'
|
||||
import { requireAuth } from '@/lib/auth/require-auth'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import { encryptPersonnummer } from '@/lib/salary/personnummer'
|
||||
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
|
||||
@@ -41,6 +37,53 @@ function authed() {
|
||||
return { supabase, enqueueMany }
|
||||
}
|
||||
|
||||
describe('GET /api/salary/runs/[id]/employees/[employeeId]', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(requireWritePermission).mockResolvedValue({ ok: true } as never)
|
||||
})
|
||||
|
||||
it('masks the embedded employee: no ciphertext, no personnummer_last4', async () => {
|
||||
// The embed is employees(*), so personnummer_last4 rides along from the DB.
|
||||
// The mask is YYYYMMDD-XXXX: mask + last4 in the same payload would
|
||||
// reassemble the full personnummer, so both pn-derived columns must be
|
||||
// stripped before the payload leaves the server.
|
||||
const STORED_PNR = '190203040000' // synthetic
|
||||
const { enqueueMany } = authed()
|
||||
enqueueMany([
|
||||
{
|
||||
data: {
|
||||
id: 'sre-1',
|
||||
gross_salary: 30000,
|
||||
employee: {
|
||||
id: 'emp-1',
|
||||
first_name: 'Test',
|
||||
last_name: 'Testsson',
|
||||
personnummer: encryptPersonnummer(STORED_PNR),
|
||||
personnummer_last4: '0000',
|
||||
employment_type: 'employee',
|
||||
},
|
||||
line_items: [],
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
const response = await GET(
|
||||
createMockRequest('/api/salary/runs/run-1/employees/emp-1'),
|
||||
createMockRouteParams({ id: 'run-1', employeeId: 'emp-1' }),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data: { employee: Record<string, unknown> }
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.employee.personnummer_masked).toBe('19020304-XXXX')
|
||||
expect(body.data.employee).not.toHaveProperty('personnummer')
|
||||
expect(body.data.employee).not.toHaveProperty('personnummer_last4')
|
||||
expect(JSON.stringify(body)).not.toContain(STORED_PNR)
|
||||
})
|
||||
})
|
||||
|
||||
describe('PATCH /api/salary/runs/[id]/employees/[employeeId]: monthly salary edit', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ensureInitialized } from '@/lib/init'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { SalaryEmployeeOverrideSchema } from '@/lib/api/schemas'
|
||||
import { decryptPersonnummer, maskPersonnummer } from '@/lib/salary/personnummer'
|
||||
import { maskEmployeeForResponse } from '@/lib/salary/personnummer'
|
||||
import { removeEmployeeFromRun } from '@/lib/salary/run-employees'
|
||||
import { getErrorEntry } from '@/lib/errors/structured-errors'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
@@ -32,17 +32,17 @@ export const GET = withRouteContext<{ params: Promise<{ id: string; employeeId:
|
||||
return NextResponse.json({ error: 'Anställd hittades inte i lönekörningen' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Strip the encrypted personnummer ciphertext before sending to the browser:
|
||||
// replace it with the YYYYMMDD-XXXX masked form so the page can render
|
||||
// identity without exposing the suffix or the raw cipher blob.
|
||||
// Strip the encrypted personnummer ciphertext AND personnummer_last4
|
||||
// before sending to the browser (the embed is employees(*), so the last4
|
||||
// column rides along otherwise, and mask + last4 reassembles the full
|
||||
// personnummer). The shared helper replaces both with the YYYYMMDD-XXXX
|
||||
// masked form under `personnummer_masked`, never under the writable
|
||||
// `personnummer` key: this payload is an employee object, and a mask under
|
||||
// the write key could be read here and posted straight back into the
|
||||
// encrypt path.
|
||||
const masked = {
|
||||
...data,
|
||||
employee: data.employee
|
||||
? {
|
||||
...data.employee,
|
||||
personnummer: maskPersonnummer(decryptPersonnummer(data.employee.personnummer)),
|
||||
}
|
||||
: data.employee,
|
||||
employee: data.employee ? maskEmployeeForResponse(data.employee) : data.employee,
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: masked })
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* A one-field PATCH must not rewrite the fields it did not name.
|
||||
*
|
||||
* UpdateSalaryLineItemSchema is CreateSalaryLineItemSchema.partial(), and
|
||||
* .partial() does NOT strip .default(). Parsed bare, `{ amount: 5500 }` comes
|
||||
* back carrying is_taxable/is_avgift_basis/is_vacation_basis=true,
|
||||
* is_gross_deduction/is_net_deduction=false and sort_order=0, and
|
||||
* updatePayslipLine spreads the patch straight into .update(). Correcting the
|
||||
* amount on a net deduction line would have silently converted it into a
|
||||
* taxable, avgift-bearing, vacation-bearing earning: wrong skatteavdrag, wrong
|
||||
* arbetsgivaravgifter, wrong semesterlöneskuld.
|
||||
*
|
||||
* The writer is mocked here on purpose: the assertion is about the exact patch
|
||||
* object handed to it, which is the thing that reaches the UPDATE.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { createMockRequest, parseJsonResponse, createMockRouteParams } from '@/tests/helpers'
|
||||
|
||||
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
|
||||
vi.mock('@/lib/auth/require-auth', () => ({ requireAuth: vi.fn() }))
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}))
|
||||
vi.mock('@/lib/salary/payslip-lines', () => ({
|
||||
updatePayslipLine: vi.fn(),
|
||||
deletePayslipLine: vi.fn(),
|
||||
}))
|
||||
|
||||
import { PATCH } from '../route'
|
||||
import { requireAuth } from '@/lib/auth/require-auth'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import { updatePayslipLine } from '@/lib/salary/payslip-lines'
|
||||
|
||||
const params = () => createMockRouteParams({ id: 'run-1', lineId: 'line-1' })
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(requireAuth).mockResolvedValue({
|
||||
user: { id: 'user-1', email: 'test@test.se' } as never,
|
||||
supabase: {} as never,
|
||||
error: null,
|
||||
})
|
||||
vi.mocked(requireWritePermission).mockResolvedValue({ ok: true } as never)
|
||||
vi.mocked(updatePayslipLine).mockResolvedValue({
|
||||
ok: true,
|
||||
data: { id: 'line-1' } as never,
|
||||
})
|
||||
})
|
||||
|
||||
async function patch(body: unknown) {
|
||||
return PATCH(
|
||||
createMockRequest('/api/salary/runs/run-1/lines/line-1', { method: 'PATCH', body }),
|
||||
params(),
|
||||
)
|
||||
}
|
||||
|
||||
/** The patch object the route handed to the writer. */
|
||||
function sentPatch(): Record<string, unknown> {
|
||||
const call = vi.mocked(updatePayslipLine).mock.calls[0]
|
||||
return (call[1] as { patch: Record<string, unknown> }).patch
|
||||
}
|
||||
|
||||
describe('PATCH /api/salary/runs/[id]/lines/[lineId] sparse patch', () => {
|
||||
it('sends ONLY the named field, not the whole default set', async () => {
|
||||
const response = await patch({ amount: 5500 })
|
||||
expect(response.status).toBe(200)
|
||||
expect(sentPatch()).toEqual({ amount: 5500 })
|
||||
})
|
||||
|
||||
it('does not resurrect the taxability / avgift / vacation flags', async () => {
|
||||
await patch({ amount: 5500 })
|
||||
const sent = sentPatch()
|
||||
for (const flag of [
|
||||
'is_taxable',
|
||||
'is_avgift_basis',
|
||||
'is_vacation_basis',
|
||||
'is_gross_deduction',
|
||||
'is_net_deduction',
|
||||
'sort_order',
|
||||
]) {
|
||||
expect(sent, `${flag} must not be written by an amount-only PATCH`).not.toHaveProperty(flag)
|
||||
}
|
||||
})
|
||||
|
||||
it('still writes a default-carrying flag when the caller sets it explicitly', async () => {
|
||||
await patch({ is_net_deduction: true })
|
||||
expect(sentPatch()).toEqual({ is_net_deduction: true })
|
||||
})
|
||||
|
||||
it('rejects null on a non-nullable field rather than quietly clearing it', async () => {
|
||||
// No field on CreateSalaryLineItemSchema is .nullable(), so "clear this
|
||||
// column" is not expressible on this endpoint. sparsePatchBody must keep
|
||||
// that a 400 and not translate null into a write. (The null-survives-as-a-
|
||||
// deliberate-clear path is covered where the schema allows it: see
|
||||
// lib/api/__tests__/sparse-patch.test.ts and the accounts PUT tests.)
|
||||
const response = await patch({ account_number: null })
|
||||
expect(response.status).toBe(400)
|
||||
expect(updatePayslipLine).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('drops unknown keys instead of forwarding them to the update', async () => {
|
||||
await patch({ amount: 100, company_id: 'other-company', id: 'other-line' })
|
||||
expect(sentPatch()).toEqual({ amount: 100 })
|
||||
})
|
||||
|
||||
it('returns 400 for an empty body instead of writing an empty update', async () => {
|
||||
const response = await patch({})
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
expect(status).toBe(400)
|
||||
expect(body.error).toBe('Inget att uppdatera')
|
||||
expect(updatePayslipLine).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still rejects an invalid value with a 400', async () => {
|
||||
const response = await patch({ amount: 'inte ett tal' })
|
||||
expect(response.status).toBe(400)
|
||||
expect(updatePayslipLine).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { sparsePatchBody } from '@/lib/api/sparse-patch'
|
||||
import { UpdateSalaryLineItemSchema } from '@/lib/api/schemas'
|
||||
import { updatePayslipLine, deletePayslipLine } from '@/lib/salary/payslip-lines'
|
||||
import { getErrorEntry } from '@/lib/errors/structured-errors'
|
||||
@@ -22,9 +23,20 @@ export const PATCH = withRouteContext<{ params: Promise<{ id: string; lineId: st
|
||||
const { id, lineId } = await params
|
||||
const { supabase, companyId } = ctx
|
||||
|
||||
const validation = await validateBody(request, UpdateSalaryLineItemSchema)
|
||||
// sparsePatchBody, not the bare schema: UpdateSalaryLineItemSchema is
|
||||
// CreateSalaryLineItemSchema.partial(), and .partial() does NOT strip
|
||||
// .default(). Parsed bare, `{ amount: 5500 }` comes back carrying
|
||||
// is_taxable/is_avgift_basis/is_vacation_basis=true,
|
||||
// is_gross_deduction/is_net_deduction=false, sort_order=0, all of which
|
||||
// updatePayslipLine spreads straight into .update(). Correcting the amount
|
||||
// on a net deduction line would silently turn it into a taxable earning.
|
||||
const validation = await validateBody(request, sparsePatchBody(UpdateSalaryLineItemSchema))
|
||||
if (!validation.success) return validation.response
|
||||
|
||||
if (Object.keys(validation.data).length === 0) {
|
||||
return NextResponse.json({ error: 'Inget att uppdatera' }, { status: 400 })
|
||||
}
|
||||
|
||||
const result = await updatePayslipLine(supabase, {
|
||||
companyId,
|
||||
salaryRunId: id,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { formatRedovisare } from '@/lib/skatteverket/format'
|
||||
import { decryptPersonnummer, maskPersonnummer } from '@/lib/salary/personnummer'
|
||||
import { maskEmployeeForResponse } from '@/lib/salary/personnummer'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
|
||||
ensureInitialized()
|
||||
@@ -41,7 +41,7 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
// client offer an already-added employee and get a confusing 409.
|
||||
supabase
|
||||
.from('salary_run_employees')
|
||||
.select('*, employee:employees(id, first_name, last_name, personnummer, personnummer_last4, employment_type, default_dimensions), line_items:salary_line_items(*)')
|
||||
.select('*, employee:employees(id, first_name, last_name, personnummer, employment_type, default_dimensions), line_items:salary_line_items(*)')
|
||||
.eq('salary_run_id', id)
|
||||
.order('created_at'),
|
||||
// Skatteverket arbetsgivare ID for AGI submission.
|
||||
@@ -156,12 +156,11 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
previous_run: previousRun,
|
||||
corrected_by_run_id: correctedByRunId,
|
||||
payslip_deliveries_summary: deliveriesSummary,
|
||||
// maskEmployeeForResponse drops the ciphertext and personnummer_last4
|
||||
// from every embedded employee, exposing only `personnummer_masked`.
|
||||
employees: (employees || []).map(emp => ({
|
||||
...emp,
|
||||
employee: emp.employee ? {
|
||||
...emp.employee,
|
||||
personnummer: maskPersonnummer(decryptPersonnummer(emp.employee.personnummer)),
|
||||
} : null,
|
||||
employee: emp.employee ? maskEmployeeForResponse(emp.employee) : null,
|
||||
})),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* Tests for PUT /api/settings/booking-templates/[id].
|
||||
*
|
||||
* The validated body is spread straight into .update(), so the write set must
|
||||
* be exactly the fields the caller named. UpdateBookingTemplateSchema carries
|
||||
* no .default() today, so this route was never exploitable; the tests lock in
|
||||
* the property so a future .default() on the schema cannot turn a rename into a
|
||||
* silent rewrite of the template's lines, category, or entity_type.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
|
||||
|
||||
const requireAuthMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-auth', () => ({
|
||||
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
const requireWriteMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
|
||||
}))
|
||||
|
||||
import { PUT } from '../route'
|
||||
|
||||
interface CapturedCall {
|
||||
method: string
|
||||
args: unknown[]
|
||||
}
|
||||
|
||||
/** Chainable builder recording calls; resolves queued {data,error} per from(). */
|
||||
function createCapturingSupabase(results: { data?: unknown; error?: unknown }[]) {
|
||||
const calls: CapturedCall[] = []
|
||||
let idx = 0
|
||||
const makeBuilder = () => {
|
||||
const result = results[idx++] ?? { data: null, error: null }
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const b: any = {}
|
||||
for (const m of ['select', 'eq', 'update', 'maybeSingle', 'single']) {
|
||||
b[m] = (...args: unknown[]) => {
|
||||
calls.push({ method: m, args })
|
||||
return b
|
||||
}
|
||||
}
|
||||
b.then = (resolve: (v: unknown) => void) =>
|
||||
resolve({ data: result.data ?? null, error: result.error ?? null })
|
||||
return b
|
||||
}
|
||||
return {
|
||||
supabase: {
|
||||
from: (table: string) => {
|
||||
calls.push({ method: 'from', args: [table] })
|
||||
return makeBuilder()
|
||||
},
|
||||
},
|
||||
calls,
|
||||
}
|
||||
}
|
||||
|
||||
const idParams = { params: Promise.resolve({ id: 'tpl-1' }) }
|
||||
|
||||
const validLines = [
|
||||
{ account: '5010', label: 'Hyra', side: 'debit', type: 'business' },
|
||||
{ account: '1930', label: 'Bank', side: 'credit', type: 'settlement' },
|
||||
]
|
||||
|
||||
/** The pre-update fetch result for a template owned by the active company. */
|
||||
const OWN_TEMPLATE = {
|
||||
data: { id: 'tpl-1', company_id: 'company-1', team_id: null, is_system: false },
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
requireWriteMock.mockResolvedValue({ ok: true })
|
||||
})
|
||||
|
||||
function auth(supabase: unknown) {
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
|
||||
}
|
||||
|
||||
function updatePayload(calls: CapturedCall[]): Record<string, unknown> {
|
||||
return calls.find((c) => c.method === 'update')?.args[0] as Record<string, unknown>
|
||||
}
|
||||
|
||||
describe('PUT /api/settings/booking-templates/[id]', () => {
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: null,
|
||||
supabase: {},
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
const req = createMockRequest('/api/settings/booking-templates/tpl-1', {
|
||||
method: 'PUT',
|
||||
body: { name: 'Nytt namn' },
|
||||
})
|
||||
expect((await PUT(req, idParams)).status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 403 for a viewer', async () => {
|
||||
const { supabase } = createCapturingSupabase([])
|
||||
auth(supabase)
|
||||
requireWriteMock.mockResolvedValue({
|
||||
ok: false,
|
||||
response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }),
|
||||
})
|
||||
const req = createMockRequest('/api/settings/booking-templates/tpl-1', {
|
||||
method: 'PUT',
|
||||
body: { name: 'Nytt namn' },
|
||||
})
|
||||
expect((await PUT(req, idParams)).status).toBe(403)
|
||||
})
|
||||
|
||||
it('returns 400 for an invalid body', async () => {
|
||||
const { supabase } = createCapturingSupabase([])
|
||||
auth(supabase)
|
||||
const req = createMockRequest('/api/settings/booking-templates/tpl-1', {
|
||||
method: 'PUT',
|
||||
// A template needs at least two lines (double entry).
|
||||
body: { lines: [validLines[0]] },
|
||||
})
|
||||
const { status } = await parseJsonResponse(await PUT(req, idParams))
|
||||
expect(status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 500 when the update fails', async () => {
|
||||
const { supabase } = createCapturingSupabase([OWN_TEMPLATE, { error: { message: 'boom' } }])
|
||||
auth(supabase)
|
||||
const req = createMockRequest('/api/settings/booking-templates/tpl-1', {
|
||||
method: 'PUT',
|
||||
body: { name: 'Nytt namn' },
|
||||
})
|
||||
const { status } = await parseJsonResponse(await PUT(req, idParams))
|
||||
expect(status).toBe(500)
|
||||
})
|
||||
|
||||
it('returns 404 when the template does not exist', async () => {
|
||||
// .single() used to turn zero rows into a PGRST116 ERROR, so not-found
|
||||
// surfaced as 500 and the 404 branch was dead.
|
||||
const { supabase, calls } = createCapturingSupabase([{ data: null }])
|
||||
auth(supabase)
|
||||
const req = createMockRequest('/api/settings/booking-templates/tpl-1', {
|
||||
method: 'PUT',
|
||||
body: { name: 'Nytt namn' },
|
||||
})
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(await PUT(req, idParams))
|
||||
expect(status).toBe(404)
|
||||
expect(body.error).toBe('Mallen hittades inte')
|
||||
expect(calls.find((c) => c.method === 'update')).toBeUndefined()
|
||||
})
|
||||
|
||||
it("returns 404 for another company's template without touching it", async () => {
|
||||
// RLS is membership-wide: a user who belongs to companies A and B can see
|
||||
// B's templates while acting in A. The route must scope to the ACTIVE
|
||||
// company, so B's template reads as not-found here and no update runs.
|
||||
const { supabase, calls } = createCapturingSupabase([
|
||||
{ data: { id: 'tpl-1', company_id: 'company-2', team_id: null, is_system: false } },
|
||||
])
|
||||
auth(supabase)
|
||||
const req = createMockRequest('/api/settings/booking-templates/tpl-1', {
|
||||
method: 'PUT',
|
||||
body: { name: 'Nytt namn' },
|
||||
})
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(await PUT(req, idParams))
|
||||
expect(status).toBe(404)
|
||||
expect(body.error).toBe('Mallen hittades inte')
|
||||
expect(calls.find((c) => c.method === 'update')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns 404 for a system template', async () => {
|
||||
const { supabase, calls } = createCapturingSupabase([
|
||||
{ data: { id: 'tpl-1', company_id: null, team_id: null, is_system: true } },
|
||||
])
|
||||
auth(supabase)
|
||||
const req = createMockRequest('/api/settings/booking-templates/tpl-1', {
|
||||
method: 'PUT',
|
||||
body: { name: 'Nytt namn' },
|
||||
})
|
||||
const { status } = await parseJsonResponse(await PUT(req, idParams))
|
||||
expect(status).toBe(404)
|
||||
expect(calls.find((c) => c.method === 'update')).toBeUndefined()
|
||||
})
|
||||
|
||||
it("updates a team template shared with the active company's team", async () => {
|
||||
// Team templates carry company_id NULL: a blind company_id filter would
|
||||
// have broken them. The scope check goes through the company's team_id.
|
||||
const { supabase, calls } = createCapturingSupabase([
|
||||
{ data: { id: 'tpl-1', company_id: null, team_id: 'team-1', is_system: false } },
|
||||
{ data: { team_id: 'team-1' } }, // companies lookup
|
||||
{ data: { id: 'tpl-1', name: 'Nytt namn' } }, // update
|
||||
])
|
||||
auth(supabase)
|
||||
const req = createMockRequest('/api/settings/booking-templates/tpl-1', {
|
||||
method: 'PUT',
|
||||
body: { name: 'Nytt namn' },
|
||||
})
|
||||
const { status } = await parseJsonResponse(await PUT(req, idParams))
|
||||
expect(status).toBe(200)
|
||||
expect(updatePayload(calls)).toEqual({ name: 'Nytt namn' })
|
||||
})
|
||||
|
||||
it("returns 404 for another team's template", async () => {
|
||||
const { supabase, calls } = createCapturingSupabase([
|
||||
{ data: { id: 'tpl-1', company_id: null, team_id: 'team-other', is_system: false } },
|
||||
{ data: { team_id: 'team-1' } }, // companies lookup
|
||||
])
|
||||
auth(supabase)
|
||||
const req = createMockRequest('/api/settings/booking-templates/tpl-1', {
|
||||
method: 'PUT',
|
||||
body: { name: 'Nytt namn' },
|
||||
})
|
||||
const { status } = await parseJsonResponse(await PUT(req, idParams))
|
||||
expect(status).toBe(404)
|
||||
expect(calls.find((c) => c.method === 'update')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('updates the template on the happy path', async () => {
|
||||
const { supabase } = createCapturingSupabase([
|
||||
OWN_TEMPLATE,
|
||||
{ data: { id: 'tpl-1', name: 'Nytt namn' } },
|
||||
])
|
||||
auth(supabase)
|
||||
const req = createMockRequest('/api/settings/booking-templates/tpl-1', {
|
||||
method: 'PUT',
|
||||
body: { name: 'Nytt namn' },
|
||||
})
|
||||
const { status, body } = await parseJsonResponse<{ data: { name: string } }>(
|
||||
await PUT(req, idParams),
|
||||
)
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.name).toBe('Nytt namn')
|
||||
})
|
||||
|
||||
it('writes only the field the caller named', async () => {
|
||||
const { supabase, calls } = createCapturingSupabase([OWN_TEMPLATE, { data: { id: 'tpl-1' } }])
|
||||
auth(supabase)
|
||||
const req = createMockRequest('/api/settings/booking-templates/tpl-1', {
|
||||
method: 'PUT',
|
||||
body: { name: 'Nytt namn' },
|
||||
})
|
||||
expect((await PUT(req, idParams)).status).toBe(200)
|
||||
expect(Object.keys(updatePayload(calls))).toEqual(['name'])
|
||||
})
|
||||
|
||||
it('leaves lines, category and entity_type alone on a name-only update', async () => {
|
||||
const { supabase, calls } = createCapturingSupabase([OWN_TEMPLATE, { data: { id: 'tpl-1' } }])
|
||||
auth(supabase)
|
||||
const req = createMockRequest('/api/settings/booking-templates/tpl-1', {
|
||||
method: 'PUT',
|
||||
body: { name: 'Nytt namn' },
|
||||
})
|
||||
await PUT(req, idParams)
|
||||
const payload = updatePayload(calls)
|
||||
for (const field of ['lines', 'category', 'entity_type', 'description']) {
|
||||
expect(payload, `${field} must not be written by a name-only PUT`).not.toHaveProperty(field)
|
||||
}
|
||||
})
|
||||
|
||||
it('takes a supplied lines array wholesale', async () => {
|
||||
const { supabase, calls } = createCapturingSupabase([OWN_TEMPLATE, { data: { id: 'tpl-1' } }])
|
||||
auth(supabase)
|
||||
const req = createMockRequest('/api/settings/booking-templates/tpl-1', {
|
||||
method: 'PUT',
|
||||
body: { lines: validLines },
|
||||
})
|
||||
expect((await PUT(req, idParams)).status).toBe(200)
|
||||
expect(updatePayload(calls)).toEqual({ lines: validLines })
|
||||
})
|
||||
|
||||
it('drops unknown keys instead of forwarding them to the update', async () => {
|
||||
const { supabase, calls } = createCapturingSupabase([OWN_TEMPLATE, { data: { id: 'tpl-1' } }])
|
||||
auth(supabase)
|
||||
const req = createMockRequest('/api/settings/booking-templates/tpl-1', {
|
||||
method: 'PUT',
|
||||
body: { name: 'Nytt namn', is_system: false, company_id: 'other' },
|
||||
})
|
||||
expect((await PUT(req, idParams)).status).toBe(200)
|
||||
expect(Object.keys(updatePayload(calls))).toEqual(['name'])
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { z } from 'zod'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { sparsePatchBody } from '@/lib/api/sparse-patch'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
|
||||
const BookingTemplateLineSchema = z.object({
|
||||
@@ -27,28 +28,77 @@ const UpdateBookingTemplateSchema = z.object({
|
||||
|
||||
/**
|
||||
* PUT /api/settings/booking-templates/[id]
|
||||
* Update a non-system template.
|
||||
* Update a non-system template belonging to the active company (or its team).
|
||||
*/
|
||||
export const PUT = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
'booking_template.update',
|
||||
async (request, ctx, { params }) => {
|
||||
const { id } = await params
|
||||
const { supabase } = ctx
|
||||
const { supabase, companyId } = ctx
|
||||
|
||||
const result = await validateBody(request, UpdateBookingTemplateSchema)
|
||||
// result.data is spread straight into .update(), so it must carry only the
|
||||
// fields the caller named. UpdateBookingTemplateSchema has no .default()
|
||||
// today; sparsePatchBody makes that a structural property rather than a
|
||||
// thing a future edit to the schema can quietly undo.
|
||||
const result = await validateBody(request, sparsePatchBody(UpdateBookingTemplateSchema))
|
||||
if (!result.success) return result.response
|
||||
|
||||
// RLS prevents updating system templates
|
||||
// Read the template first so a missing row is a clean 404: .single() on
|
||||
// the update chain reported zero rows as a PGRST116 ERROR, which fell into
|
||||
// the 500 branch and left the 404 below unreachable.
|
||||
const { data: existing, error: fetchError } = await supabase
|
||||
.from('booking_template_library')
|
||||
.select('id, company_id, team_id, is_system')
|
||||
.eq('id', id)
|
||||
.maybeSingle()
|
||||
|
||||
if (fetchError) {
|
||||
return NextResponse.json({ error: getUserErrorMessage(fetchError) }, { status: 500 })
|
||||
}
|
||||
// System templates are never editable (customize duplicates them instead);
|
||||
// reported as not-found rather than forbidden, matching the RLS view.
|
||||
if (!existing || existing.is_system) {
|
||||
return NextResponse.json({ error: 'Mallen hittades inte' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Defense in depth alongside RLS (which is membership-wide): only
|
||||
// templates scoped to the ACTIVE company, or shared with its team, are
|
||||
// editable in this context. Without this, a user who belongs to several
|
||||
// companies could edit company B's template while acting in company A.
|
||||
// Team templates carry company_id NULL, so a plain company_id filter on
|
||||
// the update would break them: check the applicable scope explicitly.
|
||||
if (existing.company_id) {
|
||||
if (existing.company_id !== companyId) {
|
||||
return NextResponse.json({ error: 'Mallen hittades inte' }, { status: 404 })
|
||||
}
|
||||
} else if (existing.team_id) {
|
||||
const { data: company } = await supabase
|
||||
.from('companies')
|
||||
.select('team_id')
|
||||
.eq('id', companyId)
|
||||
.maybeSingle()
|
||||
if (!company?.team_id || company.team_id !== existing.team_id) {
|
||||
return NextResponse.json({ error: 'Mallen hittades inte' }, { status: 404 })
|
||||
}
|
||||
} else {
|
||||
// Non-system template with neither company nor team scope should not
|
||||
// exist; refuse rather than let anyone edit an orphan.
|
||||
return NextResponse.json({ error: 'Mallen hittades inte' }, { status: 404 })
|
||||
}
|
||||
|
||||
// RLS prevents updating system templates; is_system is re-checked here so
|
||||
// the guard holds even on a service-role client.
|
||||
const { data, error } = await supabase
|
||||
.from('booking_template_library')
|
||||
.update(result.data)
|
||||
.eq('id', id)
|
||||
.eq('is_system', false)
|
||||
.select()
|
||||
.single()
|
||||
.maybeSingle()
|
||||
|
||||
if (error) return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
|
||||
if (!data) return NextResponse.json({ error: 'Template not found' }, { status: 404 })
|
||||
// The row can vanish between the scope check and the update.
|
||||
if (!data) return NextResponse.json({ error: 'Mallen hittades inte' }, { status: 404 })
|
||||
|
||||
return NextResponse.json({ data })
|
||||
},
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* The duplicate-payment guard's plus-minus 2 % band and the column it is
|
||||
* applied to must share a unit.
|
||||
*
|
||||
* `paymentAmount` (from `supplier_invoices.remaining_amount` or `body.amount`)
|
||||
* is denominated in the INVOICE's currency; `transactions.amount` is
|
||||
* denominated in the BANK ROW's currency. At roughly 11,50 SEK/EUR a band built
|
||||
* around a EUR figure and applied to a kronor column is off by a factor of
|
||||
* eleven: it selects nothing (a second verifikat for one affärshändelse then
|
||||
* posts unopposed, BFL 5 kap 1-2 §) or it selects an unrelated row.
|
||||
*
|
||||
* These tests assert the FILTER VALUES the route actually sends. The shared
|
||||
* `createQueuedMockSupabase` helper drops filter arguments, so an assertion on
|
||||
* the response shape alone passes against the pre-fix band as long as the
|
||||
* queued page happens to be empty. The whole finding lives in the arguments.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import {
|
||||
createMockRequest,
|
||||
parseJsonResponse,
|
||||
createMockRouteParams,
|
||||
makeSupplierInvoice,
|
||||
makeSupplier,
|
||||
} from '@/tests/helpers'
|
||||
|
||||
/** One recorded query: which table, and every filter argument it received. */
|
||||
type RecordedQuery = { table: string; calls: Record<string, unknown[][]> }
|
||||
|
||||
/**
|
||||
* Chainable Supabase stub that RECORDS each query's filter arguments and serves
|
||||
* one queued page per table, in call order. Keying the pages by table (rather
|
||||
* than by a single global queue) keeps the assertions stable when an unrelated
|
||||
* lookup is added to the route.
|
||||
*/
|
||||
function createRecordingSupabase(pages: Record<string, Array<{ data?: unknown; error?: unknown }>>) {
|
||||
const queries: RecordedQuery[] = []
|
||||
const queues: Record<string, Array<{ data: unknown; error: unknown }>> = {}
|
||||
for (const [table, list] of Object.entries(pages)) {
|
||||
queues[table] = list.map((r) => ({ data: r.data ?? null, error: r.error ?? null }))
|
||||
}
|
||||
|
||||
const from = (table: string) => {
|
||||
const result = queues[table]?.shift() ?? { data: null, error: null }
|
||||
const calls: Record<string, unknown[][]> = {}
|
||||
queries.push({ table, calls })
|
||||
const chain: unknown = new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_target, prop: string) {
|
||||
if (prop === 'then') {
|
||||
return (resolve: (v: unknown) => void) => resolve(result)
|
||||
}
|
||||
return (...args: unknown[]) => {
|
||||
;(calls[prop] ??= []).push(args)
|
||||
return chain
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
return chain
|
||||
}
|
||||
|
||||
const supabase = {
|
||||
from: vi.fn(from),
|
||||
rpc: vi.fn(() => from('__rpc')),
|
||||
auth: { getUser: vi.fn() },
|
||||
}
|
||||
|
||||
return { supabase, queries }
|
||||
}
|
||||
|
||||
const recording = createRecordingSupabase({})
|
||||
let mockSupabase = recording.supabase
|
||||
let recorded: RecordedQuery[] = recording.queries
|
||||
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: () => Promise.resolve(mockSupabase as unknown as SupabaseClient),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/init', () => ({
|
||||
ensureInitialized: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}))
|
||||
|
||||
const mockCreateSupplierInvoicePaymentEntry = vi.fn()
|
||||
const mockCreateSupplierInvoiceCashEntry = vi.fn()
|
||||
vi.mock('@/lib/bookkeeping/supplier-invoice-entries', () => ({
|
||||
createSupplierInvoicePaymentEntry: (...args: unknown[]) =>
|
||||
mockCreateSupplierInvoicePaymentEntry(...args),
|
||||
createSupplierInvoiceCashEntry: (...args: unknown[]) =>
|
||||
mockCreateSupplierInvoiceCashEntry(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/documents/document-service', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/core/documents/document-service')>(
|
||||
'@/lib/core/documents/document-service',
|
||||
)
|
||||
return { ...actual, linkToJournalEntry: vi.fn() }
|
||||
})
|
||||
|
||||
import { eventBus } from '@/lib/events'
|
||||
import { POST } from '../route'
|
||||
|
||||
/** Install a fresh recording client with the given per-table pages. */
|
||||
function useSupabase(pages: Record<string, Array<{ data?: unknown; error?: unknown }>>) {
|
||||
const next = createRecordingSupabase(pages)
|
||||
mockSupabase = next.supabase
|
||||
recorded = next.queries
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: { id: 'user-1' } } })
|
||||
return next
|
||||
}
|
||||
|
||||
const bankRow = (over: Record<string, unknown> = {}) => ({
|
||||
id: 'tx-99',
|
||||
date: '2026-05-10',
|
||||
amount: -12500,
|
||||
description: 'Betalning Leverantör AB',
|
||||
merchant_name: 'Leverantör AB',
|
||||
currency: 'SEK',
|
||||
amount_sek: null,
|
||||
exchange_rate: null,
|
||||
...over,
|
||||
})
|
||||
|
||||
const markPaid = async () => {
|
||||
const request = createMockRequest('/api/supplier-invoices/si-1/mark-paid', {
|
||||
method: 'POST',
|
||||
body: {},
|
||||
})
|
||||
return POST(request, createMockRouteParams({ id: 'si-1' }))
|
||||
}
|
||||
|
||||
const txQueries = () => recorded.filter((q) => q.table === 'transactions')
|
||||
|
||||
describe('POST /api/supplier-invoices/[id]/mark-paid: duplicate-guard band units', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
eventBus.clear()
|
||||
})
|
||||
|
||||
it('SEK invoice: one kronor-banded sweep, byte-identical to the pre-fix query', async () => {
|
||||
useSupabase({
|
||||
supplier_invoices: [
|
||||
{
|
||||
data: makeSupplierInvoice({
|
||||
id: 'si-1',
|
||||
status: 'approved',
|
||||
currency: 'SEK',
|
||||
total: 12500,
|
||||
total_sek: 12500,
|
||||
exchange_rate: null,
|
||||
remaining_amount: 12500,
|
||||
paid_amount: 0,
|
||||
supplier: makeSupplier({ name: 'Leverantör AB' }),
|
||||
items: [],
|
||||
}),
|
||||
},
|
||||
],
|
||||
transactions: [{ data: [bankRow()] }],
|
||||
})
|
||||
|
||||
const response = await markPaid()
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
expect(status).toBe(409)
|
||||
expect(body.error.code).toBe('SI_PAID_LIKELY_DUPLICATE')
|
||||
|
||||
// A SEK-only company must still see exactly one query with the old band.
|
||||
expect(txQueries()).toHaveLength(1)
|
||||
const q = txQueries()[0].calls
|
||||
expect(q.gte).toContainEqual(['amount', -12750])
|
||||
expect(q.lte).toContainEqual(['amount', -12250])
|
||||
// Band is kronor, so the rows it is applied to must be kronor. NULL is
|
||||
// kronor too: transactions.currency is nullable with DEFAULT 'SEK'.
|
||||
expect(q.or).toEqual([['currency.is.null,currency.eq.SEK']])
|
||||
// The per-row re-check reads these columns; a narrow projection would make
|
||||
// it read `undefined` and silently default every row to SEK.
|
||||
expect(q.select?.[0][0]).toContain('currency')
|
||||
expect(q.select?.[0][0]).toContain('amount_sek')
|
||||
expect(q.select?.[0][0]).toContain('exchange_rate')
|
||||
})
|
||||
|
||||
it('EUR invoice with a rate: EUR rows banded in EUR, kronor rows banded in kronor', async () => {
|
||||
useSupabase({
|
||||
supplier_invoices: [
|
||||
{
|
||||
data: makeSupplierInvoice({
|
||||
id: 'si-1',
|
||||
status: 'approved',
|
||||
currency: 'EUR',
|
||||
total: 1000,
|
||||
total_sek: 11500,
|
||||
exchange_rate: 11.5,
|
||||
remaining_amount: 1000,
|
||||
paid_amount: 0,
|
||||
supplier: makeSupplier({ name: 'Leverantör AB' }),
|
||||
items: [],
|
||||
}),
|
||||
},
|
||||
],
|
||||
// EUR sweep finds nothing; the kronor sweep finds the row that actually
|
||||
// paid the invoice, at the converted magnitude.
|
||||
transactions: [{ data: [] }, { data: [bankRow({ amount: -11500 })] }],
|
||||
})
|
||||
|
||||
const response = await markPaid()
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
error: { code: string; details: { candidates: Array<{ id: string }> } }
|
||||
}>(response)
|
||||
expect(status).toBe(409)
|
||||
expect(body.error.code).toBe('SI_PAID_LIKELY_DUPLICATE')
|
||||
expect(body.error.details.candidates.map((c) => c.id)).toEqual(['tx-99'])
|
||||
|
||||
expect(txQueries()).toHaveLength(2)
|
||||
const eur = txQueries()[0].calls
|
||||
expect(eur.or).toEqual([['currency.eq.EUR']])
|
||||
expect(eur.gte).toContainEqual(['amount', -1020])
|
||||
expect(eur.lte).toContainEqual(['amount', -980])
|
||||
|
||||
const sek = txQueries()[1].calls
|
||||
expect(sek.or).toEqual([['currency.is.null,currency.eq.SEK']])
|
||||
// 1 000 EUR x 11,50 = 11 500 kr, banded plus-minus 2 %. The pre-fix query
|
||||
// asked kronor rows for -1 020..-980 and matched nothing.
|
||||
expect(sek.gte).toContainEqual(['amount', -11730])
|
||||
expect(sek.lte).toContainEqual(['amount', -11270])
|
||||
})
|
||||
|
||||
it('EUR invoice with no stored rate: no kronor sweep is invented', async () => {
|
||||
useSupabase({
|
||||
supplier_invoices: [
|
||||
{
|
||||
data: makeSupplierInvoice({
|
||||
id: 'si-1',
|
||||
status: 'approved',
|
||||
currency: 'EUR',
|
||||
total: 1000,
|
||||
total_sek: null,
|
||||
exchange_rate: null,
|
||||
remaining_amount: 1000,
|
||||
paid_amount: 0,
|
||||
supplier: makeSupplier({ name: 'Leverantör AB' }),
|
||||
items: [],
|
||||
}),
|
||||
},
|
||||
// The status flip after the guard lets the payment through.
|
||||
{ data: [{ id: 'si-1' }] },
|
||||
],
|
||||
// The single EUR sweep returns a kronor row anyway (PostgREST `.or()`
|
||||
// composes with the other filters and this stub ignores them): the
|
||||
// per-row re-check must drop it rather than read -1000 kr as -1000 EUR.
|
||||
transactions: [{ data: [bankRow({ amount: -1000, currency: 'SEK' })] }],
|
||||
company_settings: [{ data: { accounting_method: 'accrual' } }],
|
||||
})
|
||||
mockCreateSupplierInvoicePaymentEntry.mockResolvedValue({ id: 'je-1' })
|
||||
|
||||
const response = await markPaid()
|
||||
const { status, body } = await parseJsonResponse<{ success: boolean }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
expect(txQueries()).toHaveLength(1)
|
||||
expect(txQueries()[0].calls.or).toEqual([['currency.eq.EUR']])
|
||||
})
|
||||
})
|
||||
@@ -405,6 +405,120 @@ describe('POST /api/supplier-invoices/[id]/mark-paid', () => {
|
||||
expect(mockCreateSupplierInvoicePaymentEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// ── Duplicate-guard currency: the plus-minus 2 % band and the column it is
|
||||
// applied to must share a unit. `remaining_amount` is invoice currency,
|
||||
// `transactions.amount` is the bank row's currency; at ~11,50 SEK/EUR a EUR
|
||||
// band on a kronor column is off by a factor of eleven.
|
||||
const eurInvoice = (over: Record<string, unknown> = {}) =>
|
||||
makeSupplierInvoice({
|
||||
id: 'si-1',
|
||||
status: 'approved',
|
||||
currency: 'EUR',
|
||||
total: 1000,
|
||||
total_sek: 11500,
|
||||
exchange_rate: 11.5,
|
||||
remaining_amount: 1000,
|
||||
paid_amount: 0,
|
||||
supplier: makeSupplier(),
|
||||
items: [],
|
||||
...over,
|
||||
})
|
||||
|
||||
const bankRow = (over: Record<string, unknown> = {}) => ({
|
||||
id: 'tx-99',
|
||||
date: '2026-05-10',
|
||||
amount: -1000,
|
||||
description: 'Betalning Leverantör AB',
|
||||
merchant_name: 'Leverantör AB',
|
||||
currency: 'SEK',
|
||||
amount_sek: null,
|
||||
exchange_rate: null,
|
||||
...over,
|
||||
})
|
||||
|
||||
it('EUR invoice: a 1 000 SEK bank row is not treated as the payment for 1 000 EUR', async () => {
|
||||
enqueue({ data: eurInvoice(), error: null })
|
||||
// Sweep 1 (EUR rows): nothing. Sweep 2 (kronor rows): a same-magnitude
|
||||
// kronor row, which is exactly what the old EUR band selected.
|
||||
enqueue({ data: [], error: null })
|
||||
enqueue({ data: [bankRow({ amount: -1000 })], error: null })
|
||||
enqueue({ data: { accounting_method: 'accrual' }, error: null })
|
||||
mockCreateSupplierInvoicePaymentEntry.mockResolvedValue({ id: 'je-1' })
|
||||
enqueue({ data: [{ id: 'si-1' }], error: null })
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const request = createMockRequest('/api/supplier-invoices/si-1/mark-paid', {
|
||||
method: 'POST',
|
||||
body: {},
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'si-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ success: boolean; status: string }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
expect(mockCreateSupplierInvoicePaymentEntry).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('EUR invoice with a rate: the 11 500 SEK bank row that paid it IS flagged', async () => {
|
||||
enqueue({ data: eurInvoice(), error: null })
|
||||
enqueue({ data: [], error: null })
|
||||
enqueue({ data: [bankRow({ amount: -11500 })], error: null })
|
||||
|
||||
const request = createMockRequest('/api/supplier-invoices/si-1/mark-paid', {
|
||||
method: 'POST',
|
||||
body: {},
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'si-1' }))
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
error: { code: string; details: { candidates: Array<{ id: string }> } }
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(409)
|
||||
expect(body.error.code).toBe('SI_PAID_LIKELY_DUPLICATE')
|
||||
expect(body.error.details.candidates.map((c) => c.id)).toEqual(['tx-99'])
|
||||
expect(mockCreateSupplierInvoicePaymentEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('EUR invoice with no stored rate: kronor rows are excluded, never compared raw', async () => {
|
||||
enqueue({ data: eurInvoice({ total_sek: null, exchange_rate: null }), error: null })
|
||||
// Only the EUR sweep can be planned; the kronor row it returns here cannot
|
||||
// be brought into a shared unit and must be dropped, not read as kronor.
|
||||
enqueue({ data: [bankRow({ amount: -1000 })], error: null })
|
||||
enqueue({ data: { accounting_method: 'accrual' }, error: null })
|
||||
mockCreateSupplierInvoicePaymentEntry.mockResolvedValue({ id: 'je-1' })
|
||||
enqueue({ data: [{ id: 'si-1' }], error: null })
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const request = createMockRequest('/api/supplier-invoices/si-1/mark-paid', {
|
||||
method: 'POST',
|
||||
body: {},
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'si-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ success: boolean }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
})
|
||||
|
||||
it('EUR invoice: a 1 000 EUR bank row still matches in its own currency', async () => {
|
||||
enqueue({ data: eurInvoice(), error: null })
|
||||
enqueue({
|
||||
data: [bankRow({ amount: -1000, currency: 'EUR', amount_sek: -11500 })],
|
||||
error: null,
|
||||
})
|
||||
enqueue({ data: [], error: null })
|
||||
|
||||
const request = createMockRequest('/api/supplier-invoices/si-1/mark-paid', {
|
||||
method: 'POST',
|
||||
body: {},
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'si-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(status).toBe(409)
|
||||
expect(body.error.code).toBe('SI_PAID_LIKELY_DUPLICATE')
|
||||
})
|
||||
|
||||
it('proceeds when force=true even with candidates present', async () => {
|
||||
const supplier = makeSupplier()
|
||||
const invoice = makeSupplierInvoice({
|
||||
|
||||
@@ -18,6 +18,14 @@ import {
|
||||
DUPLICATE_DATE_WINDOW_DAYS,
|
||||
escapeLikePattern,
|
||||
} from '@/lib/invoices/duplicate-payment-guard'
|
||||
import {
|
||||
invoiceAmountSek,
|
||||
magnitudesWithinTolerance,
|
||||
normalizeCurrencyCode,
|
||||
planAmountSweeps,
|
||||
type ComparableAmount,
|
||||
} from '@/lib/invoices/duplicate-guard-currency'
|
||||
import { resolveTransactionAmountSek } from '@/lib/transactions/booking-duplicate-detection'
|
||||
import type { SupplierInvoice, SupplierInvoiceItem } from '@/types'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
|
||||
@@ -86,30 +94,108 @@ export const POST = withRouteContext(
|
||||
})
|
||||
}
|
||||
if (supplierName) {
|
||||
const windowLow = Math.round(paymentAmount * (1 - DUPLICATE_AMOUNT_TOLERANCE_PCT) * 100) / 100
|
||||
const windowHigh = Math.round(paymentAmount * (1 + DUPLICATE_AMOUNT_TOLERANCE_PCT) * 100) / 100
|
||||
// Units: `paymentAmount` is denominated in the supplier invoice's
|
||||
// currency (that is what `remaining_amount` and `body.amount` are),
|
||||
// while `transactions.amount` is denominated in the bank row's own
|
||||
// currency. The plus-minus tolerance band is therefore planned per
|
||||
// currency and re-checked per row, so band and column always share a
|
||||
// unit. A SEK invoice yields exactly one sweep with the band it had
|
||||
// before, so a SEK-only company sees the identical single query.
|
||||
const paymentCurrency = normalizeCurrencyCode(invoice.currency)
|
||||
const reference: ComparableAmount = {
|
||||
amount: paymentAmount,
|
||||
currency: paymentCurrency,
|
||||
sek: invoiceAmountSek({
|
||||
amount: paymentAmount,
|
||||
currency: paymentCurrency,
|
||||
total: invoice.total,
|
||||
totalSek: invoice.total_sek,
|
||||
exchangeRate: invoice.exchange_rate,
|
||||
}),
|
||||
}
|
||||
const { sweeps, crossCurrencyUnverifiable } = planAmountSweeps(
|
||||
reference,
|
||||
DUPLICATE_AMOUNT_TOLERANCE_PCT,
|
||||
)
|
||||
if (crossCurrencyUnverifiable) {
|
||||
// A foreign invoice with no stored rate cannot be stated in kronor,
|
||||
// so kronor bank rows can only be excluded, never compared raw
|
||||
// (a raw compare reads 1 000 EUR as 1 000 kr). Same-currency rows are
|
||||
// still swept. Logged so the blind spot is visible in audit rather
|
||||
// than passing as a clean "no duplicate".
|
||||
opLog.warn('duplicate-payment guard: cross-currency candidates not evaluated', {
|
||||
reason: 'invoice_missing_sek_value',
|
||||
currency: paymentCurrency,
|
||||
supplierInvoiceId: id,
|
||||
})
|
||||
}
|
||||
|
||||
const dateMs = new Date(paymentDate).getTime()
|
||||
const dateLow = new Date(dateMs - DUPLICATE_DATE_WINDOW_DAYS * 24 * 3600 * 1000).toISOString().split('T')[0]
|
||||
const dateHigh = new Date(dateMs + DUPLICATE_DATE_WINDOW_DAYS * 24 * 3600 * 1000).toISOString().split('T')[0]
|
||||
const escapedSupplierName = escapeLikePattern(supplierName)
|
||||
|
||||
const { data: candidates } = await supabase
|
||||
.from('transactions')
|
||||
.select('id, date, amount, description, merchant_name')
|
||||
.eq('company_id', companyId!)
|
||||
.eq('is_business', true)
|
||||
.is('supplier_invoice_id', null)
|
||||
.is('invoice_id', null)
|
||||
.lt('amount', 0)
|
||||
.gte('amount', -windowHigh)
|
||||
.lte('amount', -windowLow)
|
||||
.gte('date', dateLow)
|
||||
.lte('date', dateHigh)
|
||||
.ilike('merchant_name', `%${escapedSupplierName}%`)
|
||||
.order('date', { ascending: false })
|
||||
.limit(5)
|
||||
type CandidateRow = {
|
||||
id: string
|
||||
date: string
|
||||
amount: number
|
||||
description: string | null
|
||||
merchant_name: string | null
|
||||
currency: string | null
|
||||
amount_sek: number | null
|
||||
exchange_rate: number | null
|
||||
}
|
||||
|
||||
if (candidates && candidates.length > 0) {
|
||||
const sweepResults = await Promise.all(
|
||||
sweeps.map((sweep) =>
|
||||
supabase
|
||||
.from('transactions')
|
||||
.select(
|
||||
'id, date, amount, description, merchant_name, currency, amount_sek, exchange_rate',
|
||||
)
|
||||
.eq('company_id', companyId!)
|
||||
.eq('is_business', true)
|
||||
.is('supplier_invoice_id', null)
|
||||
.is('invoice_id', null)
|
||||
.lt('amount', 0)
|
||||
.or(sweep.currencyFilter)
|
||||
.gte('amount', -sweep.high)
|
||||
.lte('amount', -sweep.low)
|
||||
.gte('date', dateLow)
|
||||
.lte('date', dateHigh)
|
||||
.ilike('merchant_name', `%${escapedSupplierName}%`)
|
||||
.order('date', { ascending: false })
|
||||
.limit(5),
|
||||
),
|
||||
)
|
||||
|
||||
const byId = new Map<string, CandidateRow>()
|
||||
for (const res of sweepResults) {
|
||||
for (const row of (res.data ?? []) as CandidateRow[]) {
|
||||
if (!byId.has(row.id)) byId.set(row.id, row)
|
||||
}
|
||||
}
|
||||
const candidates = Array.from(byId.values())
|
||||
.filter((c) =>
|
||||
magnitudesWithinTolerance(
|
||||
reference,
|
||||
{
|
||||
amount: Number(c.amount),
|
||||
currency: normalizeCurrencyCode(c.currency),
|
||||
sek: resolveTransactionAmountSek({
|
||||
amount: c.amount,
|
||||
currency: c.currency,
|
||||
amount_sek: c.amount_sek,
|
||||
exchange_rate: c.exchange_rate,
|
||||
}),
|
||||
},
|
||||
DUPLICATE_AMOUNT_TOLERANCE_PCT,
|
||||
),
|
||||
)
|
||||
.sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0))
|
||||
.slice(0, 5)
|
||||
|
||||
if (candidates.length > 0) {
|
||||
return errorResponseFromCode('SI_PAID_LIKELY_DUPLICATE', opLog, {
|
||||
requestId,
|
||||
details: {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import {
|
||||
createMockRequest,
|
||||
parseJsonResponse,
|
||||
@@ -44,6 +44,16 @@ vi.mock('@/lib/core/documents/document-service', () => ({
|
||||
linkToJournalEntry: (...args: unknown[]) => mockLinkToJournalEntry(...args),
|
||||
}))
|
||||
|
||||
// Riksbanken is the only external dependency of the new server-side rate
|
||||
// lookup. Spread the real module so anything else importing from it (e.g.
|
||||
// convertToSEK) keeps working.
|
||||
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) }
|
||||
})
|
||||
|
||||
import { eventBus } from '@/lib/events'
|
||||
|
||||
import { GET, POST } from '../route'
|
||||
@@ -904,3 +914,231 @@ describe('POST /api/supplier-invoices', () => {
|
||||
expect(mockCreateSupplierInvoiceRegistrationEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// ── Exchange rate + SEK amounts ─────────────────────────────────────────────
|
||||
// The queued Supabase mock is a bare Proxy, so the only way to assert what was
|
||||
// actually written is to record the argument handed to `.insert()`. The route
|
||||
// echoes back the enqueued fixture row, not its own payload.
|
||||
|
||||
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 /api/supplier-invoices: exchange rate + SEK amounts', () => {
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
const captured: InsertRecord[] = []
|
||||
let baseFrom: (...args: unknown[]) => unknown
|
||||
|
||||
const supplierInvoiceInsert = () =>
|
||||
captured.find((c) => c.table === 'supplier_invoices')?.payload
|
||||
|
||||
function enqueueHappyPath() {
|
||||
enqueue({ data: makeSupplier({ id: VALID_UUID }), error: null }) // supplier lookup
|
||||
enqueue({ data: 7 }) // get_next_arrival_number
|
||||
enqueue({ data: makeSupplierInvoice({ id: 'si-fx' }), error: null }) // insert invoice
|
||||
enqueue({ data: [], error: null }) // insert items
|
||||
enqueue({ data: { accounting_method: 'cash' }, error: null }) // company_settings
|
||||
}
|
||||
|
||||
function body(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
supplier_id: VALID_UUID,
|
||||
supplier_invoice_number: 'LF-FX',
|
||||
invoice_date: '2024-06-01',
|
||||
due_date: '2024-07-01',
|
||||
items: [
|
||||
{ description: 'Molntjänst', amount: 10000, account_number: '6540', vat_rate: 0.25 },
|
||||
],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
eventBus.clear()
|
||||
captured.length = 0
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
|
||||
mockFetchExchangeRate.mockReset()
|
||||
baseFrom = mockSupabase.from.getMockImplementation() as (...args: unknown[]) => unknown
|
||||
mockSupabase.from.mockImplementation((table: string) =>
|
||||
wrapCapturing(baseFrom(table), table, captured),
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mockSupabase.from.mockImplementation(baseFrom)
|
||||
})
|
||||
|
||||
it('populates total_sek for an ordinary SEK invoice and never asks for a rate', async () => {
|
||||
enqueueHappyPath()
|
||||
|
||||
const response = await POST(
|
||||
createMockRequest('/api/supplier-invoices', { method: 'POST', body: body() }),
|
||||
)
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
const payload = supplierInvoiceInsert()
|
||||
expect(payload).toBeDefined()
|
||||
// total_sek used to be NULL for every SEK invoice because the writer gated
|
||||
// it on an exchange rate existing. A SEK invoice has none by definition.
|
||||
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('uses a caller-supplied rate for a foreign invoice without fetching', async () => {
|
||||
enqueueHappyPath()
|
||||
|
||||
const response = await POST(
|
||||
createMockRequest('/api/supplier-invoices', {
|
||||
method: 'POST',
|
||||
body: body({ currency: 'EUR', exchange_rate: 11.5 }),
|
||||
}),
|
||||
)
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
const payload = supplierInvoiceInsert()
|
||||
expect(payload!.currency).toBe('EUR')
|
||||
expect(payload!.exchange_rate).toBe(11.5)
|
||||
expect(payload!.subtotal_sek).toBe(115000)
|
||||
expect(payload!.vat_amount_sek).toBe(28750)
|
||||
expect(payload!.total_sek).toBe(143750)
|
||||
expect(mockFetchExchangeRate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fetches the invoice-date rate server-side when the caller omits one', async () => {
|
||||
enqueueHappyPath()
|
||||
mockFetchExchangeRate.mockResolvedValue({ currency: 'EUR', rate: 11.2, date: '2024-05-31' })
|
||||
|
||||
const response = await POST(
|
||||
createMockRequest('/api/supplier-invoices', {
|
||||
method: 'POST',
|
||||
body: body({ currency: 'EUR' }),
|
||||
}),
|
||||
)
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
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-01')
|
||||
// The supabase client must be passed through: that is what makes the
|
||||
// shared exchange_rates cache a read-through cache instead of dead weight.
|
||||
expect(clientArg).toBe(mockSupabase)
|
||||
|
||||
const payload = supplierInvoiceInsert()
|
||||
expect(payload!.exchange_rate).toBe(11.2)
|
||||
// Observation date, not the requested date: Riksbanken publishes no rate
|
||||
// on weekends and the lookback picks the previous banking day.
|
||||
expect(payload!.exchange_rate_date).toBe('2024-05-31')
|
||||
expect(payload!.total_sek).toBe(140000)
|
||||
})
|
||||
|
||||
it('refuses the create with SI_FX_RATE_MISSING when no rate can be resolved', async () => {
|
||||
enqueue({ data: makeSupplier({ id: VALID_UUID }), error: null })
|
||||
mockFetchExchangeRate.mockResolvedValue(null)
|
||||
|
||||
const response = await POST(
|
||||
createMockRequest('/api/supplier-invoices', {
|
||||
method: 'POST',
|
||||
body: body({ currency: 'USD' }),
|
||||
}),
|
||||
)
|
||||
const { status, body: responseBody } = await parseJsonResponse<{
|
||||
error: { code: string; details?: { currency?: string; invoice_date?: string } }
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(responseBody.error.code).toBe('SI_FX_RATE_MISSING')
|
||||
expect(responseBody.error.details?.currency).toBe('USD')
|
||||
// Nothing may be persisted, no ankomstnummer burned, no verifikat posted:
|
||||
// an unconverted row would only fail again inside the booking path.
|
||||
expect(supplierInvoiceInsert()).toBeUndefined()
|
||||
expect(mockSupabase.rpc).not.toHaveBeenCalled()
|
||||
expect(mockCreateSupplierInvoiceRegistrationEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// supplier_invoices_exchange_rate_check is `> 0 AND < 100000`. The schema
|
||||
// used to have no ceiling, so 250000 sailed past validation, reached the
|
||||
// constraint and came back to the user as an unexplained 500.
|
||||
it('rejects an out-of-range exchange rate as a 400, not a constraint-violation 500', async () => {
|
||||
const response = await POST(
|
||||
createMockRequest('/api/supplier-invoices', {
|
||||
method: 'POST',
|
||||
body: body({ currency: 'EUR', exchange_rate: 250000 }),
|
||||
}),
|
||||
)
|
||||
const { status, body: responseBody } = await parseJsonResponse<{
|
||||
error: string
|
||||
type: string
|
||||
errors: Array<{ field: string; message: string }>
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(responseBody.type).toBe('validation_error')
|
||||
const issue = responseBody.errors.find((e) => e.field === 'exchange_rate')
|
||||
// Actionable, and Swedish: getErrorMessage passes a 'Valideringsfel:'
|
||||
// summary through verbatim, so this is what the user actually reads.
|
||||
expect(issue?.message).toContain('100 000')
|
||||
expect(responseBody.error).toContain('Valideringsfel')
|
||||
expect(supplierInvoiceInsert()).toBeUndefined()
|
||||
expect(mockCreateSupplierInvoiceRegistrationEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects exactly 100000: the CHECK bound is exclusive, so the mirror is too', async () => {
|
||||
const response = await POST(
|
||||
createMockRequest('/api/supplier-invoices', {
|
||||
method: 'POST',
|
||||
body: body({ currency: 'EUR', exchange_rate: 100000 }),
|
||||
}),
|
||||
)
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(supplierInvoiceInsert()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('accepts 99999.99, the largest rate the CHECK allows', async () => {
|
||||
enqueueHappyPath()
|
||||
|
||||
const response = await POST(
|
||||
createMockRequest('/api/supplier-invoices', {
|
||||
method: 'POST',
|
||||
body: body({ currency: 'EUR', exchange_rate: 99999.99 }),
|
||||
}),
|
||||
)
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(supplierInvoiceInsert()!.exchange_rate).toBe(99999.99)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,6 +13,11 @@ import { validateBody } from '@/lib/api/validate'
|
||||
import { CreateSupplierInvoiceSchema } from '@/lib/api/schemas'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponse, 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 type { SupplierInvoice, SupplierInvoiceItem } from '@/types'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
@@ -181,6 +186,26 @@ export const POST = withRouteContext(
|
||||
entityType = company.entity_type as 'aktiebolag' | 'enskild_firma'
|
||||
}
|
||||
|
||||
// Resolve the exchange rate BEFORE the arrival-number sequence is touched:
|
||||
// a foreign invoice we cannot translate must not burn an ankomstnummer.
|
||||
// Shared with the v1 REST route and the inbox convert route so all three
|
||||
// write paths apply the same currency policy (lib/currency/supplier-invoice-rate.ts).
|
||||
const fx = await resolveSupplierInvoiceExchangeRate(supabase, {
|
||||
currency: body.currency,
|
||||
invoiceDate: body.invoice_date,
|
||||
suppliedRate: body.exchange_rate,
|
||||
})
|
||||
if (!fx.ok) {
|
||||
// Storing exchange_rate = NULL here is what created the permanently
|
||||
// unconverted rows: the booking path refuses them (SI_FX_RATE_MISSING)
|
||||
// and the user is by then far away from the invoice. Refuse at creation
|
||||
// instead, where the kurs can still be typed into the form.
|
||||
return errorResponseFromCode('SI_FX_RATE_MISSING', log, {
|
||||
requestId,
|
||||
details: { currency: fx.currency, invoice_date: fx.invoiceDate },
|
||||
})
|
||||
}
|
||||
|
||||
const { data: arrivalNum, error: arrivalError } = await supabase
|
||||
.rpc('get_next_arrival_number', { p_company_id: companyId })
|
||||
|
||||
@@ -240,7 +265,9 @@ export const POST = withRouteContext(
|
||||
// the net. VAT is still tracked separately (vat_amount) for declarations
|
||||
// and books fiktiv 2614/2645 in the engine, but neither side moves cash.
|
||||
const payableVat = body.reverse_charge ? 0 : vatAmount
|
||||
const total = Math.round((subtotal + payableVat) * 100) / 100
|
||||
// roundOre, not the naive form: `total` and `total_sek` must round
|
||||
// identically or a SEK invoice ends up with total_sek one öre off `total`.
|
||||
const total = roundOre(subtotal + payableVat)
|
||||
|
||||
// Representation (BAS 6070-6079): ingående moms is only deductible up to
|
||||
// 300 SEK base/person per ML 8 kap. 1 §, and the income-tax deduction was
|
||||
@@ -263,12 +290,17 @@ export const POST = withRouteContext(
|
||||
}
|
||||
}
|
||||
|
||||
const exchangeRate = body.exchange_rate || null
|
||||
const subtotalSek = exchangeRate ? Math.round(subtotal * exchangeRate * 100) / 100 : null
|
||||
const vatAmountSek = exchangeRate ? Math.round(vatAmount * exchangeRate * 100) / 100 : null
|
||||
const totalSek = exchangeRate ? Math.round(total * exchangeRate * 100) / 100 : null
|
||||
// SEK invoices resolve to rate 1, so total_sek === total. The old
|
||||
// `exchangeRate ? … : null` guard left every ordinary Swedish supplier
|
||||
// invoice with total_sek = NULL, which is why SEK-reporting readers saw
|
||||
// nothing.
|
||||
const {
|
||||
subtotal_sek: subtotalSek,
|
||||
vat_amount_sek: vatAmountSek,
|
||||
total_sek: totalSek,
|
||||
} = supplierInvoiceSekAmounts(fx.rate, { subtotal, vatAmount, total })
|
||||
|
||||
const totalRounded = Math.round(total * 100) / 100
|
||||
const totalRounded = roundOre(total)
|
||||
const { data: invoice, error: invoiceError } = await supabase
|
||||
.from('supplier_invoices')
|
||||
.insert({
|
||||
@@ -282,15 +314,18 @@ export const POST = withRouteContext(
|
||||
due_date: body.due_date,
|
||||
delivery_date: body.delivery_date || null,
|
||||
status: paidPrivately ? 'paid' : '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,
|
||||
paid_with_private_funds: paidPrivately,
|
||||
subtotal: Math.round(subtotal * 100) / 100,
|
||||
subtotal: roundOre(subtotal),
|
||||
subtotal_sek: subtotalSek,
|
||||
vat_amount: Math.round(vatAmount * 100) / 100,
|
||||
vat_amount: roundOre(vatAmount),
|
||||
vat_amount_sek: vatAmountSek,
|
||||
total: totalRounded,
|
||||
total_sek: totalSek,
|
||||
|
||||
@@ -126,7 +126,9 @@ describe('POST /api/transactions/[id]/book', () => {
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error).toBe('Validation failed')
|
||||
// Inverted from `toBe('Validation failed')`: the constant was the bug.
|
||||
expect(body.error).toMatch(/^Valideringsfel: /)
|
||||
expect(body.error).toContain('entry_date')
|
||||
})
|
||||
|
||||
it('returns 404 when transaction not found', async () => {
|
||||
|
||||
@@ -54,6 +54,12 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
id,
|
||||
date: transaction.date,
|
||||
amount: transaction.amount,
|
||||
// The FX trio must ride along: `amount` is in `currency`, the ledger
|
||||
// legs it is compared against are always SEK. Selected above via
|
||||
// select('*').
|
||||
currency: transaction.currency ?? null,
|
||||
amount_sek: transaction.amount_sek ?? null,
|
||||
exchange_rate: transaction.exchange_rate ?? null,
|
||||
cash_account_id: transaction.cash_account_id ?? null,
|
||||
})
|
||||
if (!force) {
|
||||
@@ -104,8 +110,18 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
transaction_id: id,
|
||||
dismissed_transaction_id: candidate.transaction_id,
|
||||
dismissed_journal_entry_id: candidate.journal_entry_id,
|
||||
amount_ore: Math.round(candidate.amount * 100),
|
||||
// Null when the candidate's SEK value could not be established
|
||||
// (a rateless foreign sibling); the foreign figures below then
|
||||
// carry the durable record instead of a fabricated kr amount.
|
||||
amount_ore: candidate.amount != null ? Math.round(candidate.amount * 100) : null,
|
||||
dismissed_currency: candidate.currency,
|
||||
dismissed_amount_in_currency: candidate.amount_in_currency,
|
||||
entry_date: candidate.entry_date,
|
||||
// Whether the user dismissed a confirmed same-amount twin or a
|
||||
// candidate whose amounts could never be compared (BFNAR 2013:2
|
||||
// kap 8: the behandlingshistorik has to say which).
|
||||
amount_verified: candidate.amount_verified,
|
||||
unverified_reason: candidate.unverified_reason,
|
||||
},
|
||||
actor: { type: 'user', id: user.id },
|
||||
occurredAt: new Date(),
|
||||
|
||||
@@ -43,7 +43,11 @@ vi.mock('@/lib/bookkeeping/transaction-entries', () => ({
|
||||
// tests exercise categorization, not the guard. The detection query is
|
||||
// unit-tested in lib/transactions/__tests__/booking-duplicate-detection.test.ts.
|
||||
const mockDetectDup = vi.fn()
|
||||
vi.mock('@/lib/transactions/booking-duplicate-detection', () => ({
|
||||
// Spread the real module so pure helpers the route also imports from here
|
||||
// (resolveTransactionAmountSek) keep their real behaviour; only the DB-backed
|
||||
// detector is stubbed. A bare factory would leave those exports undefined.
|
||||
vi.mock('@/lib/transactions/booking-duplicate-detection', async (importActual) => ({
|
||||
...(await importActual<typeof import('@/lib/transactions/booking-duplicate-detection')>()),
|
||||
detectBookingDuplicate: (...args: unknown[]) => mockDetectDup(...args),
|
||||
}))
|
||||
|
||||
@@ -76,6 +80,15 @@ vi.mock('@/lib/bookkeeping/counterparty-templates', () => ({
|
||||
upsertCounterpartyTemplate: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
// CAS-race compensation is centralized in lib/bookkeeping/cancel-orphaned-entry.
|
||||
// The route must delegate to it rather than hand-rolling the cancel + the
|
||||
// voucher_gap_explanations insert (BFNAR 2013:2). The exact insert payload is
|
||||
// asserted in that helper's own test.
|
||||
const mockCancelOrphanedPaymentEntry = vi.fn()
|
||||
vi.mock('@/lib/bookkeeping/cancel-orphaned-entry', () => ({
|
||||
cancelOrphanedPaymentEntry: (...args: unknown[]) => mockCancelOrphanedPaymentEntry(...args),
|
||||
}))
|
||||
|
||||
const mockFindMissingActiveAccounts = vi.fn()
|
||||
vi.mock('@/lib/bookkeeping/account-validation', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/bookkeeping/account-validation')>(
|
||||
@@ -115,6 +128,46 @@ describe('POST /api/transactions/[id]/categorize', () => {
|
||||
// Default: no booking-time duplicate. The dedicated guard test overrides this.
|
||||
mockDetectDup.mockResolvedValue(null)
|
||||
mockAppendProcessingHistory.mockResolvedValue('evt-1')
|
||||
mockCancelOrphanedPaymentEntry.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('delegates the CAS-race orphan to cancelOrphanedPaymentEntry (documented voucher gap)', async () => {
|
||||
const tx = makeTransaction({
|
||||
id: 'tx-1',
|
||||
amount: -500,
|
||||
merchant_name: 'GitHub',
|
||||
journal_entry_id: null,
|
||||
})
|
||||
|
||||
enqueue({ data: tx, error: null }) // fetch transaction
|
||||
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null }) // settings
|
||||
enqueue({ data: [{ id: 'period-1' }], error: null }) // ensureFiscalPeriod
|
||||
|
||||
mockCreateTransactionJournalEntry.mockResolvedValue({ id: 'je-1' })
|
||||
mockSaveUserMappingRule.mockResolvedValue(undefined)
|
||||
|
||||
// Lost the CAS: another request stamped journal_entry_id first.
|
||||
enqueue({ data: [], error: null })
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/categorize', {
|
||||
method: 'POST',
|
||||
body: { is_business: true, category: 'expense_software' },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: unknown }>(response)
|
||||
|
||||
expect(status).toBe(409)
|
||||
expect((body.error as { code: string }).code).toBe('TX_CATEGORIZE_RACE')
|
||||
|
||||
// No hand-rolled insert: the helper owns the real column set.
|
||||
expect(mockCancelOrphanedPaymentEntry).toHaveBeenCalledTimes(1)
|
||||
expect(mockCancelOrphanedPaymentEntry).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'company-1',
|
||||
'user-1',
|
||||
'je-1',
|
||||
'Automatiskt makulerad: dubblettbokning förhindrad av samtidighetsskydd',
|
||||
)
|
||||
})
|
||||
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
@@ -547,6 +600,276 @@ describe('POST /api/transactions/[id]/categorize', () => {
|
||||
expect(body.journal_entry_created).toBe(true)
|
||||
})
|
||||
|
||||
// ── Suggestion-guard currency. `transactions.amount` is denominated in
|
||||
// `transactions.currency`; `remaining_amount` is denominated in the invoice's
|
||||
// currency. A plus-minus 2 % band around a EUR bank row applied to a kronor
|
||||
// `remaining_amount` column is off by the whole exchange rate.
|
||||
const eurExpenseTx = (over: Record<string, unknown> = {}) =>
|
||||
makeTransaction({
|
||||
id: 'tx-1',
|
||||
amount: -1000,
|
||||
currency: 'EUR',
|
||||
amount_sek: null,
|
||||
exchange_rate: 11.5,
|
||||
merchant_name: 'Leverantör AB',
|
||||
journal_entry_id: null,
|
||||
...over,
|
||||
})
|
||||
|
||||
const sekSupplierInvoice = (remaining: number) => ({
|
||||
id: 'si-1',
|
||||
supplier_invoice_number: 'INV-2026-0042',
|
||||
invoice_date: '2026-05-01',
|
||||
remaining_amount: remaining,
|
||||
total: remaining,
|
||||
currency: 'SEK',
|
||||
total_sek: remaining,
|
||||
exchange_rate: null,
|
||||
supplier: { name: 'Leverantör AB' },
|
||||
})
|
||||
|
||||
it('EUR transaction: a 1 000 SEK supplier invoice is not suggested for a 1 000 EUR payment', async () => {
|
||||
enqueue({ data: eurExpenseTx(), error: null })
|
||||
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null })
|
||||
|
||||
mockBuildMappingResultFromCategory.mockReturnValue({
|
||||
...defaultMappingResult,
|
||||
debit_account: '2440',
|
||||
})
|
||||
|
||||
enqueue({ data: [{ id: 'sup-1' }], error: null })
|
||||
// First sweep returns the same-magnitude kronor invoice the old EUR band
|
||||
// selected; the shared-unit re-check must drop it.
|
||||
enqueue({ data: [sekSupplierInvoice(1000)], error: null })
|
||||
enqueue({ data: [], error: null })
|
||||
// ensureFiscalPeriod + transaction update
|
||||
enqueue({ data: [{ id: 'period-1' }], error: null })
|
||||
mockCreateTransactionJournalEntry.mockResolvedValue({ id: 'je-1' })
|
||||
enqueue({ data: [{ id: 'tx-1' }], error: null })
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/categorize', {
|
||||
method: 'POST',
|
||||
body: { is_business: true, category: 'expense_software' },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
success: boolean
|
||||
journal_entry_created: boolean
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
expect(body.journal_entry_created).toBe(true)
|
||||
})
|
||||
|
||||
it('EUR transaction with a rate: the 11 500 SEK supplier invoice IS suggested', async () => {
|
||||
enqueue({ data: eurExpenseTx(), error: null })
|
||||
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null })
|
||||
|
||||
mockBuildMappingResultFromCategory.mockReturnValue({
|
||||
...defaultMappingResult,
|
||||
debit_account: '2440',
|
||||
})
|
||||
|
||||
enqueue({ data: [{ id: 'sup-1' }], error: null })
|
||||
// EUR sweep finds nothing; the kronor sweep finds the invoice at the
|
||||
// converted magnitude.
|
||||
enqueue({ data: [], error: null })
|
||||
enqueue({ data: [sekSupplierInvoice(11500)], error: null })
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/categorize', {
|
||||
method: 'POST',
|
||||
body: { is_business: true, category: 'expense_software' },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
error: { code: string; details: { candidates: Array<{ supplier_invoice_id: string }> } }
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(409)
|
||||
expect(body.error.code).toBe('TX_CATEGORIZE_SUGGEST_SI_MATCH')
|
||||
expect(body.error.details.candidates.map((c) => c.supplier_invoice_id)).toEqual(['si-1'])
|
||||
expect(mockCreateTransactionJournalEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('EUR transaction without a rate: kronor invoices are excluded, never compared raw', async () => {
|
||||
enqueue({ data: eurExpenseTx({ exchange_rate: null }), error: null })
|
||||
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null })
|
||||
|
||||
mockBuildMappingResultFromCategory.mockReturnValue({
|
||||
...defaultMappingResult,
|
||||
debit_account: '2440',
|
||||
})
|
||||
|
||||
enqueue({ data: [{ id: 'sup-1' }], error: null })
|
||||
// Only the EUR sweep is planned; the kronor invoice it returns here has no
|
||||
// shared unit with the bank row and must be dropped.
|
||||
enqueue({ data: [sekSupplierInvoice(1000)], error: null })
|
||||
enqueue({ data: [{ id: 'period-1' }], error: null })
|
||||
mockCreateTransactionJournalEntry.mockResolvedValue({ id: 'je-1' })
|
||||
enqueue({ data: [{ id: 'tx-1' }], error: null })
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/categorize', {
|
||||
method: 'POST',
|
||||
body: { is_business: true, category: 'expense_software' },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ success: boolean }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
})
|
||||
|
||||
it('EUR transaction: a 1 000 EUR supplier invoice still matches in its own currency', async () => {
|
||||
enqueue({ data: eurExpenseTx(), error: null })
|
||||
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null })
|
||||
|
||||
mockBuildMappingResultFromCategory.mockReturnValue({
|
||||
...defaultMappingResult,
|
||||
debit_account: '2440',
|
||||
})
|
||||
|
||||
enqueue({ data: [{ id: 'sup-1' }], error: null })
|
||||
enqueue({
|
||||
data: [
|
||||
{
|
||||
...sekSupplierInvoice(1000),
|
||||
currency: 'EUR',
|
||||
total_sek: 11500,
|
||||
exchange_rate: 11.5,
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
enqueue({ data: [], error: null })
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/categorize', {
|
||||
method: 'POST',
|
||||
body: { is_business: true, category: 'expense_software' },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(status).toBe(409)
|
||||
expect(body.error.code).toBe('TX_CATEGORIZE_SUGGEST_SI_MATCH')
|
||||
})
|
||||
|
||||
it('EUR inbound transaction: a 1 000 SEK customer invoice is not suggested', async () => {
|
||||
const tx = makeTransaction({
|
||||
id: 'tx-1',
|
||||
amount: 1000,
|
||||
currency: 'EUR',
|
||||
amount_sek: null,
|
||||
exchange_rate: 11.5,
|
||||
description: 'Inbetalning Acme AB',
|
||||
merchant_name: 'Acme AB',
|
||||
journal_entry_id: null,
|
||||
})
|
||||
|
||||
enqueue({ data: tx, error: null })
|
||||
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null })
|
||||
|
||||
mockBuildMappingResultFromCategory.mockReturnValue({
|
||||
...defaultMappingResult,
|
||||
debit_account: '1930',
|
||||
credit_account: '1510',
|
||||
})
|
||||
|
||||
// Customer lookups (merchant_name, description)
|
||||
enqueue({ data: [{ id: 'cust-1' }], error: null })
|
||||
enqueue({ data: [{ id: 'cust-1' }], error: null })
|
||||
// EUR sweep returns the same-magnitude kronor invoice; kronor sweep empty.
|
||||
enqueue({
|
||||
data: [
|
||||
{
|
||||
id: 'inv-1',
|
||||
invoice_number: '2026-0042',
|
||||
invoice_date: '2026-05-01',
|
||||
due_date: '2026-05-31',
|
||||
remaining_amount: 1000,
|
||||
total: 1000,
|
||||
currency: 'SEK',
|
||||
total_sek: 1000,
|
||||
exchange_rate: null,
|
||||
customer: { name: 'Acme AB' },
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
enqueue({ data: [], error: null })
|
||||
// ensureFiscalPeriod + transaction update
|
||||
enqueue({ data: [{ id: 'period-1' }], error: null })
|
||||
mockCreateTransactionJournalEntry.mockResolvedValue({ id: 'je-1' })
|
||||
enqueue({ data: [{ id: 'tx-1' }], error: null })
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/categorize', {
|
||||
method: 'POST',
|
||||
body: { is_business: true, category: 'income_services' },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ success: boolean }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
})
|
||||
|
||||
it('EUR inbound transaction with a rate: the 11 500 SEK customer invoice IS suggested', async () => {
|
||||
const tx = makeTransaction({
|
||||
id: 'tx-1',
|
||||
amount: 1000,
|
||||
currency: 'EUR',
|
||||
amount_sek: 11500,
|
||||
exchange_rate: null,
|
||||
description: 'Inbetalning Acme AB',
|
||||
merchant_name: 'Acme AB',
|
||||
journal_entry_id: null,
|
||||
})
|
||||
|
||||
enqueue({ data: tx, error: null })
|
||||
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null })
|
||||
|
||||
mockBuildMappingResultFromCategory.mockReturnValue({
|
||||
...defaultMappingResult,
|
||||
debit_account: '1930',
|
||||
credit_account: '1510',
|
||||
})
|
||||
|
||||
enqueue({ data: [{ id: 'cust-1' }], error: null })
|
||||
enqueue({ data: [{ id: 'cust-1' }], error: null })
|
||||
// EUR sweep empty; kronor sweep finds the invoice at the converted amount.
|
||||
enqueue({ data: [], error: null })
|
||||
enqueue({
|
||||
data: [
|
||||
{
|
||||
id: 'inv-1',
|
||||
invoice_number: '2026-0042',
|
||||
invoice_date: '2026-05-01',
|
||||
due_date: '2026-05-31',
|
||||
remaining_amount: 11500,
|
||||
total: 11500,
|
||||
currency: 'SEK',
|
||||
total_sek: 11500,
|
||||
exchange_rate: null,
|
||||
customer: { name: 'Acme AB' },
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/categorize', {
|
||||
method: 'POST',
|
||||
body: { is_business: true, category: 'income_services' },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
error: { code: string; details: { candidates: Array<{ invoice_id: string }> } }
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(409)
|
||||
expect(body.error.code).toBe('TX_CATEGORIZE_SUGGEST_CI_MATCH')
|
||||
expect(body.error.details.candidates.map((c) => c.invoice_id)).toEqual(['inv-1'])
|
||||
})
|
||||
|
||||
it('returns 409 TX_CATEGORIZE_SUGGEST_CI_MATCH when 1930/1510 mapping matches an open customer invoice', async () => {
|
||||
const tx = makeTransaction({
|
||||
id: 'tx-1',
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
/**
|
||||
* The invoice-suggestion guards' plus-minus 2 % band and the column it is
|
||||
* applied to must share a unit.
|
||||
*
|
||||
* `transactions.amount` is denominated in `transactions.currency`, while
|
||||
* `supplier_invoices.remaining_amount` / `invoices.remaining_amount` are
|
||||
* denominated in the INVOICE's currency. At roughly 11,50 SEK/EUR a band built
|
||||
* around a EUR bank row and applied to a kronor `remaining_amount` column is
|
||||
* off by a factor of eleven: it matches nothing (the user books straight to
|
||||
* 244x/151x and is later lured into a duplicate payment) or it points at an
|
||||
* unrelated invoice.
|
||||
*
|
||||
* These tests assert the FILTER VALUES the route actually sends. The shared
|
||||
* `createQueuedMockSupabase` helper drops filter arguments, so a response-shape
|
||||
* assertion alone passes against the pre-fix band whenever the queued page is
|
||||
* empty. The finding lives entirely in the arguments.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { createMockRequest, parseJsonResponse, createMockRouteParams, makeTransaction } from '@/tests/helpers'
|
||||
|
||||
/** One recorded query: which table, and every filter argument it received. */
|
||||
type RecordedQuery = { table: string; calls: Record<string, unknown[][]> }
|
||||
|
||||
/**
|
||||
* Chainable Supabase stub that RECORDS each query's filter arguments and serves
|
||||
* one queued page per table, in call order. Keyed by table so an unrelated
|
||||
* lookup elsewhere in the route does not shift the assertions.
|
||||
*/
|
||||
function createRecordingSupabase(pages: Record<string, Array<{ data?: unknown; error?: unknown }>>) {
|
||||
const queries: RecordedQuery[] = []
|
||||
const queues: Record<string, Array<{ data: unknown; error: unknown }>> = {}
|
||||
for (const [table, list] of Object.entries(pages)) {
|
||||
queues[table] = list.map((r) => ({ data: r.data ?? null, error: r.error ?? null }))
|
||||
}
|
||||
|
||||
const from = (table: string) => {
|
||||
const result = queues[table]?.shift() ?? { data: null, error: null }
|
||||
const calls: Record<string, unknown[][]> = {}
|
||||
queries.push({ table, calls })
|
||||
const chain: unknown = new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_target, prop: string) {
|
||||
if (prop === 'then') {
|
||||
return (resolve: (v: unknown) => void) => resolve(result)
|
||||
}
|
||||
return (...args: unknown[]) => {
|
||||
;(calls[prop] ??= []).push(args)
|
||||
return chain
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
return chain
|
||||
}
|
||||
|
||||
const supabase = {
|
||||
from: vi.fn(from),
|
||||
rpc: vi.fn(() => from('__rpc')),
|
||||
auth: { getUser: vi.fn() },
|
||||
}
|
||||
|
||||
return { supabase, queries }
|
||||
}
|
||||
|
||||
const initial = createRecordingSupabase({})
|
||||
let mockSupabase = initial.supabase
|
||||
let recorded: RecordedQuery[] = initial.queries
|
||||
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: () => Promise.resolve(mockSupabase as unknown as SupabaseClient),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}))
|
||||
|
||||
const mockBuildMappingResultFromCategory = vi.fn()
|
||||
vi.mock('@/lib/bookkeeping/category-mapping', () => ({
|
||||
buildMappingResultFromCategory: (...args: unknown[]) => mockBuildMappingResultFromCategory(...args),
|
||||
}))
|
||||
|
||||
const mockCreateTransactionJournalEntry = vi.fn()
|
||||
vi.mock('@/lib/bookkeeping/transaction-entries', () => ({
|
||||
createTransactionJournalEntry: (...args: unknown[]) => mockCreateTransactionJournalEntry(...args),
|
||||
}))
|
||||
|
||||
// Only the DB-backed detector is stubbed; the pure helpers the route imports
|
||||
// from this module (resolveTransactionAmountSek) keep their real behaviour.
|
||||
const mockDetectDup = vi.fn()
|
||||
vi.mock('@/lib/transactions/booking-duplicate-detection', async (importActual) => ({
|
||||
...(await importActual<typeof import('@/lib/transactions/booking-duplicate-detection')>()),
|
||||
detectBookingDuplicate: (...args: unknown[]) => mockDetectDup(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/processing-history/append', () => ({
|
||||
appendProcessingHistory: vi.fn().mockResolvedValue('evt-1'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/bookkeeping/mapping-engine', () => ({
|
||||
saveUserMappingRule: vi.fn().mockResolvedValue(undefined),
|
||||
applySettlementAccount: (result: unknown) => result,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/bookkeeping/counterparty-templates', () => ({
|
||||
upsertCounterpartyTemplate: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/bookkeeping/cancel-orphaned-entry', () => ({
|
||||
cancelOrphanedPaymentEntry: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/bookkeeping/account-validation', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/bookkeeping/account-validation')>(
|
||||
'@/lib/bookkeeping/account-validation',
|
||||
)
|
||||
return { ...actual, findUnresolvableAccounts: vi.fn().mockResolvedValue([]) }
|
||||
})
|
||||
|
||||
import { eventBus } from '@/lib/events'
|
||||
import { POST } from '../route'
|
||||
|
||||
function useSupabase(pages: Record<string, Array<{ data?: unknown; error?: unknown }>>) {
|
||||
const next = createRecordingSupabase(pages)
|
||||
mockSupabase = next.supabase
|
||||
recorded = next.queries
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: { id: 'user-1' } } })
|
||||
}
|
||||
|
||||
const settingsPage = { data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 } }
|
||||
|
||||
const baseMapping = {
|
||||
rule: null,
|
||||
debit_account: '6200',
|
||||
credit_account: '1930',
|
||||
risk_level: 'NONE',
|
||||
confidence: 1,
|
||||
requires_review: false,
|
||||
default_private: false,
|
||||
vat_lines: [],
|
||||
description: 'Test',
|
||||
}
|
||||
|
||||
const supplierInvoiceRow = (over: Record<string, unknown> = {}) => ({
|
||||
id: 'si-1',
|
||||
supplier_invoice_number: 'INV-2026-0042',
|
||||
invoice_date: '2026-05-01',
|
||||
remaining_amount: 11500,
|
||||
total: 11500,
|
||||
currency: 'SEK',
|
||||
total_sek: 11500,
|
||||
exchange_rate: null,
|
||||
supplier: { name: 'Leverantör AB' },
|
||||
...over,
|
||||
})
|
||||
|
||||
const customerInvoiceRow = (over: Record<string, unknown> = {}) => ({
|
||||
id: 'inv-1',
|
||||
invoice_number: '2026-0042',
|
||||
invoice_date: '2026-05-01',
|
||||
due_date: '2026-05-31',
|
||||
remaining_amount: 11500,
|
||||
total: 11500,
|
||||
currency: 'SEK',
|
||||
total_sek: 11500,
|
||||
exchange_rate: null,
|
||||
customer: { name: 'Acme AB' },
|
||||
...over,
|
||||
})
|
||||
|
||||
const categorize = async (category: string) => {
|
||||
const request = createMockRequest('/api/transactions/tx-1/categorize', {
|
||||
method: 'POST',
|
||||
body: { is_business: true, category },
|
||||
})
|
||||
return POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
}
|
||||
|
||||
const queriesOn = (table: string) => recorded.filter((q) => q.table === table)
|
||||
|
||||
describe('POST /api/transactions/[id]/categorize: suggestion band units', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
eventBus.clear()
|
||||
mockDetectDup.mockResolvedValue(null)
|
||||
mockBuildMappingResultFromCategory.mockReturnValue(baseMapping)
|
||||
})
|
||||
|
||||
it('SEK payment to 2440: one kronor-banded sweep, byte-identical to the pre-fix query', async () => {
|
||||
useSupabase({
|
||||
transactions: [
|
||||
{
|
||||
data: makeTransaction({
|
||||
id: 'tx-1',
|
||||
amount: -1000,
|
||||
currency: 'SEK',
|
||||
amount_sek: null,
|
||||
exchange_rate: null,
|
||||
merchant_name: 'Leverantör AB',
|
||||
reference: null,
|
||||
cash_account_id: null,
|
||||
journal_entry_id: null,
|
||||
}),
|
||||
},
|
||||
],
|
||||
company_settings: [settingsPage],
|
||||
suppliers: [{ data: [{ id: 'sup-1' }] }],
|
||||
supplier_invoices: [{ data: [supplierInvoiceRow({ remaining_amount: 1000, total: 1000, total_sek: 1000 })] }],
|
||||
})
|
||||
mockBuildMappingResultFromCategory.mockReturnValue({ ...baseMapping, debit_account: '2440' })
|
||||
|
||||
const response = await categorize('expense_software')
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
expect(status).toBe(409)
|
||||
expect(body.error.code).toBe('TX_CATEGORIZE_SUGGEST_SI_MATCH')
|
||||
|
||||
expect(queriesOn('supplier_invoices')).toHaveLength(1)
|
||||
const q = queriesOn('supplier_invoices')[0].calls
|
||||
expect(q.gte).toContainEqual(['remaining_amount', 980])
|
||||
expect(q.lte).toContainEqual(['remaining_amount', 1020])
|
||||
expect(q.or).toEqual([['currency.is.null,currency.eq.SEK']])
|
||||
})
|
||||
|
||||
it('EUR payment to 2440: EUR invoices banded in EUR, kronor invoices banded in kronor', async () => {
|
||||
useSupabase({
|
||||
transactions: [
|
||||
{
|
||||
data: makeTransaction({
|
||||
id: 'tx-1',
|
||||
amount: -1000,
|
||||
currency: 'EUR',
|
||||
amount_sek: null,
|
||||
exchange_rate: 11.5,
|
||||
merchant_name: 'Leverantör AB',
|
||||
reference: null,
|
||||
cash_account_id: null,
|
||||
journal_entry_id: null,
|
||||
}),
|
||||
},
|
||||
],
|
||||
company_settings: [settingsPage],
|
||||
suppliers: [{ data: [{ id: 'sup-1' }] }],
|
||||
// EUR sweep empty; the kronor sweep finds the 11 500 kr invoice this
|
||||
// payment actually settles.
|
||||
supplier_invoices: [{ data: [] }, { data: [supplierInvoiceRow()] }],
|
||||
})
|
||||
mockBuildMappingResultFromCategory.mockReturnValue({ ...baseMapping, debit_account: '2440' })
|
||||
|
||||
const response = await categorize('expense_software')
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
error: { code: string; details: { candidates: Array<{ supplier_invoice_id: string }> } }
|
||||
}>(response)
|
||||
expect(status).toBe(409)
|
||||
expect(body.error.code).toBe('TX_CATEGORIZE_SUGGEST_SI_MATCH')
|
||||
expect(body.error.details.candidates.map((c) => c.supplier_invoice_id)).toEqual(['si-1'])
|
||||
|
||||
expect(queriesOn('supplier_invoices')).toHaveLength(2)
|
||||
const eur = queriesOn('supplier_invoices')[0].calls
|
||||
expect(eur.or).toEqual([['currency.eq.EUR']])
|
||||
expect(eur.gte).toContainEqual(['remaining_amount', 980])
|
||||
expect(eur.lte).toContainEqual(['remaining_amount', 1020])
|
||||
|
||||
const sek = queriesOn('supplier_invoices')[1].calls
|
||||
expect(sek.or).toEqual([['currency.is.null,currency.eq.SEK']])
|
||||
// 1 000 EUR x 11,50 = 11 500 kr. The pre-fix query asked kronor invoices
|
||||
// for 980..1020 and found nothing.
|
||||
expect(sek.gte).toContainEqual(['remaining_amount', 11270])
|
||||
expect(sek.lte).toContainEqual(['remaining_amount', 11730])
|
||||
// The per-row re-check pro-rates total_sek; a narrow projection would make
|
||||
// it read `undefined` and silently treat every invoice as kronor.
|
||||
expect(sek.select?.[0][0]).toContain('total_sek')
|
||||
expect(sek.select?.[0][0]).toContain('exchange_rate')
|
||||
expect(sek.select?.[0][0]).toContain('currency')
|
||||
})
|
||||
|
||||
it('EUR receipt to 1510: customer invoices are banded per currency too', async () => {
|
||||
useSupabase({
|
||||
transactions: [
|
||||
{
|
||||
data: makeTransaction({
|
||||
id: 'tx-1',
|
||||
amount: 1000,
|
||||
currency: 'EUR',
|
||||
amount_sek: 11500,
|
||||
exchange_rate: null,
|
||||
merchant_name: 'Acme AB',
|
||||
description: 'Inbetalning Acme AB',
|
||||
reference: null,
|
||||
cash_account_id: null,
|
||||
journal_entry_id: null,
|
||||
}),
|
||||
},
|
||||
],
|
||||
company_settings: [settingsPage],
|
||||
customers: [{ data: [{ id: 'cust-1' }] }, { data: [{ id: 'cust-1' }] }],
|
||||
invoices: [{ data: [] }, { data: [customerInvoiceRow()] }],
|
||||
})
|
||||
mockBuildMappingResultFromCategory.mockReturnValue({
|
||||
...baseMapping,
|
||||
debit_account: '1930',
|
||||
credit_account: '1510',
|
||||
})
|
||||
|
||||
const response = await categorize('income_services')
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
error: { code: string; details: { candidates: Array<{ invoice_id: string }> } }
|
||||
}>(response)
|
||||
expect(status).toBe(409)
|
||||
expect(body.error.code).toBe('TX_CATEGORIZE_SUGGEST_CI_MATCH')
|
||||
expect(body.error.details.candidates.map((c) => c.invoice_id)).toEqual(['inv-1'])
|
||||
|
||||
expect(queriesOn('invoices')).toHaveLength(2)
|
||||
const eur = queriesOn('invoices')[0].calls
|
||||
expect(eur.or).toEqual([['currency.eq.EUR']])
|
||||
expect(eur.gte).toContainEqual(['remaining_amount', 980])
|
||||
expect(eur.lte).toContainEqual(['remaining_amount', 1020])
|
||||
|
||||
const sek = queriesOn('invoices')[1].calls
|
||||
expect(sek.or).toEqual([['currency.is.null,currency.eq.SEK']])
|
||||
expect(sek.gte).toContainEqual(['remaining_amount', 11270])
|
||||
expect(sek.lte).toContainEqual(['remaining_amount', 11730])
|
||||
})
|
||||
|
||||
it('EUR payment with no stored rate: only the EUR sweep runs, kronor invoices are excluded', async () => {
|
||||
useSupabase({
|
||||
transactions: [
|
||||
{
|
||||
data: makeTransaction({
|
||||
id: 'tx-1',
|
||||
amount: -1000,
|
||||
currency: 'EUR',
|
||||
amount_sek: null,
|
||||
exchange_rate: null,
|
||||
merchant_name: 'Leverantör AB',
|
||||
reference: null,
|
||||
cash_account_id: null,
|
||||
journal_entry_id: null,
|
||||
}),
|
||||
},
|
||||
// The post-suggestion status update.
|
||||
{ data: [{ id: 'tx-1' }] },
|
||||
],
|
||||
company_settings: [settingsPage],
|
||||
suppliers: [{ data: [{ id: 'sup-1' }] }],
|
||||
// The single EUR sweep returns a kronor invoice anyway (this stub ignores
|
||||
// the filters): the shared-unit re-check must drop it rather than read
|
||||
// 1 000 kr as 1 000 EUR.
|
||||
supplier_invoices: [{ data: [supplierInvoiceRow({ remaining_amount: 1000, total: 1000, total_sek: 1000 })] }],
|
||||
fiscal_periods: [{ data: [{ id: 'period-1' }] }],
|
||||
})
|
||||
mockBuildMappingResultFromCategory.mockReturnValue({ ...baseMapping, debit_account: '2440' })
|
||||
mockCreateTransactionJournalEntry.mockResolvedValue({ id: 'je-1' })
|
||||
|
||||
const response = await categorize('expense_software')
|
||||
const { status, body } = await parseJsonResponse<{ success: boolean }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
expect(queriesOn('supplier_invoices')).toHaveLength(1)
|
||||
expect(queriesOn('supplier_invoices')[0].calls.or).toEqual([['currency.eq.EUR']])
|
||||
})
|
||||
})
|
||||
@@ -5,6 +5,7 @@ import { ensureInitialized } from '@/lib/init'
|
||||
import { buildMappingResultFromCategory } from '@/lib/bookkeeping/category-mapping'
|
||||
import { getTemplateById, buildMappingResultFromTemplate, validateTemplateForEntity } from '@/lib/bookkeeping/booking-templates'
|
||||
import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries'
|
||||
import { cancelOrphanedPaymentEntry } from '@/lib/bookkeeping/cancel-orphaned-entry'
|
||||
import { detectBookingDuplicate } from '@/lib/transactions/booking-duplicate-detection'
|
||||
import { appendProcessingHistory } from '@/lib/processing-history/append'
|
||||
import { saveUserMappingRule, applySettlementAccount } from '@/lib/bookkeeping/mapping-engine'
|
||||
@@ -18,6 +19,14 @@ import {
|
||||
escapeLikePattern,
|
||||
normalizeOcrReference,
|
||||
} from '@/lib/invoices/duplicate-payment-guard'
|
||||
import {
|
||||
invoiceAmountSek,
|
||||
magnitudesWithinTolerance,
|
||||
normalizeCurrencyCode,
|
||||
planAmountSweeps,
|
||||
type ComparableAmount,
|
||||
} from '@/lib/invoices/duplicate-guard-currency'
|
||||
import { resolveTransactionAmountSek } from '@/lib/transactions/booking-duplicate-detection'
|
||||
import { AccountsNotInChartError, accountsNotInChartResponse } from '@/lib/bookkeeping/errors'
|
||||
import { collectMappingResultAccounts, findUnresolvableAccounts } from '@/lib/bookkeeping/account-validation'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
@@ -158,6 +167,11 @@ export const POST = withRouteContext(
|
||||
id,
|
||||
date: transaction.date,
|
||||
amount: transaction.amount,
|
||||
// `amount` is denominated in `currency`; the ledger legs the guard
|
||||
// compares it against are always SEK. Selected above via select('*').
|
||||
currency: transaction.currency ?? null,
|
||||
amount_sek: transaction.amount_sek ?? null,
|
||||
exchange_rate: transaction.exchange_rate ?? null,
|
||||
cash_account_id: transaction.cash_account_id ?? null,
|
||||
})
|
||||
if (!body.force) {
|
||||
@@ -207,8 +221,19 @@ export const POST = withRouteContext(
|
||||
transaction_id: id,
|
||||
dismissed_transaction_id: candidate.transaction_id,
|
||||
dismissed_journal_entry_id: candidate.journal_entry_id,
|
||||
amount_ore: Math.round(candidate.amount * 100),
|
||||
// Null when the candidate's SEK value could not be established
|
||||
// (a rateless foreign sibling); the foreign figures below then
|
||||
// carry the durable record instead of a fabricated kr amount.
|
||||
amount_ore: candidate.amount != null ? Math.round(candidate.amount * 100) : null,
|
||||
dismissed_currency: candidate.currency,
|
||||
dismissed_amount_in_currency: candidate.amount_in_currency,
|
||||
entry_date: candidate.entry_date,
|
||||
// Whether the user dismissed a confirmed same-amount twin or a
|
||||
// candidate whose kr figure was never established (BFNAR 2013:2
|
||||
// kap 8: the behandlingshistorik has to say which). Parity with
|
||||
// the /book route's dismissal record.
|
||||
amount_verified: candidate.amount_verified,
|
||||
unverified_reason: candidate.unverified_reason,
|
||||
},
|
||||
actor: { type: 'user', id: user.id },
|
||||
occurredAt: new Date(),
|
||||
@@ -403,6 +428,50 @@ export const POST = withRouteContext(
|
||||
})
|
||||
}
|
||||
|
||||
// Units for both invoice-suggestion prongs below. `transactions.amount` is
|
||||
// denominated in `transactions.currency`, while `remaining_amount` on
|
||||
// `supplier_invoices` / `invoices` is denominated in the INVOICE's
|
||||
// currency. A plus-minus 2 % band built around a EUR bank row and applied
|
||||
// to a kronor `remaining_amount` column is off by the whole exchange rate:
|
||||
// it either matches nothing or points the user at an unrelated invoice.
|
||||
// `planAmountSweeps` therefore issues one SQL sweep per currency (band and
|
||||
// column in the same unit) and `magnitudesWithinTolerance` re-checks every
|
||||
// returned row. A SEK transaction yields exactly one sweep with the band it
|
||||
// had before, so a SEK-only company runs the identical single query.
|
||||
const txReferenceAmount: ComparableAmount = {
|
||||
amount: transaction.amount,
|
||||
currency: normalizeCurrencyCode(transaction.currency),
|
||||
sek: resolveTransactionAmountSek({
|
||||
amount: transaction.amount,
|
||||
currency: transaction.currency,
|
||||
amount_sek: transaction.amount_sek,
|
||||
exchange_rate: transaction.exchange_rate,
|
||||
}),
|
||||
}
|
||||
|
||||
/** A candidate invoice row as a comparable amount (pro-rates `total_sek`). */
|
||||
const invoiceRowAmount = (row: {
|
||||
remaining_amount: number | null
|
||||
total?: number | null
|
||||
currency: string | null
|
||||
total_sek?: number | null
|
||||
exchange_rate?: number | null
|
||||
}): ComparableAmount => {
|
||||
const remaining = row.remaining_amount ?? row.total ?? 0
|
||||
const currency = normalizeCurrencyCode(row.currency)
|
||||
return {
|
||||
amount: Number(remaining),
|
||||
currency,
|
||||
sek: invoiceAmountSek({
|
||||
amount: Number(remaining),
|
||||
currency,
|
||||
total: row.total,
|
||||
totalSek: row.total_sek,
|
||||
exchangeRate: row.exchange_rate,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// Prong B: intercept plain 244x categorization of supplier payments when
|
||||
// an open supplier invoice already covers this amount. Categorizing direct
|
||||
// to 244x leaves the invoice with status='approved' and lures the user
|
||||
@@ -416,9 +485,20 @@ export const POST = withRouteContext(
|
||||
/^244\d$/.test(mappingResult.debit_account) &&
|
||||
/^1\d{3}$/.test(mappingResult.credit_account)
|
||||
) {
|
||||
const txAmountAbs = Math.abs(transaction.amount)
|
||||
const windowLow = Math.round(txAmountAbs * (1 - DUPLICATE_AMOUNT_TOLERANCE_PCT) * 100) / 100
|
||||
const windowHigh = Math.round(txAmountAbs * (1 + DUPLICATE_AMOUNT_TOLERANCE_PCT) * 100) / 100
|
||||
const { sweeps, crossCurrencyUnverifiable } = planAmountSweeps(
|
||||
txReferenceAmount,
|
||||
DUPLICATE_AMOUNT_TOLERANCE_PCT,
|
||||
)
|
||||
if (crossCurrencyUnverifiable) {
|
||||
// A foreign bank row with neither amount_sek nor exchange_rate cannot
|
||||
// be stated in kronor, so kronor invoices are excluded rather than
|
||||
// compared raw. Logged: an unevaluated candidate set is not the same
|
||||
// thing as "no open invoice matches".
|
||||
txLog.warn('supplier-invoice suggestion: cross-currency candidates not evaluated', {
|
||||
reason: 'transaction_missing_sek_value',
|
||||
currency: txReferenceAmount.currency,
|
||||
})
|
||||
}
|
||||
|
||||
let supplierIds: string[] = []
|
||||
if (transaction.merchant_name) {
|
||||
@@ -444,20 +524,56 @@ export const POST = withRouteContext(
|
||||
.toISOString()
|
||||
.split('T')[0]
|
||||
|
||||
const { data: openInvoices } = await supabase
|
||||
.from('supplier_invoices')
|
||||
.select('id, supplier_invoice_number, invoice_date, remaining_amount, currency, supplier:suppliers(name)')
|
||||
.eq('company_id', companyId)
|
||||
.in('supplier_id', supplierIds)
|
||||
.in('status', ['registered', 'approved', 'partially_paid', 'overdue'])
|
||||
.gte('remaining_amount', windowLow)
|
||||
.lte('remaining_amount', windowHigh)
|
||||
.gte('invoice_date', invoiceDateLow)
|
||||
.lte('invoice_date', invoiceDateHigh)
|
||||
.order('invoice_date', { ascending: false })
|
||||
.limit(5)
|
||||
type SupplierCandidateRow = {
|
||||
id: string
|
||||
supplier_invoice_number: string | null
|
||||
invoice_date: string
|
||||
remaining_amount: number | null
|
||||
total: number | null
|
||||
currency: string | null
|
||||
total_sek: number | null
|
||||
exchange_rate: number | null
|
||||
supplier: { name?: string } | null
|
||||
}
|
||||
|
||||
if (openInvoices && openInvoices.length > 0) {
|
||||
const sweepResults = await Promise.all(
|
||||
sweeps.map((sweep) =>
|
||||
supabase
|
||||
.from('supplier_invoices')
|
||||
.select(
|
||||
'id, supplier_invoice_number, invoice_date, remaining_amount, total, currency, total_sek, exchange_rate, supplier:suppliers(name)',
|
||||
)
|
||||
.eq('company_id', companyId)
|
||||
.in('supplier_id', supplierIds)
|
||||
.in('status', ['registered', 'approved', 'partially_paid', 'overdue'])
|
||||
.or(sweep.currencyFilter)
|
||||
.gte('remaining_amount', sweep.low)
|
||||
.lte('remaining_amount', sweep.high)
|
||||
.gte('invoice_date', invoiceDateLow)
|
||||
.lte('invoice_date', invoiceDateHigh)
|
||||
.order('invoice_date', { ascending: false })
|
||||
.limit(5),
|
||||
),
|
||||
)
|
||||
|
||||
const byId = new Map<string, SupplierCandidateRow>()
|
||||
for (const res of sweepResults) {
|
||||
for (const row of (res.data ?? []) as unknown as SupplierCandidateRow[]) {
|
||||
if (!byId.has(row.id)) byId.set(row.id, row)
|
||||
}
|
||||
}
|
||||
const openInvoices = Array.from(byId.values())
|
||||
.filter((inv) =>
|
||||
magnitudesWithinTolerance(
|
||||
txReferenceAmount,
|
||||
invoiceRowAmount(inv),
|
||||
DUPLICATE_AMOUNT_TOLERANCE_PCT,
|
||||
),
|
||||
)
|
||||
.sort((a, b) => (a.invoice_date < b.invoice_date ? 1 : a.invoice_date > b.invoice_date ? -1 : 0))
|
||||
.slice(0, 5)
|
||||
|
||||
if (openInvoices.length > 0) {
|
||||
return errorResponseFromCode('TX_CATEGORIZE_SUGGEST_SI_MATCH', txLog, {
|
||||
requestId,
|
||||
details: {
|
||||
@@ -488,9 +604,16 @@ export const POST = withRouteContext(
|
||||
/^19\d{2}$/.test(mappingResult.debit_account) &&
|
||||
/^151\d$/.test(mappingResult.credit_account)
|
||||
) {
|
||||
const txAmount = transaction.amount
|
||||
const windowLow = Math.round(txAmount * (1 - DUPLICATE_AMOUNT_TOLERANCE_PCT) * 100) / 100
|
||||
const windowHigh = Math.round(txAmount * (1 + DUPLICATE_AMOUNT_TOLERANCE_PCT) * 100) / 100
|
||||
const { sweeps, crossCurrencyUnverifiable } = planAmountSweeps(
|
||||
txReferenceAmount,
|
||||
DUPLICATE_AMOUNT_TOLERANCE_PCT,
|
||||
)
|
||||
if (crossCurrencyUnverifiable) {
|
||||
txLog.warn('customer-invoice suggestion: cross-currency candidates not evaluated', {
|
||||
reason: 'transaction_missing_sek_value',
|
||||
currency: txReferenceAmount.currency,
|
||||
})
|
||||
}
|
||||
|
||||
// Resolve candidate customer(s) by name. Inbound bank txs are typically
|
||||
// described by payer name in EITHER merchant_name OR description, so
|
||||
@@ -533,28 +656,47 @@ export const POST = withRouteContext(
|
||||
due_date: string | null
|
||||
remaining_amount: number | null
|
||||
total: number
|
||||
currency: string
|
||||
currency: string | null
|
||||
total_sek: number | null
|
||||
exchange_rate: number | null
|
||||
customer: { name?: string } | null
|
||||
}
|
||||
const CANDIDATE_COLUMNS =
|
||||
'id, invoice_number, invoice_date, due_date, remaining_amount, total, currency, total_sek, exchange_rate, customer:customers(name)'
|
||||
const openInvoiceCandidates: CandidateRow[] = []
|
||||
/** Same-unit re-check: drops any row the SQL sweep let through. */
|
||||
const comparable = (row: CandidateRow) =>
|
||||
magnitudesWithinTolerance(
|
||||
txReferenceAmount,
|
||||
invoiceRowAmount(row),
|
||||
DUPLICATE_AMOUNT_TOLERANCE_PCT,
|
||||
)
|
||||
|
||||
if (customerIds.length > 0) {
|
||||
const { data: byCustomer } = await supabase
|
||||
.from('invoices')
|
||||
.select(
|
||||
'id, invoice_number, invoice_date, due_date, remaining_amount, total, currency, customer:customers(name)',
|
||||
)
|
||||
.eq('company_id', companyId)
|
||||
.in('customer_id', customerIds)
|
||||
.in('status', ['sent', 'overdue', 'partially_paid'])
|
||||
.gte('remaining_amount', windowLow)
|
||||
.lte('remaining_amount', windowHigh)
|
||||
.gte('due_date', dueDateLow)
|
||||
.lte('due_date', dueDateHigh)
|
||||
.order('due_date', { ascending: false })
|
||||
.limit(5)
|
||||
for (const row of (byCustomer ?? []) as unknown as CandidateRow[]) {
|
||||
openInvoiceCandidates.push(row)
|
||||
const sweepResults = await Promise.all(
|
||||
sweeps.map((sweep) =>
|
||||
supabase
|
||||
.from('invoices')
|
||||
.select(CANDIDATE_COLUMNS)
|
||||
.eq('company_id', companyId)
|
||||
.in('customer_id', customerIds)
|
||||
.in('status', ['sent', 'overdue', 'partially_paid'])
|
||||
.or(sweep.currencyFilter)
|
||||
.gte('remaining_amount', sweep.low)
|
||||
.lte('remaining_amount', sweep.high)
|
||||
.gte('due_date', dueDateLow)
|
||||
.lte('due_date', dueDateHigh)
|
||||
.order('due_date', { ascending: false })
|
||||
.limit(5),
|
||||
),
|
||||
)
|
||||
for (const res of sweepResults) {
|
||||
for (const row of (res.data ?? []) as unknown as CandidateRow[]) {
|
||||
if (!comparable(row)) continue
|
||||
if (!openInvoiceCandidates.some((existing) => existing.id === row.id)) {
|
||||
openInvoiceCandidates.push(row)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -565,21 +707,26 @@ export const POST = withRouteContext(
|
||||
const txReference = (transaction as Transaction & { reference?: string | null }).reference
|
||||
const normalizedTxRef = normalizeOcrReference(txReference ?? null)
|
||||
if (normalizedTxRef) {
|
||||
const { data: byRef } = await supabase
|
||||
.from('invoices')
|
||||
.select(
|
||||
'id, invoice_number, invoice_date, due_date, remaining_amount, total, currency, customer:customers(name)',
|
||||
)
|
||||
.eq('company_id', companyId)
|
||||
.in('status', ['sent', 'overdue', 'partially_paid'])
|
||||
.gte('remaining_amount', windowLow)
|
||||
.lte('remaining_amount', windowHigh)
|
||||
.gte('due_date', dueDateLow)
|
||||
.lte('due_date', dueDateHigh)
|
||||
.order('due_date', { ascending: false })
|
||||
.limit(20)
|
||||
for (const row of (byRef ?? []) as unknown as CandidateRow[]) {
|
||||
if (normalizeOcrReference(row.invoice_number) === normalizedTxRef) {
|
||||
const refSweepResults = await Promise.all(
|
||||
sweeps.map((sweep) =>
|
||||
supabase
|
||||
.from('invoices')
|
||||
.select(CANDIDATE_COLUMNS)
|
||||
.eq('company_id', companyId)
|
||||
.in('status', ['sent', 'overdue', 'partially_paid'])
|
||||
.or(sweep.currencyFilter)
|
||||
.gte('remaining_amount', sweep.low)
|
||||
.lte('remaining_amount', sweep.high)
|
||||
.gte('due_date', dueDateLow)
|
||||
.lte('due_date', dueDateHigh)
|
||||
.order('due_date', { ascending: false })
|
||||
.limit(20),
|
||||
),
|
||||
)
|
||||
for (const res of refSweepResults) {
|
||||
for (const row of (res.data ?? []) as unknown as CandidateRow[]) {
|
||||
if (normalizeOcrReference(row.invoice_number) !== normalizedTxRef) continue
|
||||
if (!comparable(row)) continue
|
||||
if (!openInvoiceCandidates.some((existing) => existing.id === row.id)) {
|
||||
openInvoiceCandidates.unshift(row)
|
||||
}
|
||||
@@ -779,28 +926,16 @@ export const POST = withRouteContext(
|
||||
|
||||
if ((!updateResult || updateResult.length === 0) && journalEntryId) {
|
||||
// CAS guard: another request set journal_entry_id between our read and
|
||||
// write. Cancel the orphaned entry and document the voucher gap.
|
||||
const { data: orphan } = await supabase
|
||||
.from('journal_entries')
|
||||
.select('fiscal_period_id, voucher_series, voucher_number')
|
||||
.eq('id', journalEntryId)
|
||||
.single()
|
||||
|
||||
await supabase
|
||||
.from('journal_entries')
|
||||
.update({ status: 'cancelled' })
|
||||
.eq('id', journalEntryId)
|
||||
|
||||
if (orphan) {
|
||||
await supabase.from('voucher_gap_explanations').insert({
|
||||
company_id: companyId,
|
||||
fiscal_period_id: orphan.fiscal_period_id,
|
||||
voucher_series: orphan.voucher_series || 'A',
|
||||
gap_number: orphan.voucher_number,
|
||||
explanation: 'Automatiskt makulerad: dubblettbokning förhindrad av samtidighetsskydd',
|
||||
created_by: user.id,
|
||||
})
|
||||
}
|
||||
// write. Cancel the orphaned entry and document the voucher gap through
|
||||
// the shared helper (BFNAR 2013:2), which owns the correct
|
||||
// voucher_gap_explanations column set and logs failures loudly.
|
||||
await cancelOrphanedPaymentEntry(
|
||||
supabase,
|
||||
companyId,
|
||||
user.id,
|
||||
journalEntryId,
|
||||
'Automatiskt makulerad: dubblettbokning förhindrad av samtidighetsskydd',
|
||||
)
|
||||
|
||||
return errorResponseFromCode('TX_CATEGORIZE_RACE', txLog, { requestId })
|
||||
}
|
||||
|
||||
@@ -27,9 +27,15 @@ export const GET = withRouteContext(
|
||||
// narrow (id, date, amount, journal_entry_id) so this endpoint cannot
|
||||
// leak description / counterparty fields that aren't required to
|
||||
// surface a duplicate-payment candidate. GDPR Art.5(1)(c)/(f).
|
||||
//
|
||||
// currency / amount_sek / exchange_rate are part of that minimum, not an
|
||||
// expansion of it: they carry no personal data, and without them the
|
||||
// detector cannot state a non-SEK bank line in SEK and would compare a
|
||||
// foreign amount against an always-SEK ledger leg. A narrow column list is
|
||||
// exactly how this guard would go dead on FX rows.
|
||||
const { data: transaction, error } = await supabase
|
||||
.from('transactions')
|
||||
.select('id, date, amount, journal_entry_id')
|
||||
.select('id, date, amount, currency, amount_sek, exchange_rate, journal_entry_id')
|
||||
.eq('id', transactionId)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
@@ -49,6 +55,9 @@ export const GET = withRouteContext(
|
||||
transactionId,
|
||||
transactionDate: transaction.date,
|
||||
transactionAmount: transaction.amount,
|
||||
transactionCurrency: transaction.currency ?? null,
|
||||
transactionAmountSek: transaction.amount_sek ?? null,
|
||||
transactionExchangeRate: transaction.exchange_rate ?? null,
|
||||
})
|
||||
return NextResponse.json({ candidate })
|
||||
} catch (err) {
|
||||
|
||||
@@ -112,7 +112,9 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error).toBe('Validation failed')
|
||||
// Inverted from `toBe('Validation failed')`: the constant was the bug.
|
||||
expect(body.error).toMatch(/^Valideringsfel: /)
|
||||
expect(body.error).toContain('invoice_id')
|
||||
})
|
||||
|
||||
it('returns 404 when transaction not found', async () => {
|
||||
@@ -1014,7 +1016,44 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
|
||||
expect((body.error as unknown as { code: string }).code).toBe('MATCH_INVOICE_DUPLICATE_PAYMENT')
|
||||
})
|
||||
|
||||
it('returns success with journal_entry_error when journal entry fails (non-blocking)', async () => {
|
||||
it('aborts the match with 500 when the payment journal entry fails (no invoice update, no link)', async () => {
|
||||
// Regression for the half-state: a generic booking failure used to mark
|
||||
// the invoice paid and link the transaction with NO verifikat, which no
|
||||
// flow could ever repair (mark-paid rejects paid invoices, this route
|
||||
// rejects linked transactions). Mirrors match-supplier-invoice: the match
|
||||
// aborts before ANY write.
|
||||
const tx = makeTransaction({ id: 'tx-1', amount: 12500, invoice_id: null, date: '2024-06-15' })
|
||||
const invoice = makeInvoice({ id: VALID_UUID, status: 'sent', total: 12500, remaining_amount: 12500 })
|
||||
|
||||
enqueue({ data: tx, error: null })
|
||||
enqueue({ data: invoice, error: null })
|
||||
enqueue({ data: [], error: null }) // hard-duplicate check
|
||||
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
|
||||
// Nothing else enqueued on purpose: the route must return before the
|
||||
// invoice update, payment insert, or transaction link ever run.
|
||||
|
||||
mockCreateJournalEntry.mockRejectedValue(new Error('deadlock detected'))
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/match-invoice', {
|
||||
method: 'POST',
|
||||
body: { invoice_id: VALID_UUID },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
error: { code: string; details?: { reason?: string } }
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(500)
|
||||
expect(body.error.code).toBe('MATCH_INVOICE_RECORD_PAYMENT_FAILED')
|
||||
// The raw English message never reaches the user (issue #337): the reason
|
||||
// detail carries the Swedish invoice-context fallback.
|
||||
expect(body.error.details?.reason).toBe('Kunde inte hantera fakturan. Försök igen.')
|
||||
// Exactly the four reads happened (tx, invoice, hard-dup, settings):
|
||||
// no invoice update, no invoice_payments insert, no transaction link.
|
||||
expect(mockSupabase.from).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
|
||||
it('aborts the match when createJournalEntry resolves without an id (no half-state)', async () => {
|
||||
const tx = makeTransaction({ id: 'tx-1', amount: 12500, invoice_id: null, date: '2024-06-15' })
|
||||
const invoice = makeInvoice({ id: VALID_UUID, status: 'sent', total: 12500, remaining_amount: 12500 })
|
||||
|
||||
@@ -1023,34 +1062,18 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
|
||||
enqueue({ data: [], error: null }) // hard-duplicate check
|
||||
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
|
||||
|
||||
mockCreateJournalEntry.mockRejectedValue(new Error('Period locked'))
|
||||
|
||||
// Update invoice (optimistic lock)
|
||||
enqueue({ data: [{ id: VALID_UUID }], error: null })
|
||||
// Insert invoice_payments
|
||||
enqueue({ data: null, error: null })
|
||||
// Update transaction
|
||||
enqueue({ data: null, error: null })
|
||||
// logMatchEvent
|
||||
enqueue({ data: null, error: null })
|
||||
mockCreateJournalEntry.mockResolvedValue(null)
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/match-invoice', {
|
||||
method: 'POST',
|
||||
body: { invoice_id: VALID_UUID },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
success: boolean
|
||||
journal_entry_id: null
|
||||
journal_entry_error: string
|
||||
}>(response)
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
expect(body.journal_entry_id).toBeNull()
|
||||
// Untyped errors no longer leak their raw English message (issue #337):
|
||||
// they map to the Swedish invoice-context fallback.
|
||||
expect(body.journal_entry_error).toBe('Kunde inte hantera fakturan. Försök igen.')
|
||||
expect(status).toBe(500)
|
||||
expect(body.error.code).toBe('MATCH_INVOICE_RECORD_PAYMENT_FAILED')
|
||||
expect(mockSupabase.from).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -135,6 +135,41 @@ export const GET = withRouteContext(
|
||||
| { required: true; error: 'rate_unavailable'; tx_currency: string; invoice_currency: string }
|
||||
| { required: false }
|
||||
|
||||
// Mirrors the POST handler's guard. transactions.amount is denominated in
|
||||
// transactions.currency; the SEK value lives in amount_sek (pre-computed at
|
||||
// ingest) or is derivable from exchange_rate. A foreign row carrying
|
||||
// neither (the shape a row gets when the Riksbanken lookup failed at
|
||||
// ingest, see lib/transactions/ingest.ts) has no establishable SEK value,
|
||||
// and the raw foreign number must never stand in for one: the dialog would
|
||||
// preview a 500 USD receipt as a 500 SEK verifikat. Refuse here too so the
|
||||
// preview never shows lines the POST would reject.
|
||||
const txIsForeign = !!transaction.currency && transaction.currency !== 'SEK'
|
||||
if (
|
||||
txIsForeign &&
|
||||
transaction.amount_sek == null &&
|
||||
!(transaction.exchange_rate != null && transaction.exchange_rate > 0)
|
||||
) {
|
||||
return errorResponseFromCode('MATCH_INVOICE_TX_FX_RATE_MISSING', log, {
|
||||
requestId,
|
||||
details: {
|
||||
transactionCurrency: transaction.currency,
|
||||
transactionDate: transaction.date,
|
||||
},
|
||||
})
|
||||
}
|
||||
// Actual SEK that hit the bank, resolved through the same helper
|
||||
// buildInvoicePaymentClearingLines uses for the bank leg. SEK rows return
|
||||
// Math.abs(amount) unchanged.
|
||||
const txAbsSek =
|
||||
Math.round(
|
||||
resolveSekAmount(
|
||||
Math.abs(transaction.amount),
|
||||
transaction.amount_sek != null ? Math.abs(transaction.amount_sek) : null,
|
||||
transaction.currency,
|
||||
transaction.exchange_rate,
|
||||
) * 100,
|
||||
) / 100
|
||||
|
||||
let fxConversion: FxConversion = { required: false }
|
||||
if (transaction.currency !== invoice.currency) {
|
||||
const rateInfo = await fetchExchangeRate(
|
||||
@@ -145,10 +180,6 @@ export const GET = withRouteContext(
|
||||
// bankSek / rate = how many units of invoice.currency this payment
|
||||
// satisfies. Round to 4 decimal places to preserve precision through
|
||||
// subsequent partial-payment accumulations.
|
||||
const txAbsSek =
|
||||
transaction.currency === 'SEK'
|
||||
? Math.abs(transaction.amount)
|
||||
: Math.abs(transaction.amount) * (transaction.exchange_rate ?? 1)
|
||||
const paidInInvoiceCurrency =
|
||||
Math.round((txAbsSek / rateInfo.rate) * 10000) / 10000
|
||||
fxConversion = {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createInvoiceCashEntry } from '@/lib/bookkeeping/invoice-entries'
|
||||
import { buildInvoicePaymentClearingLines } from '@/lib/bookkeeping/invoice-payment-lines'
|
||||
import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils'
|
||||
import { coerceDimensionsBag } from '@/lib/bookkeeping/dimension-resolver'
|
||||
import { resolveSettlementAccount } from '@/lib/bookkeeping/settlement-account'
|
||||
import { fetchExchangeRate } from '@/lib/currency/riksbanken'
|
||||
import { reverseEntry, createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
|
||||
import { AccountsNotInChartError } from '@/lib/bookkeeping/errors'
|
||||
import { AccountsNotInChartError, isBookkeepingError } from '@/lib/bookkeeping/errors'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
@@ -155,6 +157,46 @@ export const POST = withRouteContext(
|
||||
source: 'manual' | 'riksbanken'
|
||||
}
|
||||
|
||||
// A bank row is stored in ITS OWN currency: transactions.amount is
|
||||
// denominated in transactions.currency, and the SEK value lives either in
|
||||
// amount_sek (pre-computed at ingest) or is derivable from exchange_rate.
|
||||
// Every journal entry line is SEK, so a foreign row whose SEK value cannot
|
||||
// be established must not be booked or allocated at all: substituting the
|
||||
// raw foreign number would settle a 500 USD receipt as 500 SEK, a tenth of
|
||||
// the real payment. Rows in exactly that shape exist: when the Riksbanken
|
||||
// lookup fails at ingest the transaction is written with neither field
|
||||
// (lib/transactions/ingest.ts). Refuse loudly, the same way the
|
||||
// match_batch_allocate RPC refuses with BATCH_FX_RATE_MISSING, instead of
|
||||
// guessing a rate of 1.
|
||||
const txIsForeign = !!transaction.currency && transaction.currency !== 'SEK'
|
||||
if (
|
||||
txIsForeign &&
|
||||
transaction.amount_sek == null &&
|
||||
!(transaction.exchange_rate != null && transaction.exchange_rate > 0)
|
||||
) {
|
||||
return errorResponseFromCode('MATCH_INVOICE_TX_FX_RATE_MISSING', txLog, {
|
||||
requestId,
|
||||
details: {
|
||||
transactionCurrency: transaction.currency,
|
||||
transactionDate: transaction.date,
|
||||
},
|
||||
})
|
||||
}
|
||||
// The actual SEK that hit the bank. Resolved through the SAME helper
|
||||
// buildInvoicePaymentClearingLines uses for the bank leg (amount_sek first,
|
||||
// then amount * exchange_rate), so the FX conversion below and the posted
|
||||
// verifikat can never disagree on the SEK figure. SEK rows return
|
||||
// Math.abs(amount) unchanged.
|
||||
const txAbsSek =
|
||||
Math.round(
|
||||
resolveSekAmount(
|
||||
Math.abs(transaction.amount),
|
||||
transaction.amount_sek != null ? Math.abs(transaction.amount_sek) : null,
|
||||
transaction.currency,
|
||||
transaction.exchange_rate,
|
||||
) * 100,
|
||||
) / 100
|
||||
|
||||
let fx: FxConversion = { required: false }
|
||||
if (transaction.currency !== invoice.currency) {
|
||||
const manualRate =
|
||||
@@ -184,10 +226,6 @@ export const POST = withRouteContext(
|
||||
},
|
||||
})
|
||||
}
|
||||
const txAbsSek =
|
||||
transaction.currency === 'SEK'
|
||||
? Math.abs(transaction.amount)
|
||||
: Math.abs(transaction.amount) * (transaction.exchange_rate ?? 1)
|
||||
const paidInInvoiceCurrency = Math.round((txAbsSek / rate) * 10000) / 10000
|
||||
fx = {
|
||||
required: true,
|
||||
@@ -239,6 +277,11 @@ export const POST = withRouteContext(
|
||||
transactionId,
|
||||
transactionDate: transaction.date,
|
||||
transactionAmount: transaction.amount,
|
||||
// `amount` is in `currency`; the 19xx legs the detector compares it
|
||||
// against are always SEK. Selected above via select('*').
|
||||
transactionCurrency: transaction.currency ?? null,
|
||||
transactionAmountSek: transaction.amount_sek ?? null,
|
||||
transactionExchangeRate: transaction.exchange_rate ?? null,
|
||||
})
|
||||
if (!force) {
|
||||
if (candidate) {
|
||||
@@ -376,7 +419,6 @@ export const POST = withRouteContext(
|
||||
const useCashEntry = !invoiceAlreadyBooked && accountingMethod === 'cash' && isFullyPaid
|
||||
|
||||
let journalEntryId: string | null = null
|
||||
let journalEntryError: string | null = null
|
||||
|
||||
try {
|
||||
if (customLines) {
|
||||
@@ -463,6 +505,19 @@ export const POST = withRouteContext(
|
||||
fx.required ? fx.paidInInvoiceCurrency : undefined,
|
||||
paymentAccount,
|
||||
)
|
||||
// Re-propagate the invoice's default dimension bag onto every leg,
|
||||
// including the FX result lines, so a project's kursvinst/kursförlust
|
||||
// stays inside the project P&L. createInvoicePaymentJournalEntry does
|
||||
// this for its own callers; the shared line-builder is dimension-
|
||||
// agnostic, so the two routes that use it have to do it themselves or
|
||||
// dimension users silently lose the tagging on payment vouchers.
|
||||
// Copied per line: a shared object would let one line's mutation leak.
|
||||
const defaultDimensions = coerceDimensionsBag(
|
||||
(invoice as { default_dimensions?: unknown }).default_dimensions,
|
||||
)
|
||||
if (defaultDimensions) {
|
||||
for (const line of clearingLines) line.dimensions = { ...defaultDimensions }
|
||||
}
|
||||
const journalEntry = await createJournalEntry(supabase, companyId!, user.id, {
|
||||
fiscal_period_id: fiscalPeriodId,
|
||||
entry_date: transaction.date,
|
||||
@@ -478,11 +533,40 @@ export const POST = withRouteContext(
|
||||
if (err instanceof AccountsNotInChartError) {
|
||||
return errorResponse(err, txLog, { requestId })
|
||||
}
|
||||
// A foreign-currency invoice with no booking rate is a missing-input
|
||||
// failure, not a transient booking failure: buildInvoicePaymentClearing
|
||||
// Lines refuses rather than valuing the 1510 credit at a fabricated rate.
|
||||
// Fully retryable once invoice.exchange_rate is on file.
|
||||
// Dispatch on `code`, not instanceof: the class lives in a module route
|
||||
// tests routinely vi.mock away, and the literal keeps a mocked-away
|
||||
// export from turning into an `undefined === undefined` catch-all.
|
||||
if ((err as { code?: unknown })?.code === 'MATCH_INVOICE_BOOKING_RATE_MISSING') {
|
||||
return errorResponse(err, txLog, { requestId })
|
||||
}
|
||||
txLog.error('failed to create payment journal entry', err as Error)
|
||||
// Other errors are recorded but don't abort the match: the user can
|
||||
// re-book the verifikation manually. All errors map to Swedish via
|
||||
// getErrorMessage; the raw message must never reach the user (issue #337).
|
||||
journalEntryError = getErrorMessage(err, { context: 'invoice' })
|
||||
// ANY failed payment voucher fails the whole match (mirrors
|
||||
// match-supplier-invoice): proceeding used to mark the invoice paid and
|
||||
// link the transaction with NO verifikat, an unrecoverable half-state:
|
||||
// mark-paid rejects 'paid' invoices and this route rejects linked
|
||||
// transactions, so no flow could ever complete the booking afterwards.
|
||||
// Typed bookkeeping errors (period locked, no fiscal period, ...) map
|
||||
// to their registered envelope; everything else returns the invoice-
|
||||
// side payment-failure code with a Swedish reason via getErrorMessage,
|
||||
// so the raw message never reaches the user (issue #337).
|
||||
if (isBookkeepingError(err)) {
|
||||
return errorResponse(err, txLog, { requestId })
|
||||
}
|
||||
return errorResponseFromCode('MATCH_INVOICE_RECORD_PAYMENT_FAILED', txLog, {
|
||||
requestId,
|
||||
details: { reason: getErrorMessage(err, { context: 'invoice' }) },
|
||||
})
|
||||
}
|
||||
|
||||
if (!journalEntryId) {
|
||||
// createJournalEntry resolved without an id: the same unrecoverable
|
||||
// half-state as a thrown failure, so the match aborts here too
|
||||
// (mirrors the supplier route's !journalEntryId guard).
|
||||
return errorResponseFromCode('MATCH_INVOICE_RECORD_PAYMENT_FAILED', txLog, { requestId })
|
||||
}
|
||||
|
||||
// Underlag for the payment verifikation: re-attach the invoice PDF that
|
||||
@@ -652,13 +736,6 @@ export const POST = withRouteContext(
|
||||
txLog.warn('invoice.match_confirmed event emission failed', err as Error)
|
||||
}
|
||||
|
||||
if (journalEntryError) {
|
||||
txLog.warn('match recorded but payment journal entry failed', {
|
||||
errorCode: 'MATCH_INVOICE_PARTIAL',
|
||||
message: journalEntryError,
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
invoice_status: newStatus,
|
||||
@@ -666,7 +743,9 @@ export const POST = withRouteContext(
|
||||
paid_amount: newPaidAmount,
|
||||
remaining_amount: newRemaining,
|
||||
journal_entry_id: journalEntryId,
|
||||
journal_entry_error: journalEntryError,
|
||||
// Always null since a failed voucher now aborts the whole match; the
|
||||
// field survives for response-shape compatibility with existing callers.
|
||||
journal_entry_error: null,
|
||||
category: 'income_services',
|
||||
})
|
||||
},
|
||||
|
||||
@@ -93,6 +93,58 @@ export const GET = withRouteContext(
|
||||
const siAlreadyBooked = !!(invoice as { registration_journal_entry_id?: string | null }).registration_journal_entry_id
|
||||
const useCashEntry = !siAlreadyBooked && accountingMethod === 'cash'
|
||||
|
||||
const si = invoice as SupplierInvoice & { items?: SupplierInvoiceItem[] }
|
||||
|
||||
// Amount resolution, byte-identical to the POST handler: same inputs, same
|
||||
// `Math.round(x * 100) / 100` form. This preview is what the user approves
|
||||
// in the dialog, so any divergence here means one number is approved and a
|
||||
// different one is booked.
|
||||
const txAmountAbs = Math.abs(transaction.amount)
|
||||
const remainingInvoiceCurrency = si.remaining_amount ?? si.total
|
||||
// In the INVOICE's currency: a cross-currency match settles whatever
|
||||
// remains rather than reading the bank figure as invoice currency.
|
||||
const paymentAmountInvoiceCurrency =
|
||||
transaction.currency === si.currency ? txAmountAbs : remainingInvoiceCurrency
|
||||
// SEK that actually left the bank, when known. SEK bank line → the absolute
|
||||
// amount; foreign line with a stored amount_sek → that value; foreign line
|
||||
// WITHOUT amount_sek → unknown (null). The raw foreign amount must never
|
||||
// stand in: that is what renders 19 USD as 19 kr.
|
||||
const bankSekStored =
|
||||
transaction.currency === 'SEK'
|
||||
? txAmountAbs
|
||||
: transaction.amount_sek != null
|
||||
? Math.abs(transaction.amount_sek)
|
||||
: null
|
||||
const invoiceFxRate = si.exchange_rate ?? null
|
||||
// SEK the invoice was booked at for this payment portion; null when the
|
||||
// invoice is foreign and carries no exchange_rate.
|
||||
const bookedSek =
|
||||
si.currency === 'SEK'
|
||||
? paymentAmountInvoiceCurrency
|
||||
: invoiceFxRate && invoiceFxRate > 0
|
||||
? Math.round(paymentAmountInvoiceCurrency * invoiceFxRate * 100) / 100
|
||||
: null
|
||||
const actualBankSek = bankSekStored ?? bookedSek
|
||||
if (actualBankSek == null) {
|
||||
// Neither side yields a SEK figure (foreign bank line without amount_sek
|
||||
// paying a foreign invoice without exchange_rate). The POST handler
|
||||
// refuses this row with the same code, so refuse here instead of
|
||||
// previewing a 1:1 number that can never be committed.
|
||||
return errorResponseFromCode('SI_FX_RATE_MISSING', log, {
|
||||
requestId,
|
||||
details: {
|
||||
transaction_currency: transaction.currency,
|
||||
invoice_currency: si.currency,
|
||||
},
|
||||
})
|
||||
}
|
||||
const originalBookedSek = bookedSek ?? actualBankSek
|
||||
const exchangeRateDifference =
|
||||
Math.round((originalBookedSek - actualBankSek) * 100) / 100
|
||||
const fullSettlement =
|
||||
transaction.currency !== si.currency ||
|
||||
txAmountAbs >= remainingInvoiceCurrency - 0.005
|
||||
|
||||
const lines: PreviewLine[] = []
|
||||
let entryType: 'clearing' | 'cash' = 'clearing'
|
||||
// Drives the dialog's "markeras som betald" / öresavrundning copy. Cash
|
||||
@@ -102,25 +154,30 @@ export const GET = withRouteContext(
|
||||
|
||||
if (useCashEntry) {
|
||||
entryType = 'cash'
|
||||
const si = invoice as SupplierInvoice & { items?: SupplierInvoiceItem[] }
|
||||
const items = si.items ?? []
|
||||
|
||||
// Kontantmetoden books the expense AT PAYMENT at the payment-date rate
|
||||
// (the SEK that actually left the bank), so translate this preview the
|
||||
// same way the committed verifikat does. The bank SEK is only known when
|
||||
// the transaction is in SEK or carries a stored amount_sek; for a foreign
|
||||
// transaction without it we fall back to the invoice's own rate (the raw
|
||||
// foreign amount must never be used: that would render 19 USD as 19 kr).
|
||||
const bankSek =
|
||||
transaction.currency === 'SEK'
|
||||
? Math.abs(transaction.amount)
|
||||
: transaction.amount_sek != null
|
||||
? Math.abs(transaction.amount_sek)
|
||||
: null
|
||||
// Kontantmetoden books the expense AT PAYMENT at the payment-date rate.
|
||||
// The POST handler passes createSupplierInvoiceCashEntry a settledBankSek
|
||||
// override only for a full foreign settlement whose rate actually moved;
|
||||
// the builder then derives its rate as settledBankSek / invoice.total and
|
||||
// otherwise keeps the invoice's stored rate. Mirror that derivation
|
||||
// exactly (createSupplierInvoiceCashEntry's `effectiveRate`).
|
||||
const settledBankSek =
|
||||
exchangeRateDifference !== 0 && fullSettlement ? actualBankSek : undefined
|
||||
const cashRate =
|
||||
bankSek != null && si.currency !== 'SEK' && si.total > 0
|
||||
? bankSek / si.total
|
||||
settledBankSek != null && settledBankSek > 0 && si.currency !== 'SEK' && si.total > 0
|
||||
? settledBankSek / si.total
|
||||
: si.exchange_rate
|
||||
// The builder routes every leg through toSekOrThrow, which refuses a
|
||||
// foreign invoice with no usable rate rather than posting it as if
|
||||
// 1 EUR = 1 SEK. Refuse the same rows here so the dialog can't display
|
||||
// amounts the commit will reject.
|
||||
if (si.currency !== 'SEK' && !(cashRate != null && cashRate > 0)) {
|
||||
return errorResponseFromCode('SI_FX_RATE_MISSING', log, {
|
||||
requestId,
|
||||
details: { invoice_currency: si.currency },
|
||||
})
|
||||
}
|
||||
|
||||
// Mirror createSupplierInvoiceCashEntry: per-item expense debit + VAT
|
||||
// debit + bank credit. We only need a faithful preview, not exact
|
||||
@@ -175,16 +232,13 @@ export const GET = withRouteContext(
|
||||
})
|
||||
} else {
|
||||
// Clearing: Dr 2440 / Cr 1930 (or chosen payment account).
|
||||
const si = invoice as SupplierInvoice
|
||||
const isPureSek = transaction.currency === 'SEK' && si.currency === 'SEK'
|
||||
if (isPureSek) {
|
||||
// Shared builder so the previewed lines (including any 3740
|
||||
// öresavrundning row) are byte-identical to what the POST commits.
|
||||
const remainingSek = si.remaining_amount ?? si.total
|
||||
const bankSek = Math.abs(transaction.amount)
|
||||
const { lines: clearingLines, oreDiffSek } = buildSupplierPaymentClearingLines({
|
||||
apSek: remainingSek,
|
||||
bankSek,
|
||||
apSek: remainingInvoiceCurrency,
|
||||
bankSek: txAmountAbs,
|
||||
paymentAccount,
|
||||
})
|
||||
for (const l of clearingLines) {
|
||||
@@ -198,29 +252,49 @@ export const GET = withRouteContext(
|
||||
oreRounding = oreDiffSek !== 0
|
||||
// Full settlement when the öre residual is absorbed or the bank covers
|
||||
// the whole remaining; a ≥1 kr short payment leaves a partial.
|
||||
isFullyPaid = oreRounding || bankSek >= remainingSek - ORE_TOLERANCE
|
||||
isFullyPaid = oreRounding || txAmountAbs >= remainingInvoiceCurrency - ORE_TOLERANCE
|
||||
} else {
|
||||
const amountSek = resolveSekAmount(
|
||||
Math.abs(transaction.amount),
|
||||
null,
|
||||
transaction.currency,
|
||||
null,
|
||||
)
|
||||
const total = resolveSekAmount(si.total, si.total_sek, si.currency, si.exchange_rate)
|
||||
const amount = Math.round(Math.min(amountSek, total) * 100) / 100
|
||||
// Foreign leg under faktureringsmetoden. createSupplierInvoicePaymentEntry
|
||||
// clears 2440 at the SEK the leverantörsskuld was BOOKED at and credits
|
||||
// the bank with the SEK that actually moved, booking the difference as
|
||||
// kursvinst (3960) or kursförlust (7960). The old preview showed a single
|
||||
// min(bankSEK, invoiceSEK) figure on both legs and no FX line, so the
|
||||
// bank credit the user approved differed from the committed one by
|
||||
// exactly the kursdifferens (and, with no conversion inputs at all,
|
||||
// showed the raw foreign amount as kronor).
|
||||
lines.push({
|
||||
account_number: '2440',
|
||||
debit_amount: amount,
|
||||
debit_amount: Math.round(originalBookedSek * 100) / 100,
|
||||
credit_amount: 0,
|
||||
description: 'Kvittning leverantörsskuld',
|
||||
})
|
||||
lines.push({
|
||||
account_number: paymentAccount,
|
||||
debit_amount: 0,
|
||||
credit_amount: amount,
|
||||
credit_amount: Math.round(actualBankSek * 100) / 100,
|
||||
description: 'Utbetalning från bank',
|
||||
})
|
||||
isFullyPaid = amount >= total - ORE_TOLERANCE
|
||||
if (exchangeRateDifference > 0) {
|
||||
lines.push({
|
||||
account_number: '3960',
|
||||
debit_amount: 0,
|
||||
credit_amount: Math.round(Math.abs(exchangeRateDifference) * 100) / 100,
|
||||
description: 'Valutakursvinst',
|
||||
})
|
||||
} else if (exchangeRateDifference < 0) {
|
||||
lines.push({
|
||||
account_number: '7960',
|
||||
debit_amount: Math.round(Math.abs(exchangeRateDifference) * 100) / 100,
|
||||
credit_amount: 0,
|
||||
description: 'Valutakursförlust',
|
||||
})
|
||||
}
|
||||
// Mirrors planSupplierPayment without öre absorption (the accrual FX
|
||||
// path never absorbs): a cross-currency match is clamped to the
|
||||
// remaining balance and therefore always settles in full; a
|
||||
// same-currency foreign match settles when the bank amount covers it.
|
||||
isFullyPaid =
|
||||
paymentAmountInvoiceCurrency >= remainingInvoiceCurrency - ORE_TOLERANCE
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -178,10 +178,27 @@ export const POST = withRouteContext(
|
||||
// Actual SEK leaving the bank. Prefer the stored bank figure; if a foreign
|
||||
// transaction has no amount_sek, fall back to the invoice's booked SEK so
|
||||
// the magnitude is right (→ exchangeRateDifference 0, i.e. "no independent
|
||||
// bank figure to reconcile against"). Last resort, with no invoice rate
|
||||
// either, is the raw amount. The FX diff hits 7960/3960 so 2440 clears
|
||||
// cleanly whenever bank-paid SEK genuinely differs from booked SEK.
|
||||
const actualBankSek = bankSekStored ?? bookedSek ?? txAmountAbs
|
||||
// bank figure to reconcile against"). The FX diff hits 7960/3960 so 2440
|
||||
// clears cleanly whenever bank-paid SEK genuinely differs from booked SEK.
|
||||
//
|
||||
// When BOTH are unknown (foreign bank line without amount_sek paying a
|
||||
// foreign invoice without exchange_rate) there is no SEK figure at all.
|
||||
// The old last resort was the raw foreign amount, which is precisely what
|
||||
// the comment above forbids: 19 USD posted as 19 kr on a ~175 kr payment,
|
||||
// and the entry still balances so no trigger catches it. Refuse instead,
|
||||
// same policy as the match_batch_allocate RPC (BATCH_FX_RATE_MISSING) and
|
||||
// toSekOrThrow() in the entry generators. Rejecting here, before any JE or
|
||||
// ledger write, leaves the match fully retryable once the rate is filled in.
|
||||
const actualBankSek = bankSekStored ?? bookedSek
|
||||
if (actualBankSek == null) {
|
||||
return errorResponseFromCode('SI_FX_RATE_MISSING', txLog, {
|
||||
requestId,
|
||||
details: {
|
||||
transaction_currency: transaction.currency,
|
||||
invoice_currency: invoice.currency,
|
||||
},
|
||||
})
|
||||
}
|
||||
const originalBookedSek = bookedSek ?? actualBankSek
|
||||
|
||||
// Positive = gain (AP credited at more SEK than the bank actually paid).
|
||||
@@ -316,6 +333,15 @@ export const POST = withRouteContext(
|
||||
// mark the invoice paid with NO voucher: an unrecoverable half-state:
|
||||
// mark-paid rejects 'paid' invoices and this route rejects linked
|
||||
// transactions, so no flow could ever complete the booking afterwards.
|
||||
// The cash-method builder converts every leg through toSekOrThrow, so a
|
||||
// foreign invoice with no usable rate surfaces here as
|
||||
// SupplierInvoiceFxRateMissingError. Dispatch on its `code` (not
|
||||
// instanceof: the class is routinely vi.mock'ed away) so errorResponse
|
||||
// maps it to the registered 400 "ange fakturans växelkurs" entry instead
|
||||
// of a generic 500. Same code the preview returns for the same row.
|
||||
if ((err as { code?: unknown })?.code === 'SI_FX_RATE_MISSING') {
|
||||
return errorResponse(err, txLog, { requestId })
|
||||
}
|
||||
if (isBookkeepingError(err)) {
|
||||
return errorResponse(err, txLog, { requestId })
|
||||
}
|
||||
|
||||
@@ -75,6 +75,14 @@ describe('POST /api/transactions/bulk-book', () => {
|
||||
})
|
||||
|
||||
it('link path passes through to RPC and returns the success envelope', async () => {
|
||||
// Currency gate tx fetch (hoisted: runs on every path).
|
||||
enqueue({
|
||||
data: [
|
||||
{ id: TX1, amount: 100, currency: 'SEK', description: 'Swish 1', date: '2026-06-05' },
|
||||
{ id: TX2, amount: 200, currency: 'SEK', description: 'Swish 2', date: '2026-06-05' },
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
// RPC returns the link-existing happy path.
|
||||
enqueue({
|
||||
data: {
|
||||
@@ -109,6 +117,14 @@ describe('POST /api/transactions/bulk-book', () => {
|
||||
})
|
||||
|
||||
it('create-new path fetches template, expands per mode, and calls RPC', async () => {
|
||||
// Tx fetch (hoisted for the currency gate): 2 incomes totalling 300.
|
||||
enqueue({
|
||||
data: [
|
||||
{ id: TX1, amount: 100, currency: 'SEK', description: 'Swish 1', date: '2026-06-05' },
|
||||
{ id: TX2, amount: 200, currency: 'SEK', description: 'Swish 2', date: '2026-06-05' },
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
// Template fetch.
|
||||
enqueue({
|
||||
data: {
|
||||
@@ -123,14 +139,6 @@ describe('POST /api/transactions/bulk-book', () => {
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
// Tx fetch: 2 incomes totalling 300.
|
||||
enqueue({
|
||||
data: [
|
||||
{ id: TX1, amount: 100, currency: 'SEK', description: 'Swish 1', date: '2026-06-05' },
|
||||
{ id: TX2, amount: 200, currency: 'SEK', description: 'Swish 2', date: '2026-06-05' },
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
|
||||
// applyTemplate stub: return a balanced 3-line set per call.
|
||||
vi.mocked(applyTemplate).mockImplementation((_lines, total) => [
|
||||
@@ -178,6 +186,14 @@ describe('POST /api/transactions/bulk-book', () => {
|
||||
})
|
||||
|
||||
it('maps RPC structured failure code to errorResponseFromCode', async () => {
|
||||
// Currency gate tx fetch.
|
||||
enqueue({
|
||||
data: [
|
||||
{ id: TX1, amount: 100, currency: 'SEK', description: 'Swish 1', date: '2026-06-05' },
|
||||
{ id: TX2, amount: 200, currency: 'SEK', description: 'Swish 2', date: '2026-06-06' },
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
enqueue({
|
||||
data: { ok: false, code: 'BULK_BOOK_DATE_MISMATCH', details: { expected: '2026-06-05', got: '2026-06-06' } },
|
||||
error: null,
|
||||
@@ -193,3 +209,203 @@ describe('POST /api/transactions/bulk-book', () => {
|
||||
expect(body.error.code).toBe('BULK_BOOK_DATE_MISMATCH')
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Mixed-currency guard (BFL 4 kap 6 §: one redovisningsvaluta). A
|
||||
* samlingsverifikation spanning SEK and EUR has no representable single
|
||||
* belopp, so all three request shapes must refuse it with the SAME code the
|
||||
* MCP twin uses. Before this, only the template branch checked; manual_lines
|
||||
* and existing_journal_entry_id went straight to the RPC, which summed
|
||||
* 100 EUR + 100 SEK into the scalar 200.
|
||||
*/
|
||||
describe('POST /api/transactions/bulk-book: mixed-currency guard', () => {
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
|
||||
const MIXED_TXS = [
|
||||
{ id: TX1, amount: 100, currency: 'SEK', description: 'Swish', date: '2026-06-05' },
|
||||
{ id: TX2, amount: 100, currency: 'EUR', description: 'Stripe', date: '2026-06-05' },
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
|
||||
})
|
||||
|
||||
it('refuses a mixed-currency selection on the template branch', async () => {
|
||||
enqueue({ data: MIXED_TXS, error: null })
|
||||
|
||||
const request = createMockRequest('/api/transactions/bulk-book', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
tx_ids: [TX1, TX2],
|
||||
template_id: TPL,
|
||||
mode: 'sum_per_account',
|
||||
entry_description: 'Samlingsverifikation',
|
||||
},
|
||||
})
|
||||
const response = await POST(request)
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
error: { code: string; details?: { currencies?: string[] } }
|
||||
}>(response)
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('BULK_BOOK_MIXED_CURRENCY')
|
||||
})
|
||||
|
||||
it('refuses a mixed-currency selection on the manual_lines branch', async () => {
|
||||
enqueue({ data: MIXED_TXS, error: null })
|
||||
|
||||
const request = createMockRequest('/api/transactions/bulk-book', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
tx_ids: [TX1, TX2],
|
||||
entry_description: 'Samlingsverifikation',
|
||||
manual_lines: [
|
||||
{ account_number: '1930', debit_amount: 200, credit_amount: 0, currency: 'SEK' },
|
||||
{ account_number: '3001', debit_amount: 0, credit_amount: 200, currency: 'SEK' },
|
||||
],
|
||||
},
|
||||
})
|
||||
const response = await POST(request)
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('BULK_BOOK_MIXED_CURRENCY')
|
||||
// Refused before the RPC: the chart-of-accounts lookup never ran either.
|
||||
expect(mockSupabase.rpc).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses a mixed-currency selection on the existing_journal_entry_id branch', async () => {
|
||||
enqueue({ data: MIXED_TXS, error: null })
|
||||
|
||||
const request = createMockRequest('/api/transactions/bulk-book', {
|
||||
method: 'POST',
|
||||
body: { tx_ids: [TX1, TX2], existing_journal_entry_id: JE },
|
||||
})
|
||||
const response = await POST(request)
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('BULK_BOOK_MIXED_CURRENCY')
|
||||
expect(mockSupabase.rpc).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses a homogeneous non-SEK selection with BULK_BOOK_FOREIGN_CURRENCY', async () => {
|
||||
// Same currency throughout, but not kronor: the RPC would write the
|
||||
// foreign magnitudes into the always-SEK debit/credit columns, so the
|
||||
// route refuses before the RPC just like the mixed-currency case.
|
||||
enqueue({
|
||||
data: [
|
||||
{ id: TX1, amount: 100, currency: 'EUR', description: 'Stripe 1', date: '2026-06-05' },
|
||||
{ id: TX2, amount: 200, currency: 'EUR', description: 'Stripe 2', date: '2026-06-05' },
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
|
||||
const request = createMockRequest('/api/transactions/bulk-book', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
tx_ids: [TX1, TX2],
|
||||
entry_description: 'Samlingsverifikation',
|
||||
manual_lines: [
|
||||
{ account_number: '1930', debit_amount: 300, credit_amount: 0, currency: 'EUR' },
|
||||
{ account_number: '3001', debit_amount: 0, credit_amount: 300, currency: 'EUR' },
|
||||
],
|
||||
},
|
||||
})
|
||||
const response = await POST(request)
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
error: { code: string; details?: { currency?: string } }
|
||||
}>(response)
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('BULK_BOOK_FOREIGN_CURRENCY')
|
||||
expect(body.error.details?.currency).toBe('EUR')
|
||||
expect(mockSupabase.rpc).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('books a single-currency manual_lines selection', async () => {
|
||||
// Tx fetch: both SEK.
|
||||
enqueue({
|
||||
data: [
|
||||
{ id: TX1, amount: 100, currency: 'SEK', description: 'Swish', date: '2026-06-05' },
|
||||
{ id: TX2, amount: 100, currency: 'SEK', description: 'Swish', date: '2026-06-05' },
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
// chart_of_accounts allowlist.
|
||||
enqueue({
|
||||
data: [{ account_number: '1930' }, { account_number: '3001' }],
|
||||
error: null,
|
||||
})
|
||||
// Dimension rules: none configured.
|
||||
enqueue({ data: [], error: null })
|
||||
// RPC happy path.
|
||||
enqueue({
|
||||
data: {
|
||||
ok: true,
|
||||
mode: 'create_new',
|
||||
journal_entry_id: JE,
|
||||
voucher_series: 'A',
|
||||
voucher_number: 14,
|
||||
linked_tx_count: 2,
|
||||
tx_sum: 200,
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
// Event re-fetch.
|
||||
enqueue({ data: [], error: null })
|
||||
|
||||
const request = createMockRequest('/api/transactions/bulk-book', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
tx_ids: [TX1, TX2],
|
||||
entry_description: 'Samlingsverifikation',
|
||||
manual_lines: [
|
||||
{ account_number: '1930', debit_amount: 200, credit_amount: 0, currency: 'SEK' },
|
||||
{ account_number: '3001', debit_amount: 0, credit_amount: 200, currency: 'SEK' },
|
||||
],
|
||||
},
|
||||
})
|
||||
const response = await POST(request)
|
||||
const { status, body } = await parseJsonResponse<{ data: { mode: string } }>(response)
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.mode).toBe('create_new')
|
||||
})
|
||||
|
||||
it('treats NULL currency as SEK and still books', async () => {
|
||||
enqueue({
|
||||
data: [
|
||||
{ id: TX1, amount: 100, currency: null, description: 'Legacy row', date: '2026-06-05' },
|
||||
{ id: TX2, amount: 100, currency: 'SEK', description: 'Swish', date: '2026-06-05' },
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
enqueue({ data: [{ account_number: '1930' }, { account_number: '3001' }], error: null })
|
||||
enqueue({ data: [], error: null })
|
||||
enqueue({
|
||||
data: {
|
||||
ok: true,
|
||||
mode: 'create_new',
|
||||
journal_entry_id: JE,
|
||||
voucher_series: 'A',
|
||||
voucher_number: 15,
|
||||
linked_tx_count: 2,
|
||||
tx_sum: 200,
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
enqueue({ data: [], error: null })
|
||||
|
||||
const request = createMockRequest('/api/transactions/bulk-book', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
tx_ids: [TX1, TX2],
|
||||
entry_description: 'Samlingsverifikation',
|
||||
manual_lines: [
|
||||
{ account_number: '1930', debit_amount: 200, credit_amount: 0, currency: 'SEK' },
|
||||
{ account_number: '3001', debit_amount: 0, credit_amount: 200, currency: 'SEK' },
|
||||
],
|
||||
},
|
||||
})
|
||||
const response = await POST(request)
|
||||
expect(response.status).toBe(200)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -82,6 +82,66 @@ export const POST = withRouteContext(
|
||||
|
||||
const opLog = log.child({ txCount: body.tx_ids.length })
|
||||
|
||||
// Fetch the selected txs ONCE, up front, for every path. The template
|
||||
// branch needs the amounts to expand the template; all three branches
|
||||
// need the currencies for the homogeneity gate below.
|
||||
const { data: txs, error: txError } = await supabase
|
||||
.from('transactions')
|
||||
.select('id, amount, currency, description, date')
|
||||
.in('id', body.tx_ids)
|
||||
.eq('company_id', companyId)
|
||||
|
||||
if (txError || !txs || txs.length === 0) {
|
||||
return errorResponseFromCode('BULK_BOOK_TXS_NOT_FOUND', opLog, { requestId })
|
||||
}
|
||||
if (txs.length !== body.tx_ids.length) {
|
||||
return errorResponseFromCode('BULK_BOOK_TXS_NOT_FOUND', opLog, {
|
||||
requestId,
|
||||
details: { expected: body.tx_ids.length, found: txs.length },
|
||||
})
|
||||
}
|
||||
|
||||
const txTyped = txs as Pick<Transaction, 'id' | 'amount' | 'currency' | 'description' | 'date'>[]
|
||||
|
||||
// Currency homogeneity, enforced BEFORE the branch split so it covers
|
||||
// all three paths (template, manual_lines, existing_journal_entry_id).
|
||||
// BFL 4 kap 6 § requires the bokföring to be presented in one and the
|
||||
// same redovisningsvaluta. A samlingsverifikation mixing e.g. SEK and
|
||||
// EUR has no representable single belopp: summing the raw amounts adds
|
||||
// 100 EUR to 100 SEK as if they were one unit, and the verifikat would
|
||||
// then state an amount matching no affärshändelse (BFL 5 kap 7 §
|
||||
// "belopp"). Nothing the caller can pass makes that correct without
|
||||
// per-tx FX rates, so this refuses instead of warning. Mirrors the MCP
|
||||
// twin (gnubok_bulk_book_transactions), which guards the same three
|
||||
// paths; cross-currency batches belong in the FX-aware batch-allocate
|
||||
// flow (kursdifferens on 7960/3960).
|
||||
// NULL currency is legacy for the column default 'SEK' (the codebase
|
||||
// reads it that way everywhere), so it is normalized before comparison:
|
||||
// a NULL/SEK selection is not a currency mix and must stay bookable.
|
||||
const currencies = new Set(txTyped.map((t) => t.currency ?? 'SEK'))
|
||||
if (currencies.size > 1) {
|
||||
return errorResponseFromCode('BULK_BOOK_MIXED_CURRENCY', opLog, {
|
||||
requestId,
|
||||
details: { currencies: Array.from(currencies).sort() },
|
||||
})
|
||||
}
|
||||
const currency = txTyped[0]!.currency ?? 'SEK'
|
||||
|
||||
// A HOMOGENEOUS foreign batch is refused too: the RPC writes the line
|
||||
// amounts into journal_entry_lines.debit_amount/credit_amount, which are
|
||||
// ALWAYS kronor, and neither the route nor the RPC carries an exchange
|
||||
// rate here. Two EUR transactions of 100 + 200 would produce a verifikat
|
||||
// whose 300 is read as kronor by balansräkning, momsdeklaration and SIE
|
||||
// export. Foreign-currency transactions are booked individually through
|
||||
// the FX-aware flows, which resolve a rate and book the kursdifferens.
|
||||
// The RPC enforces the same refusal for callers that bypass this route.
|
||||
if (currency !== 'SEK') {
|
||||
return errorResponseFromCode('BULK_BOOK_FOREIGN_CURRENCY', opLog, {
|
||||
requestId,
|
||||
details: { currency },
|
||||
})
|
||||
}
|
||||
|
||||
// Three paths now (PR #608):
|
||||
// 1. existing_journal_entry_id → null new_entry, RPC links txs to JE.
|
||||
// 2. template_id → route expands template per mode, builds lines.
|
||||
@@ -156,40 +216,9 @@ export const POST = withRouteContext(
|
||||
|
||||
const templateLines = (template.lines ?? []) as BookingTemplateLibraryLine[]
|
||||
|
||||
// Need each tx's amount + currency to expand per mode. The RPC also
|
||||
// re-validates (date, direction, not-already-booked) but we need the
|
||||
// amount sum to drive the template expansion.
|
||||
const { data: txs, error: txError } = await supabase
|
||||
.from('transactions')
|
||||
.select('id, amount, currency, description, date')
|
||||
.in('id', body.tx_ids)
|
||||
.eq('company_id', companyId)
|
||||
|
||||
if (txError || !txs || txs.length === 0) {
|
||||
return errorResponseFromCode('BULK_BOOK_TXS_NOT_FOUND', opLog, { requestId })
|
||||
}
|
||||
if (txs.length !== body.tx_ids.length) {
|
||||
return errorResponseFromCode('BULK_BOOK_TXS_NOT_FOUND', opLog, {
|
||||
requestId,
|
||||
details: { expected: body.tx_ids.length, found: txs.length },
|
||||
})
|
||||
}
|
||||
|
||||
const txTyped = txs as Pick<Transaction, 'id' | 'amount' | 'currency' | 'description' | 'date'>[]
|
||||
|
||||
// Same-currency invariant for v1. Mixed-currency batches would need
|
||||
// FX conversion per tx; out of scope. Use the dedicated
|
||||
// BULK_BOOK_MIXED_CURRENCY code so the toast doesn't blame direction
|
||||
// (PR #606 review fix).
|
||||
const currencies = new Set(txTyped.map((t) => t.currency))
|
||||
if (currencies.size > 1) {
|
||||
return errorResponseFromCode('BULK_BOOK_MIXED_CURRENCY', opLog, {
|
||||
requestId,
|
||||
details: { currencies: Array.from(currencies) },
|
||||
})
|
||||
}
|
||||
const currency = txTyped[0]!.currency
|
||||
|
||||
// The tx rows (amount + currency) were fetched and currency-gated
|
||||
// above; the RPC still re-validates date, direction, and
|
||||
// not-already-booked.
|
||||
const txAbsAmounts = txTyped.map((t) => Math.abs(t.amount))
|
||||
const totalAbs = round2(txAbsAmounts.reduce((s, a) => s + a, 0))
|
||||
|
||||
|
||||
@@ -0,0 +1,641 @@
|
||||
/**
|
||||
* Cursor-pagination contract for every v1 list endpoint that pages with the
|
||||
* default (created_at, id) keyset cursor.
|
||||
*
|
||||
* Regression lock for the P0 where invoices / journal-entries /
|
||||
* supplier-invoices encoded the cursor on a Postgres `date` column
|
||||
* (invoice_date / entry_date). PostgREST serializes `date` as "2026-07-25",
|
||||
* `decodeDefaultCursor` rejects anything that is not a full ISO-8601
|
||||
* timestamp, so the keyset predicate was never applied: page 2 returned
|
||||
* page 1, forever, while still advertising a fresh next_cursor. An
|
||||
* integrator syncing verifikat looped on the newest rows indefinitely.
|
||||
*
|
||||
* The mock here is NOT the usual pass-through Proxy: it is a small in-memory
|
||||
* PostgREST that actually evaluates .eq/.neq/.gte/.lte/.or/.order/.limit.
|
||||
* A pass-through mock cannot catch this bug at all, because the bug is that
|
||||
* the filter is never sent. Anything the routes emit that the mini-parser
|
||||
* does not understand throws, so the suite fails loudly instead of passing
|
||||
* vacuously.
|
||||
*/
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
beforeAll(() => {
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
throw new Error(
|
||||
`cursor pagination tests require NODE_ENV=test (got ${process.env.NODE_ENV ?? 'undefined'})`,
|
||||
)
|
||||
}
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321'
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key'
|
||||
})
|
||||
|
||||
vi.mock('@/lib/auth/api-keys', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/auth/api-keys')>('@/lib/auth/api-keys')
|
||||
return {
|
||||
...actual,
|
||||
validateApiKey: vi.fn(),
|
||||
createServiceClientNoCookies: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@supabase/supabase-js', async () => {
|
||||
const actual = await vi.importActual<typeof import('@supabase/supabase-js')>('@supabase/supabase-js')
|
||||
return { ...actual, createClient: vi.fn().mockReturnValue({}) }
|
||||
})
|
||||
|
||||
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
|
||||
import { decodeDefaultCursor } from '@/lib/api/v1/pagination'
|
||||
import { GET as listInvoices } from '../invoices/route'
|
||||
import { GET as listJournalEntries } from '../journal-entries/route'
|
||||
import { GET as listSupplierInvoices } from '../supplier-invoices/route'
|
||||
import { GET as listTransactions } from '../transactions/route'
|
||||
|
||||
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
|
||||
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// In-memory PostgREST
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type Row = Record<string, unknown>
|
||||
|
||||
/**
|
||||
* String comparison, which matches Postgres ordering for the two column
|
||||
* kinds this harness sorts on: same-format ISO-8601 timestamps (lexical
|
||||
* order == chronological order) and lowercase-hex UUIDs.
|
||||
*/
|
||||
function cmp(a: unknown, b: unknown): number {
|
||||
const left = a === null || a === undefined ? '' : String(a)
|
||||
const right = b === null || b === undefined ? '' : String(b)
|
||||
return left < right ? -1 : left > right ? 1 : 0
|
||||
}
|
||||
|
||||
/** Split a PostgREST filter list on commas that are not inside and(...)/or(...). */
|
||||
function splitTopLevel(expression: string): string[] {
|
||||
const parts: string[] = []
|
||||
let depth = 0
|
||||
let current = ''
|
||||
for (const ch of expression) {
|
||||
if (ch === '(') depth++
|
||||
if (ch === ')') depth--
|
||||
if (ch === ',' && depth === 0) {
|
||||
parts.push(current)
|
||||
current = ''
|
||||
continue
|
||||
}
|
||||
current += ch
|
||||
}
|
||||
if (current) parts.push(current)
|
||||
return parts
|
||||
}
|
||||
|
||||
/** Evaluate a single `column.operator.value` term (or a nested and(...) group). */
|
||||
function matchesTerm(row: Row, term: string): boolean {
|
||||
if (term.startsWith('and(') && term.endsWith(')')) {
|
||||
return splitTopLevel(term.slice(4, -1)).every((sub) => matchesTerm(row, sub))
|
||||
}
|
||||
const firstDot = term.indexOf('.')
|
||||
const secondDot = term.indexOf('.', firstDot + 1)
|
||||
if (firstDot === -1 || secondDot === -1) {
|
||||
throw new Error(`in-memory PostgREST: unsupported filter term "${term}"`)
|
||||
}
|
||||
const column = term.slice(0, firstDot)
|
||||
const operator = term.slice(firstDot + 1, secondDot)
|
||||
const value = term.slice(secondDot + 1)
|
||||
const delta = cmp(row[column], value)
|
||||
switch (operator) {
|
||||
case 'lt':
|
||||
return delta < 0
|
||||
case 'lte':
|
||||
return delta <= 0
|
||||
case 'gt':
|
||||
return delta > 0
|
||||
case 'gte':
|
||||
return delta >= 0
|
||||
case 'eq':
|
||||
return delta === 0
|
||||
case 'neq':
|
||||
return delta !== 0
|
||||
default:
|
||||
throw new Error(`in-memory PostgREST: unsupported operator "${operator}" in "${term}"`)
|
||||
}
|
||||
}
|
||||
|
||||
function makeKeysetSupabase(tables: Record<string, Row[]>) {
|
||||
const from = vi.fn((table: string) => {
|
||||
let rows = [...(tables[table] ?? [])]
|
||||
const orders: Array<{ column: string; ascending: boolean }> = []
|
||||
let limitValue: number | null = null
|
||||
let singleRow = false
|
||||
|
||||
const builder = {
|
||||
select: () => builder,
|
||||
eq: (column: string, value: unknown) => {
|
||||
rows = rows.filter((r) => cmp(r[column], value) === 0)
|
||||
return builder
|
||||
},
|
||||
neq: (column: string, value: unknown) => {
|
||||
rows = rows.filter((r) => cmp(r[column], value) !== 0)
|
||||
return builder
|
||||
},
|
||||
gte: (column: string, value: unknown) => {
|
||||
rows = rows.filter((r) => cmp(r[column], value) >= 0)
|
||||
return builder
|
||||
},
|
||||
lte: (column: string, value: unknown) => {
|
||||
rows = rows.filter((r) => cmp(r[column], value) <= 0)
|
||||
return builder
|
||||
},
|
||||
is: (column: string, value: unknown) => {
|
||||
rows = rows.filter((r) => (r[column] ?? null) === value)
|
||||
return builder
|
||||
},
|
||||
not: (column: string, operator: string, value: unknown) => {
|
||||
if (operator !== 'is') {
|
||||
throw new Error(`in-memory PostgREST: unsupported not() operator "${operator}"`)
|
||||
}
|
||||
rows = rows.filter((r) => (r[column] ?? null) !== value)
|
||||
return builder
|
||||
},
|
||||
or: (expression: string) => {
|
||||
const terms = splitTopLevel(expression)
|
||||
rows = rows.filter((r) => terms.some((term) => matchesTerm(r, term)))
|
||||
return builder
|
||||
},
|
||||
order: (column: string, options?: { ascending?: boolean }) => {
|
||||
orders.push({ column, ascending: options?.ascending !== false })
|
||||
return builder
|
||||
},
|
||||
limit: (count: number) => {
|
||||
limitValue = count
|
||||
return builder
|
||||
},
|
||||
maybeSingle: () => {
|
||||
singleRow = true
|
||||
return builder
|
||||
},
|
||||
single: () => {
|
||||
singleRow = true
|
||||
return builder
|
||||
},
|
||||
then: (resolve: (value: unknown) => void) => {
|
||||
const sorted = [...rows].sort((a, b) => {
|
||||
for (const o of orders) {
|
||||
const delta = cmp(a[o.column], b[o.column])
|
||||
if (delta !== 0) return o.ascending ? delta : -delta
|
||||
}
|
||||
return 0
|
||||
})
|
||||
const sliced = limitValue === null ? sorted : sorted.slice(0, limitValue)
|
||||
resolve(singleRow ? { data: sliced[0] ?? null, error: null } : { data: sliced, error: null })
|
||||
},
|
||||
}
|
||||
return builder
|
||||
})
|
||||
|
||||
return { from, rpc: vi.fn(async () => ({ data: null, error: null })) }
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// Fixtures
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
|
||||
const CUSTOMER_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'
|
||||
const SUPPLIER_ID = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd'
|
||||
const FISCAL_PERIOD_ID = 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee'
|
||||
const USER_ID = 'user-1'
|
||||
|
||||
// Ascending lexical order, which is also the id tie-break order.
|
||||
const IDS = [
|
||||
'11111111-1111-4111-8111-111111111111',
|
||||
'22222222-2222-4222-8222-222222222222',
|
||||
'33333333-3333-4333-8333-333333333333',
|
||||
'44444444-4444-4444-8444-444444444444',
|
||||
'55555555-5555-4555-8555-555555555555',
|
||||
'66666666-6666-4666-8666-666666666666',
|
||||
'77777777-7777-4777-8777-777777777777',
|
||||
]
|
||||
|
||||
// Rows 3 and 4 deliberately share a created_at so the id tie-break is
|
||||
// exercised at a page boundary (limit=2 splits the pair across pages 2/3).
|
||||
const CREATED_AT = [
|
||||
'2026-07-25T10:00:06.000Z',
|
||||
'2026-07-25T10:00:05.000Z',
|
||||
'2026-07-25T10:00:04.000Z',
|
||||
'2026-07-25T10:00:03.000Z',
|
||||
'2026-07-25T10:00:03.000Z',
|
||||
'2026-07-25T10:00:02.000Z',
|
||||
'2026-07-25T10:00:01.000Z',
|
||||
]
|
||||
|
||||
// Every row carries the SAME business date: this is the bulk-import shape
|
||||
// that made the old date-anchored cursor loop forever.
|
||||
const BUSINESS_DATE = '2026-07-25'
|
||||
|
||||
/** Expected read order under (created_at DESC, id ASC). */
|
||||
const EXPECTED_ORDER = IDS
|
||||
|
||||
const MEMBERSHIP = [{ user_id: USER_ID, company_id: COMPANY_ID, role: 'owner' }]
|
||||
|
||||
function invoiceRows(): Row[] {
|
||||
return IDS.map((id, i) => ({
|
||||
id,
|
||||
company_id: COMPANY_ID,
|
||||
invoice_number: `F-10${i}`,
|
||||
customer_id: CUSTOMER_ID,
|
||||
invoice_date: BUSINESS_DATE,
|
||||
due_date: '2026-08-24',
|
||||
status: 'sent',
|
||||
document_type: 'invoice',
|
||||
currency: 'SEK',
|
||||
subtotal: 1000,
|
||||
vat_amount: 250,
|
||||
total: 1250,
|
||||
remaining_amount: 1250,
|
||||
paid_at: null,
|
||||
created_at: CREATED_AT[i],
|
||||
customer: { id: CUSTOMER_ID, name: 'Acme AB' },
|
||||
}))
|
||||
}
|
||||
|
||||
function journalEntryRows(): Row[] {
|
||||
return IDS.map((id, i) => ({
|
||||
id,
|
||||
company_id: COMPANY_ID,
|
||||
fiscal_period_id: FISCAL_PERIOD_ID,
|
||||
voucher_series: 'A',
|
||||
voucher_number: 100 + i,
|
||||
entry_date: BUSINESS_DATE,
|
||||
description: `Verifikat ${100 + i}`,
|
||||
status: 'posted',
|
||||
source_type: 'manual',
|
||||
source_id: null,
|
||||
notes: null,
|
||||
reverses_id: null,
|
||||
reversed_by_id: null,
|
||||
correction_of_id: null,
|
||||
created_at: CREATED_AT[i],
|
||||
updated_at: CREATED_AT[i],
|
||||
}))
|
||||
}
|
||||
|
||||
function supplierInvoiceRows(): Row[] {
|
||||
return IDS.map((id, i) => ({
|
||||
id,
|
||||
company_id: COMPANY_ID,
|
||||
supplier_id: SUPPLIER_ID,
|
||||
arrival_number: 40 + i,
|
||||
supplier_invoice_number: `2026-12${i}`,
|
||||
invoice_date: BUSINESS_DATE,
|
||||
due_date: '2026-08-24',
|
||||
status: 'registered',
|
||||
currency: 'SEK',
|
||||
subtotal: 1000,
|
||||
vat_amount: 250,
|
||||
total: 1250,
|
||||
paid_amount: 0,
|
||||
remaining_amount: 1250,
|
||||
is_credit_note: false,
|
||||
paid_at: null,
|
||||
created_at: CREATED_AT[i],
|
||||
supplier: { id: SUPPLIER_ID, name: 'Office Depot AB' },
|
||||
}))
|
||||
}
|
||||
|
||||
function transactionRows(): Row[] {
|
||||
return IDS.map((id, i) => ({
|
||||
id,
|
||||
company_id: COMPANY_ID,
|
||||
date: BUSINESS_DATE,
|
||||
description: `Kortköp ${i}`,
|
||||
amount: -100 - i,
|
||||
currency: 'SEK',
|
||||
reference: null,
|
||||
merchant_name: null,
|
||||
journal_entry_id: null,
|
||||
invoice_id: null,
|
||||
supplier_invoice_id: null,
|
||||
is_business: null,
|
||||
category: null,
|
||||
import_source: 'csv',
|
||||
created_at: CREATED_AT[i],
|
||||
}))
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// Endpoint table
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type ListHandler = (
|
||||
request: Request,
|
||||
params: { params: Promise<{ companyId: string }> },
|
||||
) => Promise<Response>
|
||||
|
||||
interface ListEndpointCase {
|
||||
name: string
|
||||
segment: string
|
||||
table: string
|
||||
scope: string
|
||||
handler: ListHandler
|
||||
rows: () => Row[]
|
||||
/** A query string that must fail validation with 400. */
|
||||
invalidQuery: string
|
||||
}
|
||||
|
||||
const ENDPOINTS: ListEndpointCase[] = [
|
||||
{
|
||||
name: 'invoices',
|
||||
segment: 'invoices',
|
||||
table: 'invoices',
|
||||
scope: 'invoices:read',
|
||||
handler: listInvoices as ListHandler,
|
||||
rows: invoiceRows,
|
||||
invalidQuery: 'status=quantum',
|
||||
},
|
||||
{
|
||||
name: 'journal-entries',
|
||||
segment: 'journal-entries',
|
||||
table: 'journal_entries',
|
||||
scope: 'reports:read',
|
||||
handler: listJournalEntries as ListHandler,
|
||||
rows: journalEntryRows,
|
||||
invalidQuery: 'status=quantum',
|
||||
},
|
||||
{
|
||||
name: 'supplier-invoices',
|
||||
segment: 'supplier-invoices',
|
||||
table: 'supplier_invoices',
|
||||
scope: 'suppliers:read',
|
||||
handler: listSupplierInvoices as ListHandler,
|
||||
rows: supplierInvoiceRows,
|
||||
invalidQuery: 'date_from=2026/07/25',
|
||||
},
|
||||
{
|
||||
name: 'transactions',
|
||||
segment: 'transactions',
|
||||
table: 'transactions',
|
||||
scope: 'transactions:read',
|
||||
handler: listTransactions as ListHandler,
|
||||
rows: transactionRows,
|
||||
invalidQuery: 'status=unknown',
|
||||
},
|
||||
]
|
||||
|
||||
const ALL_SCOPES = ENDPOINTS.map((e) => e.scope)
|
||||
|
||||
function makeRequest(url: string, withAuth = true): Request {
|
||||
return new Request(url, {
|
||||
method: 'GET',
|
||||
headers: withAuth ? { Authorization: 'Bearer test-fixture-not-a-real-key' } : {},
|
||||
})
|
||||
}
|
||||
|
||||
function companyParams(companyId: string) {
|
||||
return { params: Promise.resolve({ companyId }) }
|
||||
}
|
||||
|
||||
interface Page {
|
||||
ids: string[]
|
||||
nextCursor: string | undefined
|
||||
}
|
||||
|
||||
async function fetchPage(
|
||||
endpoint: ListEndpointCase,
|
||||
query: string,
|
||||
): Promise<{ status: number; page: Page; body: { data: Array<{ id: string }>; meta: { next_cursor?: string } } }> {
|
||||
const res = await endpoint.handler(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/${endpoint.segment}?${query}`),
|
||||
companyParams(COMPANY_ID),
|
||||
)
|
||||
const body = await res.json()
|
||||
return {
|
||||
status: res.status,
|
||||
body,
|
||||
page: {
|
||||
ids: (body.data ?? []).map((row: { id: string }) => row.id),
|
||||
nextCursor: body.meta?.next_cursor,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockValidate.mockResolvedValue({
|
||||
userId: USER_ID,
|
||||
companyId: COMPANY_ID,
|
||||
apiKeyId: 'ak_1',
|
||||
apiKeyName: 'CI key',
|
||||
scopes: ALL_SCOPES,
|
||||
mode: 'live',
|
||||
})
|
||||
})
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// The decisive tests
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe.each(ENDPOINTS)('GET /api/v1/companies/:companyId/$name cursor pagination', (endpoint) => {
|
||||
function mountRows() {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeKeysetSupabase({
|
||||
company_members: MEMBERSHIP,
|
||||
[endpoint.table]: endpoint.rows(),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
it('page 2 shares zero ids with page 1', async () => {
|
||||
mountRows()
|
||||
const first = await fetchPage(endpoint, 'limit=2')
|
||||
expect(first.status).toBe(200)
|
||||
expect(first.page.ids).toHaveLength(2)
|
||||
expect(first.page.nextCursor).toBeTruthy()
|
||||
|
||||
const second = await fetchPage(endpoint, `limit=2&cursor=${encodeURIComponent(first.page.nextCursor!)}`)
|
||||
expect(second.status).toBe(200)
|
||||
expect(second.page.ids).toHaveLength(2)
|
||||
|
||||
const overlap = second.page.ids.filter((id) => first.page.ids.includes(id))
|
||||
expect(overlap).toEqual([])
|
||||
})
|
||||
|
||||
it('the emitted cursor is a decodable (timestamp, uuid) pair', async () => {
|
||||
mountRows()
|
||||
const first = await fetchPage(endpoint, 'limit=2')
|
||||
// The whole bug was an encoder/decoder mismatch: a cursor that the
|
||||
// route's own decoder throws away silently disables the keyset.
|
||||
const decoded = decodeDefaultCursor(first.page.nextCursor!)
|
||||
expect(decoded).not.toBeNull()
|
||||
expect(decoded!.id).toBe(first.page.ids[first.page.ids.length - 1])
|
||||
})
|
||||
|
||||
it('walks every row exactly once and terminates', async () => {
|
||||
mountRows()
|
||||
const seen: string[] = []
|
||||
let cursor: string | undefined
|
||||
let pages = 0
|
||||
|
||||
// Hard stop well above the 4 pages this fixture needs: an unterminated
|
||||
// walk fails the assertion below instead of hanging CI.
|
||||
while (pages < 20) {
|
||||
pages++
|
||||
const query = cursor ? `limit=2&cursor=${encodeURIComponent(cursor)}` : 'limit=2'
|
||||
const { status, page } = await fetchPage(endpoint, query)
|
||||
expect(status).toBe(200)
|
||||
seen.push(...page.ids)
|
||||
if (!page.nextCursor) break
|
||||
cursor = page.nextCursor
|
||||
}
|
||||
|
||||
expect(pages).toBe(4) // 7 rows at limit=2
|
||||
expect(seen).toEqual(EXPECTED_ORDER)
|
||||
expect(new Set(seen).size).toBe(EXPECTED_ORDER.length)
|
||||
})
|
||||
|
||||
it('breaks created_at ties on id so a tied pair is not re-served', async () => {
|
||||
mountRows()
|
||||
// Rows 3 and 4 share a created_at. Page 2 ends on row 3; page 3 must
|
||||
// start on row 4 rather than repeating the tied timestamp block.
|
||||
const first = await fetchPage(endpoint, 'limit=2')
|
||||
const second = await fetchPage(endpoint, `limit=2&cursor=${encodeURIComponent(first.page.nextCursor!)}`)
|
||||
expect(second.page.ids).toEqual([IDS[2], IDS[3]])
|
||||
const third = await fetchPage(endpoint, `limit=2&cursor=${encodeURIComponent(second.page.nextCursor!)}`)
|
||||
expect(third.page.ids).toEqual([IDS[4], IDS[5]])
|
||||
})
|
||||
|
||||
it('does not advertise a next_cursor on the final page', async () => {
|
||||
mountRows()
|
||||
const { page } = await fetchPage(endpoint, 'limit=100')
|
||||
expect(page.ids).toEqual(EXPECTED_ORDER)
|
||||
expect(page.nextCursor).toBeUndefined()
|
||||
})
|
||||
|
||||
it('fails soft on a tampered cursor: restarts at page 1 with 200', async () => {
|
||||
// pagination.ts documents the contract at its ISO_TIMESTAMP guard: a
|
||||
// stale or corrupt cursor is discarded and treated as "start from the
|
||||
// beginning", never a 400.
|
||||
const garbage = [
|
||||
'not-base64-or-json',
|
||||
Buffer.from(JSON.stringify({ ts: '2026-07-25', id: IDS[0] })).toString('base64url'),
|
||||
Buffer.from(JSON.stringify({ ts: '2026-07-25T10:00:03.000Z', id: 'not-a-uuid' })).toString('base64url'),
|
||||
Buffer.from(JSON.stringify({ ts: "'); drop table api_keys; --", id: IDS[0] })).toString('base64url'),
|
||||
]
|
||||
|
||||
for (const cursor of garbage) {
|
||||
mountRows()
|
||||
const { status, page } = await fetchPage(endpoint, `limit=2&cursor=${encodeURIComponent(cursor)}`)
|
||||
expect(status).toBe(200)
|
||||
expect(page.ids).toEqual([IDS[0], IDS[1]])
|
||||
}
|
||||
})
|
||||
|
||||
it('orders by created_at, not by the business date column', async () => {
|
||||
// Every fixture row shares the same invoice_date / entry_date / date.
|
||||
// A date-anchored sort cannot produce a stable total order here, which
|
||||
// is exactly how the P0 manifested in production.
|
||||
mountRows()
|
||||
const { body } = await fetchPage(endpoint, 'limit=100')
|
||||
const timestamps = body.data.map((row) => (row as unknown as { created_at: string }).created_at)
|
||||
const descending = [...timestamps].sort().reverse()
|
||||
expect(timestamps).toEqual(descending)
|
||||
})
|
||||
})
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// Standard route guards
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe.each(ENDPOINTS)('GET /api/v1/companies/:companyId/$name guards', (endpoint) => {
|
||||
it('returns 401 UNAUTHORIZED without a bearer token', async () => {
|
||||
mockServiceClient.mockReturnValue(makeKeysetSupabase({}))
|
||||
const res = await endpoint.handler(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/${endpoint.segment}`, false),
|
||||
companyParams(COMPANY_ID),
|
||||
)
|
||||
expect(res.status).toBe(401)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('UNAUTHORIZED')
|
||||
})
|
||||
|
||||
it('returns 400 VALIDATION_ERROR on a malformed filter', async () => {
|
||||
mockServiceClient.mockReturnValue(makeKeysetSupabase({ company_members: MEMBERSHIP }))
|
||||
const { status, body } = await fetchPage(endpoint, endpoint.invalidQuery)
|
||||
expect(status).toBe(400)
|
||||
expect((body as unknown as { error: { code: string } }).error.code).toBe('VALIDATION_ERROR')
|
||||
})
|
||||
|
||||
it('returns 404 NOT_FOUND when the key user is not a member of the company', async () => {
|
||||
// No company_members row: the wrapper 404s rather than leaking existence.
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeKeysetSupabase({ company_members: [], [endpoint.table]: endpoint.rows() }),
|
||||
)
|
||||
const { status, body } = await fetchPage(endpoint, 'limit=2')
|
||||
expect(status).toBe(404)
|
||||
expect((body as unknown as { error: { code: string } }).error.code).toBe('NOT_FOUND')
|
||||
})
|
||||
|
||||
it('returns 403 INSUFFICIENT_SCOPE when the key lacks the read scope', async () => {
|
||||
mockValidate.mockResolvedValue({
|
||||
userId: USER_ID,
|
||||
companyId: COMPANY_ID,
|
||||
apiKeyId: 'ak_1',
|
||||
scopes: ALL_SCOPES.filter((s) => s !== endpoint.scope),
|
||||
mode: 'live',
|
||||
})
|
||||
mockServiceClient.mockReturnValue(makeKeysetSupabase({ company_members: MEMBERSHIP }))
|
||||
const { status, body } = await fetchPage(endpoint, 'limit=2')
|
||||
expect(status).toBe(403)
|
||||
expect((body as unknown as { error: { code: string } }).error.code).toBe('INSUFFICIENT_SCOPE')
|
||||
})
|
||||
})
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// Business-date filters stay available now that the sort key moved
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('business-date filters', () => {
|
||||
it('invoices: ?date_from / ?date_to narrow on invoice_date', async () => {
|
||||
const rows = invoiceRows()
|
||||
rows[0].invoice_date = '2026-01-15'
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeKeysetSupabase({ company_members: MEMBERSHIP, invoices: rows }),
|
||||
)
|
||||
const { status, page } = await fetchPage(ENDPOINTS[0], 'limit=100&date_from=2026-07-01&date_to=2026-07-31')
|
||||
expect(status).toBe(200)
|
||||
expect(page.ids).toEqual(EXPECTED_ORDER.slice(1))
|
||||
})
|
||||
|
||||
it('journal-entries: ?date_from / ?date_to narrow on entry_date', async () => {
|
||||
const rows = journalEntryRows()
|
||||
rows[0].entry_date = '2026-01-15'
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeKeysetSupabase({ company_members: MEMBERSHIP, journal_entries: rows }),
|
||||
)
|
||||
const { status, page } = await fetchPage(ENDPOINTS[1], 'limit=100&date_from=2026-07-01&date_to=2026-07-31')
|
||||
expect(status).toBe(200)
|
||||
expect(page.ids).toEqual(EXPECTED_ORDER.slice(1))
|
||||
})
|
||||
|
||||
it('supplier-invoices: ?date_from / ?date_to narrow on invoice_date', async () => {
|
||||
const rows = supplierInvoiceRows()
|
||||
rows[0].invoice_date = '2026-01-15'
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeKeysetSupabase({ company_members: MEMBERSHIP, supplier_invoices: rows }),
|
||||
)
|
||||
const { status, page } = await fetchPage(ENDPOINTS[2], 'limit=100&date_from=2026-07-01&date_to=2026-07-31')
|
||||
expect(status).toBe(200)
|
||||
expect(page.ids).toEqual(EXPECTED_ORDER.slice(1))
|
||||
})
|
||||
|
||||
it('transactions: ?date_from / ?date_to narrow on date', async () => {
|
||||
const rows = transactionRows()
|
||||
rows[0].date = '2026-01-15'
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeKeysetSupabase({ company_members: MEMBERSHIP, transactions: rows }),
|
||||
)
|
||||
const { status, page } = await fetchPage(ENDPOINTS[3], 'limit=100&date_from=2026-07-01&date_to=2026-07-31')
|
||||
expect(status).toBe(200)
|
||||
expect(page.ids).toEqual(EXPECTED_ORDER.slice(1))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* Integration tests for GET /api/v1/companies/:companyId/compliance/check.
|
||||
*
|
||||
* Focus: the voucher_gaps runner must check EVERY voucher series registered
|
||||
* for the period (BFNAR 2013:2 kap 8 §: the verifikationsnummerserie must be
|
||||
* unbroken per series, and a gap must be documented). Checking only series
|
||||
* 'A' reports "clean" books that are not clean.
|
||||
*/
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
beforeAll(() => {
|
||||
if (process.env.NODE_ENV !== 'test') throw new Error('NODE_ENV=test required')
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321'
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key'
|
||||
})
|
||||
|
||||
vi.mock('@/lib/auth/api-keys', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/auth/api-keys')>('@/lib/auth/api-keys')
|
||||
return { ...actual, validateApiKey: vi.fn(), createServiceClientNoCookies: vi.fn() }
|
||||
})
|
||||
vi.mock('@supabase/supabase-js', async () => {
|
||||
const actual = await vi.importActual<typeof import('@supabase/supabase-js')>('@supabase/supabase-js')
|
||||
return { ...actual, createClient: vi.fn().mockReturnValue({}) }
|
||||
})
|
||||
|
||||
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
|
||||
import { GET as complianceCheck } from '../route'
|
||||
|
||||
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
|
||||
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
|
||||
|
||||
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
|
||||
const PERIOD_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
|
||||
|
||||
type MockResult = { data?: unknown; error?: unknown }
|
||||
type Gap = { gap_start: number; gap_end: number }
|
||||
|
||||
/** Per-table result queues + a `detect_voucher_gaps` stub keyed by p_series. */
|
||||
function makeSupabase(opts: {
|
||||
tables?: Record<string, MockResult>
|
||||
gapsBySeries?: Record<string, Gap[]>
|
||||
rpcError?: unknown
|
||||
}) {
|
||||
const tables = opts.tables ?? {}
|
||||
const buildChain = (table: string): unknown => {
|
||||
const handler: ProxyHandler<object> = {
|
||||
get(_target, prop) {
|
||||
if (prop === 'then') {
|
||||
return (resolve: (v: unknown) => void) =>
|
||||
resolve(tables[table] ?? { data: null, error: null })
|
||||
}
|
||||
return (..._args: unknown[]) => buildChain(table)
|
||||
},
|
||||
}
|
||||
return new Proxy({}, handler)
|
||||
}
|
||||
const rpc = vi.fn(async (_name: string, args: Record<string, unknown>) => {
|
||||
if (opts.rpcError) return { data: null, error: opts.rpcError }
|
||||
const series = args.p_series as string
|
||||
return { data: opts.gapsBySeries?.[series] ?? [], error: null }
|
||||
})
|
||||
return { from: vi.fn((table: string) => buildChain(table)), rpc }
|
||||
}
|
||||
|
||||
const MEMBERSHIP: MockResult = { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }
|
||||
const PERIOD: MockResult = { data: { id: PERIOD_ID }, error: null }
|
||||
|
||||
function makeRequest(query: string, opts: { auth?: boolean } = {}): Request {
|
||||
return new Request(`https://x.test/api/v1/companies/${COMPANY_ID}/compliance/check${query}`, {
|
||||
method: 'GET',
|
||||
headers: opts.auth === false ? {} : { Authorization: 'Bearer test-fixture-not-a-real-key' },
|
||||
})
|
||||
}
|
||||
|
||||
function call(request: Request) {
|
||||
return complianceCheck(request, { params: Promise.resolve({ companyId: COMPANY_ID }) })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockValidate.mockResolvedValue({
|
||||
userId: 'user-1',
|
||||
companyId: COMPANY_ID,
|
||||
apiKeyId: 'ak_1',
|
||||
scopes: ['compliance:read'],
|
||||
mode: 'live',
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /api/v1/companies/:companyId/compliance/check', () => {
|
||||
it('returns 401 without a bearer token', async () => {
|
||||
mockServiceClient.mockReturnValue(makeSupabase({}))
|
||||
const res = await call(makeRequest(`?type=voucher_gaps&fiscal_period_id=${PERIOD_ID}`, { auth: false }))
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 400 for an unsupported check type', async () => {
|
||||
mockServiceClient.mockReturnValue(makeSupabase({ tables: { company_members: MEMBERSHIP } }))
|
||||
const res = await call(makeRequest('?type=not_a_check'))
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 400 when voucher_gaps is called without fiscal_period_id', async () => {
|
||||
mockServiceClient.mockReturnValue(makeSupabase({ tables: { company_members: MEMBERSHIP } }))
|
||||
const res = await call(makeRequest('?type=voucher_gaps'))
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 404 when the caller is not a member of the company', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeSupabase({ tables: { company_members: { data: null, error: null } } }),
|
||||
)
|
||||
const res = await call(makeRequest(`?type=voucher_gaps&fiscal_period_id=${PERIOD_ID}`))
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('reports clean when every series is continuous', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeSupabase({
|
||||
tables: {
|
||||
company_members: MEMBERSHIP,
|
||||
fiscal_periods: PERIOD,
|
||||
voucher_sequences: { data: [{ voucher_series: 'A' }, { voucher_series: 'F' }], error: null },
|
||||
},
|
||||
gapsBySeries: {},
|
||||
}),
|
||||
)
|
||||
const res = await call(makeRequest(`?type=voucher_gaps&fiscal_period_id=${PERIOD_ID}`))
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data.ready).toBe(true)
|
||||
expect(body.data.findings).toHaveLength(0)
|
||||
expect(body.data.details.total_gaps).toBe(0)
|
||||
expect(body.data.details.series_checked).toEqual(['A', 'F'])
|
||||
})
|
||||
|
||||
it('reports a gap that sits in a non-A series (series F: 33 → 37)', async () => {
|
||||
const supabase = makeSupabase({
|
||||
tables: {
|
||||
company_members: MEMBERSHIP,
|
||||
fiscal_periods: PERIOD,
|
||||
voucher_sequences: { data: [{ voucher_series: 'A' }, { voucher_series: 'F' }], error: null },
|
||||
voucher_gap_explanations: { data: [], error: null },
|
||||
},
|
||||
gapsBySeries: { F: [{ gap_start: 34, gap_end: 36 }] },
|
||||
})
|
||||
mockServiceClient.mockReturnValue(supabase)
|
||||
|
||||
const res = await call(makeRequest(`?type=voucher_gaps&fiscal_period_id=${PERIOD_ID}`))
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
|
||||
// Every registered series is checked, not only the RPC's 'A' default.
|
||||
const seriesArgs = supabase.rpc.mock.calls.map((c) => (c[1] as { p_series: string }).p_series)
|
||||
expect(seriesArgs).toEqual(['A', 'F'])
|
||||
|
||||
expect(body.data.ready).toBe(false)
|
||||
expect(body.data.findings).toHaveLength(1)
|
||||
const finding = body.data.findings[0]
|
||||
expect(finding.severity).toBe('blocker')
|
||||
expect(finding.code).toBe('VOUCHER_GAP_UNEXPLAINED')
|
||||
// The RPC returns only (gap_start, gap_end): the series must still be real.
|
||||
expect(finding.message).toContain('Series F')
|
||||
expect(finding.message).not.toContain('undefined')
|
||||
expect(finding.details).toMatchObject({
|
||||
voucher_series: 'F',
|
||||
gap_start: 34,
|
||||
gap_end: 36,
|
||||
has_explanation: false,
|
||||
})
|
||||
expect(body.data.details.unexplained_count).toBe(1)
|
||||
})
|
||||
|
||||
it('downgrades a gap to info only when voucher_gap_explanations documents it', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeSupabase({
|
||||
tables: {
|
||||
company_members: MEMBERSHIP,
|
||||
fiscal_periods: PERIOD,
|
||||
voucher_sequences: { data: [{ voucher_series: 'F' }], error: null },
|
||||
voucher_gap_explanations: {
|
||||
data: [{ voucher_series: 'F', gap_start: 34, gap_end: 36 }],
|
||||
error: null,
|
||||
},
|
||||
},
|
||||
gapsBySeries: { F: [{ gap_start: 34, gap_end: 36 }] },
|
||||
}),
|
||||
)
|
||||
const res = await call(makeRequest(`?type=voucher_gaps&fiscal_period_id=${PERIOD_ID}`))
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data.ready).toBe(true)
|
||||
expect(body.data.findings[0].severity).toBe('info')
|
||||
expect(body.data.findings[0].code).toBe('VOUCHER_GAP_EXPLAINED')
|
||||
expect(body.data.findings[0].details.voucher_series).toBe('F')
|
||||
})
|
||||
|
||||
it('falls back to series A when the period has no voucher_sequences rows', async () => {
|
||||
const supabase = makeSupabase({
|
||||
tables: {
|
||||
company_members: MEMBERSHIP,
|
||||
fiscal_periods: PERIOD,
|
||||
voucher_sequences: { data: [], error: null },
|
||||
},
|
||||
gapsBySeries: {},
|
||||
})
|
||||
mockServiceClient.mockReturnValue(supabase)
|
||||
const res = await call(makeRequest(`?type=voucher_gaps&fiscal_period_id=${PERIOD_ID}`))
|
||||
expect(res.status).toBe(200)
|
||||
expect(supabase.rpc.mock.calls.map((c) => (c[1] as { p_series: string }).p_series)).toEqual(['A'])
|
||||
})
|
||||
|
||||
it('surfaces a failing detection instead of reporting "no gaps"', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeSupabase({
|
||||
tables: {
|
||||
company_members: MEMBERSHIP,
|
||||
fiscal_periods: PERIOD,
|
||||
voucher_sequences: { data: [{ voucher_series: 'F' }], error: null },
|
||||
},
|
||||
rpcError: { message: 'boom', code: 'XX000' },
|
||||
}),
|
||||
)
|
||||
const res = await call(makeRequest(`?type=voucher_gaps&fiscal_period_id=${PERIOD_ID}`))
|
||||
expect(res.status).toBeGreaterThanOrEqual(400)
|
||||
const body = await res.json()
|
||||
expect(body.data).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns 400 when fiscal_period_id belongs to another company', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeSupabase({
|
||||
tables: { company_members: MEMBERSHIP, fiscal_periods: { data: null, error: null } },
|
||||
}),
|
||||
)
|
||||
const res = await call(makeRequest(`?type=voucher_gaps&fiscal_period_id=${PERIOD_ID}`))
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user