diff --git a/DECISIONS.md b/DECISIONS.md index 0e5b23a9..becafc8f 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1483,6 +1483,7 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-09-01] ENABLE_BANKING_SANDBOX removed from the enable-banking manifest and the index.ts header (#2131): the variable was declared as optional but never read anywhere; sandbox vs production is decided by ENABLE_BANKING_API_URL (api.tilisy.com vs api.enablebanking.com, api-client.ts derives isSandbox from the host). A dead variable declared in the manifest is what the self-hosting docs would otherwise have copied. The manifest now lists the two optional variables the code actually reads (API_URL, PSU_TYPE); the _PRODUCTION aliases stay undeclared on purpose, they are a hosted Vercel convention, not an operator contract. [2026-09-02] Repo-wide bloat sweep (chore/bloat-sweep-2026-09): removed dead files/exports/types/i18n namespaces and deduplicated byte-identical helpers into canonical homes (lib/utils chunk/sleep/utcDateStamp, lib/dates/iso, lib/invariants/uuid, lib/xml/escape, lib/reports/sru/format, lib/pdf/number-text pdfAmount/formatDateSv, lib/browser/panel-request, lib/api/v1/body + v1ValidationError, lib/bookkeeping/booking-template-schemas). Deliberately NOT done: naive Math.round(x*100)/100 helpers were not swapped for roundOre (behaviour change at half-ore values, ratchet campaign owns it); lib/bokslut/rounding.ts shim kept because money.test.ts asserts the back-compat re-export; text-based v1 body parsers (empty body allowed) kept inline because readV1JsonBody has different empty-body semantics; the four HTTP endpoints with no first-party caller (skatteverket agi/underlag + agi/sparad DELETE, invoice-inbox items/:id/history, mail connections/backfill) stay because removing a reachable endpoint is a surface change; VacationBalanceCard deleted as unreachable since #1130, the v1/MCP vacation-year-close routes stay. [2026-09-01] PR #2130 security-scan round: the register's djuplank is validated (https + skatteverket.se host) before it is returned or navigated to, since the settings page follows it; a contested org number now WITHDRAWS an already-recorded grant nightly (not only blocks new ones), outside the downgrade guards on purpose. NOT done: proof of org-number ownership (Bolagsverket firmatecknare / BankID) before any ombud grant; the org number is tenant-editable across the product (AGI, invoices, årsredovisning) and binding it to a verified identity is a product decision for Emil, tracked as a follow-up rather than declined. +[2026-09-02] Verifikationsserie per bankkonto applies to bank_transaction bookings only (book route, categorize, agent, pending operations), not to invoice settlements matched from the bank or to bulk-book samlingsverifikat: invoice_paid/supplier_invoice_paid series describe the payment kind and match-* routes have eight builder branches that do not take a series; bulk-book resolves its series inside the bulk_book_transactions RPC. One predictable rule beats partial coverage; extend later if a user asks. [2026-09-02] Grok custom connectors are allowlisted by the exact callback https://grok.com/connectors-oauth-exchange-code/ (trailing slash optional), not a grok.com prefix: the value is published by X Corp at docs.x.com/x-ads-api/mcp ("Grok (web)" redirect URL) and grok.com serves the path itself (slash form 308s to no-slash on the same origin), and a prefix would let any future grok.com path receive authorization codes. Grok gets side doors next to ChatGPT (onboarding) and a row under "Other clients" (settings); Claude stays the visual primary per the 2026-08-27 founder call. No new client marker plumbing: the settings URL uses the existing ?client= param with value grok. [2026-09-02] Viewer write gate as ONE table-level trigger (enforce_company_writer_role) instead of re-emitting 15 SECURITY DEFINER bodies and ~130 policies: keyed on the JWT role claim so it fires inside definer functions too; no-op for service_role and trigger cascades. agent_conversations/agent_messages and telemetry tables deliberately excluded. [2026-09-02] Posting-integrity guards key on current_user IN ('anon','authenticated'), not the JWT claim: inside SECURITY DEFINER RPCs current_user is the definer, so commit_journal_entry, SIE import, storno and rättelse keep working while direct PostgREST manipulation of posted vouchers is blocked. Residual: a direct draft->posted flip may still reuse an unused number below the sequence high-water mark. diff --git a/app/api/bookkeeping/voucher-sequences/next/__tests__/route.test.ts b/app/api/bookkeeping/voucher-sequences/next/__tests__/route.test.ts index f75bab5d..9d45253a 100644 --- a/app/api/bookkeeping/voucher-sequences/next/__tests__/route.test.ts +++ b/app/api/bookkeeping/voucher-sequences/next/__tests__/route.test.ts @@ -173,6 +173,113 @@ describe('GET /api/bookkeeping/voucher-sequences/next', () => { expect(body.data).toEqual({ next: 5, series: 'V', fiscal_period_id: 'period-1' }) }) + it("prefers the cash account's own series over the per-source-type map", async () => { + mockAuth.mockResolvedValue({ data: { user: { id: 'user-1' } } }) + + mockFrom.mockImplementation((table: string) => { + if (table === 'fiscal_periods') { + return mockChain({ data: { id: 'period-1' }, error: null }) + } + if (table === 'company_settings') { + return mockChain({ + data: { + default_voucher_series: 'A', + default_voucher_series_per_source_type: { bank_transaction: 'B' }, + }, + error: null, + }) + } + if (table === 'cash_accounts') { + return mockChain({ data: { voucher_series: 'M' }, error: null }) + } + if (table === 'voucher_sequences') { + return mockChain({ data: { last_number: 9 }, error: null }) + } + throw new Error(`Unexpected table: ${table}`) + }) + + const response = await GET( + mkReq('?source_type=bank_transaction&cash_account_id=11111111-1111-4111-8111-111111111111'), + mkParams(), + ) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.data).toEqual({ next: 10, series: 'M', fiscal_period_id: 'period-1' }) + }) + + it('falls through to the per-source-type map when the cash account has no override', async () => { + mockAuth.mockResolvedValue({ data: { user: { id: 'user-1' } } }) + + mockFrom.mockImplementation((table: string) => { + if (table === 'fiscal_periods') { + return mockChain({ data: { id: 'period-1' }, error: null }) + } + if (table === 'company_settings') { + return mockChain({ + data: { + default_voucher_series: 'A', + default_voucher_series_per_source_type: { bank_transaction: 'B' }, + }, + error: null, + }) + } + if (table === 'cash_accounts') { + return mockChain({ data: { voucher_series: null }, error: null }) + } + if (table === 'voucher_sequences') { + return mockChain({ data: null, error: null }) + } + throw new Error(`Unexpected table: ${table}`) + }) + + const response = await GET( + mkReq('?source_type=bank_transaction&cash_account_id=11111111-1111-4111-8111-111111111111'), + mkParams(), + ) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.data).toEqual({ next: 1, series: 'B', fiscal_period_id: 'period-1' }) + }) + + it('ignores the cash account override for source types other than bank_transaction', async () => { + mockAuth.mockResolvedValue({ data: { user: { id: 'user-1' } } }) + + mockFrom.mockImplementation((table: string) => { + if (table === 'fiscal_periods') { + return mockChain({ data: { id: 'period-1' }, error: null }) + } + if (table === 'company_settings') { + return mockChain({ + data: { default_voucher_series: 'A', default_voucher_series_per_source_type: { manual: 'V' } }, + error: null, + }) + } + if (table === 'voucher_sequences') { + return mockChain({ data: { last_number: 2 }, error: null }) + } + throw new Error(`Unexpected table: ${table}`) + }) + + const response = await GET( + mkReq('?source_type=manual&cash_account_id=11111111-1111-4111-8111-111111111111'), + mkParams(), + ) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.data).toEqual({ next: 3, series: 'V', fiscal_period_id: 'period-1' }) + // cash_accounts was never consulted (the mock would have thrown). + }) + + it('rejects a malformed cash_account_id with 400 before touching the database', async () => { + mockAuth.mockResolvedValue({ data: { user: { id: 'user-1' } } }) + const response = await GET(mkReq('?source_type=bank_transaction&cash_account_id=nope'), mkParams()) + expect(response.status).toBe(400) + expect(mockFrom).not.toHaveBeenCalled() + }) + it('falls back to A when the source_type has no per-source-type mapping', async () => { mockAuth.mockResolvedValue({ data: { user: { id: 'user-1' } } }) diff --git a/app/api/bookkeeping/voucher-sequences/next/route.ts b/app/api/bookkeeping/voucher-sequences/next/route.ts index a71cb97e..1b796705 100644 --- a/app/api/bookkeeping/voucher-sequences/next/route.ts +++ b/app/api/bookkeeping/voucher-sequences/next/route.ts @@ -4,6 +4,7 @@ import { errorResponse } from '@/lib/errors/get-structured-error' import { validateQuery } from '@/lib/api/validate' import { VoucherSequenceNextQuerySchema } from '@/lib/api/schemas' import { resolveDefaultSeriesForSource } from '@/lib/bookkeeping/voucher-series-resolver' +import { resolveCashAccountVoucherSeries } from '@/lib/bookkeeping/cash-account-voucher-series' export const GET = withRouteContext( 'voucher_sequence.next', @@ -15,7 +16,12 @@ export const GET = withRouteContext( operation: 'voucher_sequence.next', }) if (!query.success) return query.response - const { period_id: overridePeriodId, series: overrideSeries, source_type: sourceType } = query.data + const { + period_id: overridePeriodId, + series: overrideSeries, + source_type: sourceType, + cash_account_id: cashAccountId, + } = query.data const today = new Date().toISOString().split('T')[0] // Vouchers are numbered per fiscal period, so the preview must reflect the @@ -57,14 +63,23 @@ export const GET = withRouteContext( } // When a source_type is supplied, resolve the series exactly as the booking - // engine does (per-source-type map → 'A'), so the preview can never disagree - // with the verifikat that actually gets created. Without a source_type, keep - // the legacy generic default for callers that just want "the next number". + // engine does (cash account override → per-source-type map → 'A'), so the + // preview can never disagree with the verifikat that actually gets created. + // Without a source_type, keep the legacy generic default for callers that + // just want "the next number". + // The account override only applies to entries booked from bank + // transactions; for any other source type it must not colour the preview. + const cashAccountSeries = + !overrideSeries && cashAccountId && sourceType === 'bank_transaction' + ? await resolveCashAccountVoucherSeries(supabase, companyId, cashAccountId) + : undefined const series = overrideSeries ? overrideSeries - : sourceType - ? resolveDefaultSeriesForSource(settings, sourceType) - : settings?.default_voucher_series || 'A' + : cashAccountSeries + ? cashAccountSeries + : sourceType + ? resolveDefaultSeriesForSource(settings, sourceType) + : settings?.default_voucher_series || 'A' if (!period) { return NextResponse.json({ data: { next: null, series, fiscal_period_id: null } }) diff --git a/app/api/cash-accounts/[id]/__tests__/route.test.ts b/app/api/cash-accounts/[id]/__tests__/route.test.ts new file mode 100644 index 00000000..86e700ab --- /dev/null +++ b/app/api/cash-accounts/[id]/__tests__/route.test.ts @@ -0,0 +1,143 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { + parseJsonResponse, + createMockRouteParams, + createQueuedMockSupabase, +} from '@/tests/helpers' + +const { supabase: mockSupabase, enqueue, reset, findCalls } = createQueuedMockSupabase() +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'), +})) + +const requireWriteMock = vi.fn() +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), +})) + +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: vi.fn(), +})) + +import { PATCH } from '../route' +import { requireAuth } from '@/lib/auth/require-auth' + +const CA_1 = '11111111-1111-4111-8111-111111111111' +const CA_OTHER = '22222222-2222-4222-8222-222222222222' + +describe('PATCH /api/cash-accounts/[id] (verifikationsserie per bankkonto)', () => { + const mockUser = { id: 'user-1', email: 'test@test.se' } + + function patchReq(body: unknown) { + return new Request('http://localhost/api/cash-accounts/ca-1', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + } + + beforeEach(() => { + vi.clearAllMocks() + reset() + vi.mocked(requireAuth).mockResolvedValue({ + user: mockUser as never, + supabase: mockSupabase as never, + error: null, + }) + requireWriteMock.mockResolvedValue({ ok: true }) + }) + + 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 response = await PATCH(patchReq({ voucher_series: 'M' }), createMockRouteParams({ id: CA_1 })) + expect(response.status).toBe(401) + }) + + it('returns 403 when the caller is a viewer', async () => { + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + + const response = await PATCH(patchReq({ voucher_series: 'M' }), createMockRouteParams({ id: CA_1 })) + expect(response.status).toBe(403) + }) + + it('returns 400 on a malformed series (must be one uppercase letter)', async () => { + for (const bad of ['m', 'AB', '', 7]) { + const response = await PATCH(patchReq({ voucher_series: bad }), createMockRouteParams({ id: CA_1 })) + expect(response.status).toBe(400) + } + expect(findCalls('cash_accounts', 'update')).toHaveLength(0) + }) + + it('returns 400 when voucher_series is missing entirely', async () => { + const response = await PATCH(patchReq({}), createMockRouteParams({ id: CA_1 })) + expect(response.status).toBe(400) + }) + + it('returns 404 for an id that is not a UUID, without touching the database', async () => { + const response = await PATCH(patchReq({ voucher_series: 'M' }), createMockRouteParams({ id: 'not-a-uuid' })) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) + + expect(status).toBe(404) + expect(body.error.code).toBe('CASH_ACCOUNT_NOT_FOUND') + expect(findCalls('cash_accounts', 'update')).toHaveLength(0) + }) + + it('returns 404 when the account does not belong to the company', async () => { + enqueue({ data: null, error: null }) + + const response = await PATCH(patchReq({ voucher_series: 'M' }), createMockRouteParams({ id: CA_OTHER })) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) + + expect(status).toBe(404) + expect(body.error.code).toBe('CASH_ACCOUNT_NOT_FOUND') + const eqCalls = findCalls('cash_accounts', 'eq') + expect(eqCalls).toContainEqual(['company_id', 'company-1']) + expect(eqCalls).toContainEqual(['id', CA_OTHER]) + }) + + it('sets the series and returns the updated account (happy path)', async () => { + enqueue({ data: { id: 'ca-1', ledger_account: '1931', voucher_series: 'M' }, error: null }) + + const response = await PATCH(patchReq({ voucher_series: 'M' }), createMockRouteParams({ id: CA_1 })) + const { status, body } = await parseJsonResponse<{ data: { voucher_series: string } }>(response) + + expect(status).toBe(200) + expect(body.data.voucher_series).toBe('M') + expect(findCalls('cash_accounts', 'update')).toContainEqual([{ voucher_series: 'M' }]) + }) + + it('clears the override with null so the account follows the per-type default again', async () => { + enqueue({ data: { id: 'ca-1', ledger_account: '1931', voucher_series: null }, error: null }) + + const response = await PATCH(patchReq({ voucher_series: null }), createMockRouteParams({ id: CA_1 })) + const { status, body } = await parseJsonResponse<{ data: { voucher_series: string | null } }>(response) + + expect(status).toBe(200) + expect(body.data.voucher_series).toBeNull() + expect(findCalls('cash_accounts', 'update')).toContainEqual([{ voucher_series: null }]) + }) + + it('maps a database error to the canonical error envelope', async () => { + enqueue({ data: null, error: { message: 'boom', code: '42P01' } }) + + const response = await PATCH(patchReq({ voucher_series: 'M' }), createMockRouteParams({ id: CA_1 })) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) + + expect(status).toBeGreaterThanOrEqual(400) + expect(body.error).toBeDefined() + }) +}) diff --git a/app/api/cash-accounts/[id]/route.ts b/app/api/cash-accounts/[id]/route.ts new file mode 100644 index 00000000..9117b62e --- /dev/null +++ b/app/api/cash-accounts/[id]/route.ts @@ -0,0 +1,53 @@ +import { NextResponse } from 'next/server' +import { withRouteContext } from '@/lib/api/with-route-context' +import { validateBody } from '@/lib/api/validate' +import { UpdateCashAccountVoucherSeriesSchema } from '@/lib/api/schemas' +import { errorResponse } from '@/lib/errors/get-structured-error' +import { setVoucherSeries } from '@/lib/cash-accounts/service' +import { UUID_RE } from '@/lib/invariants/uuid' + +/** Canonical 404 for an id that is not one of the company's bank accounts. */ +function notFound(): NextResponse { + return NextResponse.json( + { + error: { + code: 'CASH_ACCOUNT_NOT_FOUND', + message: 'Bankkontot hittades inte.', + message_en: 'Bank account not found.', + }, + }, + { status: 404 }, + ) +} + +/** + * PATCH /api/cash-accounts/[id] + * + * Sets or clears the verifikationsserie override on one of the company's + * bank accounts. Only this one field is editable here: ledger account and + * primary flag have their own guarded flows (unique constraint, atomic RPC). + */ +export const PATCH = withRouteContext<{ params: Promise<{ id: string }> }>( + 'cash_accounts.update', + async (request, { supabase, companyId, log, requestId }, { params }) => { + const { id } = await params + // A non-UUID id can never match a row; answer 404 instead of letting the + // uuid cast surface as a 500 from Postgres. + if (!UUID_RE.test(id)) return notFound() + const validation = await validateBody(request, UpdateCashAccountVoucherSeriesSchema) + if (!validation.success) return validation.response + + let updated + try { + updated = await setVoucherSeries(supabase, companyId, id, validation.data.voucher_series) + } catch (err) { + log.error('cash_accounts voucher_series update failed', err as Error) + return errorResponse(err, log, { requestId }) + } + + if (!updated) return notFound() + + return NextResponse.json({ data: updated }) + }, + { requireWrite: true }, +) diff --git a/app/api/transactions/[id]/book/__tests__/route.test.ts b/app/api/transactions/[id]/book/__tests__/route.test.ts index d96140cf..73d4cca1 100644 --- a/app/api/transactions/[id]/book/__tests__/route.test.ts +++ b/app/api/transactions/[id]/book/__tests__/route.test.ts @@ -237,6 +237,84 @@ describe('POST /api/transactions/[id]/book', () => { ) }) + it("books into the bank account's own verifikationsserie when the cash account carries one", async () => { + const tx = makeTransaction({ + id: 'tx-1', + amount: -500, + journal_entry_id: null, + cash_account_id: 'ca-card', + }) + const je = makeJournalEntry({ id: 'je-new', voucher_series: 'M' }) + + // Fetch transaction + enqueue({ data: tx, error: null }) + // guardBookedCounterLines own-row lookup (1930 matches the own ledger: clean) + enqueue({ data: { ledger_account: '1930' }, error: null }) + // Cash account series override + enqueue({ data: { voucher_series: 'M' }, error: null }) + mockCreateJournalEntry.mockResolvedValue(je) + // Update transaction + enqueue({ data: [{ id: 'tx-1' }], error: null }) + + const request = createMockRequest('/api/transactions/tx-1/book', { + method: 'POST', + body: validBody, + }) + const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(200) + expect(mockCreateJournalEntry).toHaveBeenCalledWith( + expect.anything(), + 'company-1', + 'user-1', + expect.objectContaining({ source_type: 'bank_transaction', voucher_series: 'M' }), + ) + }) + + it('lets an explicit voucher_series from the dialog win over the cash account override', async () => { + const tx = makeTransaction({ + id: 'tx-1', + amount: -500, + journal_entry_id: null, + cash_account_id: 'ca-card', + }) + const je = makeJournalEntry({ id: 'je-new', voucher_series: 'V' }) + + enqueue({ data: tx, error: null }) + enqueue({ data: { ledger_account: '1930' }, error: null }) + mockCreateJournalEntry.mockResolvedValue(je) + enqueue({ data: [{ id: 'tx-1' }], error: null }) + + const request = createMockRequest('/api/transactions/tx-1/book', { + method: 'POST', + body: { ...validBody, voucher_series: 'V' }, + }) + const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(200) + expect(mockCreateJournalEntry).toHaveBeenCalledWith( + expect.anything(), + 'company-1', + 'user-1', + expect.objectContaining({ voucher_series: 'V' }), + ) + // No cash_accounts series lookup: the explicit pick short-circuits it. + const seriesLookups = findCalls('cash_accounts', 'select').filter((args) => args[0] === 'voucher_series') + expect(seriesLookups).toHaveLength(0) + }) + + it('rejects a malformed voucher_series with 400', async () => { + const request = createMockRequest('/api/transactions/tx-1/book', { + method: 'POST', + body: { ...validBody, voucher_series: 'ab' }, + }) + const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) + expect(response.status).toBe(400) + expect(mockCreateJournalEntry).not.toHaveBeenCalled() + }) + it('returns 400 TX_CATEGORIZE_ORPHANED_COUNTER_ACCOUNT when a line books the settlement row against its active twin (#1643)', async () => { // The issue's dialog shape: 1930 and 1931 both enabled on one active // connection; "Ändra rader" pre-filled 1930 debit / 1931 credit from a @@ -288,6 +366,7 @@ describe('POST /api/transactions/[id]/book', () => { ], }) // cash_accounts topology enqueue({ data: [{ id: 'conn-live', status: 'active' }] }) // bank_connections statuses + enqueue({ data: { voucher_series: 'M' } }) // series override of the LIVE twin the row moves to mockCreateJournalEntry.mockResolvedValue(makeJournalEntry({ id: 'je-new' })) enqueue({ data: [{ id: 'tx-1' }], error: null }) // link update @@ -308,6 +387,14 @@ describe('POST /api/transactions/[id]/book', () => { expect(findCalls('transactions', 'update')).toContainEqual([ expect.objectContaining({ journal_entry_id: 'je-new', cash_account_id: 'ca-live' }), ]) + // The series follows the account the row ends up on, not the stale one. + expect(findCalls('cash_accounts', 'eq')).toContainEqual(['id', 'ca-live']) + expect(mockCreateJournalEntry).toHaveBeenCalledWith( + expect.anything(), + 'company-1', + 'user-1', + expect.objectContaining({ voucher_series: 'M' }), + ) }) it('returns 400 TX_CATEGORIZE_ORPHANED_COUNTER_ACCOUNT when the single bank line sits on a dead twin of the live own row (#1643 round 5)', async () => { diff --git a/app/api/transactions/[id]/book/route.ts b/app/api/transactions/[id]/book/route.ts index 4ee957a4..bae5c4ef 100644 --- a/app/api/transactions/[id]/book/route.ts +++ b/app/api/transactions/[id]/book/route.ts @@ -3,6 +3,7 @@ import { eventBus } from '@/lib/events' import { ensureInitialized } from '@/lib/init' import { withRouteContext } from '@/lib/api/with-route-context' import { createJournalEntry } from '@/lib/bookkeeping/engine' +import { resolveCashAccountVoucherSeries } from '@/lib/bookkeeping/cash-account-voucher-series' import { guardBookedCounterLines } from '@/lib/cash-accounts/service' import { reverseOrphanedJournalEntry } from '@/lib/bookkeeping/cancel-orphaned-entry' import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors' @@ -167,6 +168,19 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( }) } + // Series: the dialog's explicit pick wins; otherwise the bank account the + // row will sit on after this booking (the live sibling when the guard + // re-points a stranded row, else its own) may carry its own + // verifikationsserie; otherwise the engine falls back to the + // per-source-type default. + const voucherSeries = + validation.data.voucher_series ?? + (await resolveCashAccountVoucherSeries( + supabase, + companyId, + repointCashAccountId ?? (transaction as Transaction).cash_account_id, + )) + // Create journal entry via the engine let journalEntry try { @@ -177,6 +191,7 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( source_type: 'bank_transaction', source_id: id, lines, + ...(voucherSeries ? { voucher_series: voucherSeries } : {}), }) } catch (err) { const typed = bookkeepingErrorResponse(err) diff --git a/components/bookkeeping/JournalEntryForm.tsx b/components/bookkeeping/JournalEntryForm.tsx index 6656ffe1..6495d8a8 100644 --- a/components/bookkeeping/JournalEntryForm.tsx +++ b/components/bookkeeping/JournalEntryForm.tsx @@ -113,6 +113,13 @@ interface Props { */ extraBody?: Record duplicateMatchTransaction?: DuplicateMatchTransaction + /** Embedded variant only: show the series picker anyway. The series is + * seeded from the server (source type + cash account override) so the + * dialog and the booking route can never disagree. */ + seriesPicker?: boolean + /** Bank account the entry is booked from; its voucher_series override + * (Inställningar → Bokföring) seeds the picker. */ + cashAccountId?: string | null /** Fired after the duplicate guard's match action links the transaction to * the existing voucher (no new entry was created). */ onDuplicateMatched?: (journalEntryId: string) => void @@ -140,6 +147,8 @@ export default function JournalEntryForm({ onUpdated, extraBody, duplicateMatchTransaction, + seriesPicker, + cashAccountId, onDuplicateMatched, }: Props) { const { canWrite } = useCanWrite() @@ -181,6 +190,14 @@ export default function JournalEntryForm({ initialLines ?? [{ ...BLANK_LINE }, { ...BLANK_LINE }] ) const [voucherSeries, setVoucherSeries] = useState(initialVoucherSeries ?? 'A') + // Embedded forms show the picker only on request (bank transaction dialog). + const showSeries = !embedded || !!seriesPicker + // Whether voucherSeries is authoritative. The standalone form seeds it from + // company settings; the embedded picker asks the server once (source type + + // cash account override) and marks it resolved, or when the user picks. Until + // then the submit omits voucher_series so the route resolves it itself: an + // unresolved 'A' must never override the bank account's own series. + const [seriesResolved, setSeriesResolved] = useState(!embedded) // The source_type the entry will be committed with. Seeded from the prop // (undefined -> 'manual' for the standalone form). Applying a booking template // whose category maps to a dedicated source type (e.g. VAT -> vat_settlement) @@ -359,7 +376,7 @@ export default function JournalEntryForm({ // Read-only hint; the actual number is reserved atomically at commit time, // so this may shift by one if another entry lands first. useEffect(() => { - if (embedded || !entryDate || !voucherSeries) { + if (!showSeries || !entryDate || !voucherSeries) { setNextVoucherNumber(null) return } @@ -367,13 +384,29 @@ export default function JournalEntryForm({ // Keyed on the entry date rather than the resolved period so the preview // fires as soon as the series is known: the route resolves the period // from the date itself, which is exactly how selectedPeriod is derived. - const qs = new URLSearchParams({ date: entryDate, series: voucherSeries }) + // Before the embedded picker is resolved, ask by source type + cash + // account instead of by series: the route answers with the series the + // booking would actually get, and that seeds the picker. + const qs = new URLSearchParams({ date: entryDate }) + if (seriesResolved) { + qs.set('series', voucherSeries) + } else { + if (sourceType) qs.set('source_type', sourceType) + if (cashAccountId) qs.set('cash_account_id', cashAccountId) + } fetch(`/api/bookkeeping/voucher-sequences/next?${qs}`) .then((r) => (r.ok ? r.json() : null)) .then((body) => { if (cancelled) return const next = body?.data?.next setNextVoucherNumber(typeof next === 'number' ? next : null) + if (!seriesResolved && body) { + const resolved = body?.data?.series + if (typeof resolved === 'string' && /^[A-Z]$/.test(resolved)) { + setVoucherSeries(resolved) + } + setSeriesResolved(true) + } }) .catch(() => { if (!cancelled) setNextVoucherNumber(null) @@ -381,7 +414,7 @@ export default function JournalEntryForm({ return () => { cancelled = true } - }, [embedded, entryDate, voucherSeries]) + }, [showSeries, seriesResolved, entryDate, voucherSeries, sourceType, cashAccountId]) // Fetch exchange rate from Riksbanken when currency changes const fetchRate = useCallback(async (currency: Currency) => { @@ -1042,7 +1075,9 @@ export default function JournalEntryForm({ description, source_type: effectiveSourceType, source_id: sourceId, - voucher_series: voucherSeries || 'A', + // Omitted while an embedded picker is still unresolved: see + // seriesResolved. Endpoints that do not declare the key strip it. + ...(seriesResolved ? { voucher_series: voucherSeries || 'A' } : {}), notes: notes || undefined, lines: entryLines, // Set only when retrying past the booking-time duplicate guard (see @@ -1056,7 +1091,7 @@ export default function JournalEntryForm({ }), }) return (await throwOnStructuredError(res)) as { data?: { id?: string; voucher_series?: string; voucher_number?: number }; journal_entry_id?: string } - }, [lines, rate, entryCurrency, computedForeignAmount, t, submitUrl, editEntryId, selectedPeriod, entryDate, description, effectiveSourceType, sourceId, voucherSeries, notes, extraBody]) + }, [lines, rate, entryCurrency, computedForeignAmount, t, submitUrl, editEntryId, selectedPeriod, entryDate, description, effectiveSourceType, sourceId, voucherSeries, seriesResolved, notes, extraBody]) const { runSubmit, dialog: activationDialog, confirm: confirmActivation, cancel: cancelActivation } = useSubmitWithAccountActivation(postJournalEntry) @@ -1410,13 +1445,19 @@ export default function JournalEntryForm({ className="mt-1 h-8" /> - {!embedded && ( + {showSeries && ( // Closed list, not free text: the letters carry fixed meanings // (A = redovisning, B = kundfakturor, ...) and a typo here silently // starts a new series with its own number sequence.
- { + setVoucherSeries(v) + setSeriesResolved(true) + }} + > diff --git a/components/settings/VoucherSeriesPerCashAccountForm.tsx b/components/settings/VoucherSeriesPerCashAccountForm.tsx new file mode 100644 index 00000000..01f9d983 --- /dev/null +++ b/components/settings/VoucherSeriesPerCashAccountForm.tsx @@ -0,0 +1,139 @@ +'use client' + +import { useMemo, useState } from 'react' +import { useTranslations } from 'next-intl' +import { Loader2 } from 'lucide-react' +import { useToast } from '@/components/ui/use-toast' +import { SettingsGroup, SettingsRow, SettingsSelect } from '@/components/settings/SettingsRows' +import { useCashAccounts } from '@/lib/reference-data/hooks' +import { getErrorMessage } from '@/lib/errors/get-error-message' +import { VOUCHER_SERIES_PRESETS } from '@/lib/bookkeeping/voucher-series-resolver' +import type { CashAccount, CompanySettings } from '@/types' + +// Sentinel for "no override" in the