From 8a7fd567bdad9dc98df870f942a1ec7da81405b2 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Fri, 24 Jul 2026 20:07:24 +0200 Subject: [PATCH] fix(settings): validate share-capital pair before saving (#1137) (#1160) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(settings): validate share-capital pair before saving (#1137) Entering aktiekapital without antal aktier (or vice versa) died on the DB pair constraint company_settings_share_capital_pair as a raw 500 with the generic 'Vardet uppfyller inte de tillatna kraven' toast. The pair rule (ARL 5 kap 14 $: the aktiekapital note needs both values) now surfaces as a clear 400 in the PUT route, checked against effective body-or-stored values so partial API updates are covered too. The form additionally marks each field required when its sibling is filled, so the browser blocks a one-sided submit before the request is sent. Fixes #1137 Co-Authored-By: Claude Fable 5 * docs: decision-log entry for share-capital pair validation placement Co-Authored-By: Claude Fable 5 * docs(settings): correct the share-capital note citation to ÅRL 5 kap 34 § 5 kap 14 § is ställda säkerheter; the antal aktier/kvotvärde note is 5 kap 34 § (flagged by the Swedish compliance review bot, verified against the swedish-financial-reporting skill). Co-Authored-By: Claude Fable 5 * test(settings): assert the pair message on the one-sided-clear rejection CodeRabbit review on #1160. Its second suggestion (assert the update payload on the partial-update test) is skipped: createQueuedMockSupabase proxies away builder args, so payloads are not recordable, same as every other test in this suite. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- DECISIONS.md | 1 + app/api/settings/__tests__/route.test.ts | 65 ++++++++++++++++++++++++ app/api/settings/route.ts | 18 ++++++- components/settings/ShareCapitalForm.tsx | 2 + 4 files changed, 85 insertions(+), 1 deletion(-) diff --git a/DECISIONS.md b/DECISIONS.md index a92ce399..7ff04449 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -364,3 +364,4 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-07-24] Journey onboarding stack MERGED to main (#1141, #1145 ex-#1142, #1143) after founder preview click-through + "safe to merge". Production flag NEXT_PUBLIC_ONBOARDING_JOURNEY deliberately NOT set at merge time: BankID roles-prefill path is reducer-tested but not yet live-verified, so the flip is an explicit founder step, followed by one BankID smoke and then PR D (wizard deletion, /companies/new mode='add', picker restyle). [2026-07-24] Onboarding journey migration COMPLETE with PR #1150: wizard deleted, /companies/new on journey mode='add', BankID picker = searchable list, flag conditional removed (env var cleaned from Vercel post-merge). Bot-review triage: compliance findings on getUser()/redirect()/ensure_user_team skipped as App Router misreadings or pre-existing patterns; fixed the real ones (stale select_company keys, unused hasExistingCompanies plumbing). [2026-07-24] Invite recovery on onboarding surfaces = cookie retry + hint, NOT accept-by-email: a BankID signup's email is confirmed via a client-delivered magiclink (no mailbox proof), so auto-joining on email match would let anyone who registers the invitee's address claim the membership. The cookie/token path keeps mailbox possession required; cookie-less invitees get pointed back to the mailed link, with no company name leaked. +[2026-07-24] Share-capital pair rule validated in the PUT route, not UpdateSettingsSchema: the all-or-nothing check needs the stored row (a partial update may send only one key), which Zod cannot see; the route already owns the other cross-field effective-value checks. diff --git a/app/api/settings/__tests__/route.test.ts b/app/api/settings/__tests__/route.test.ts index c741cb8d..83362071 100644 --- a/app/api/settings/__tests__/route.test.ts +++ b/app/api/settings/__tests__/route.test.ts @@ -145,6 +145,71 @@ describe('PUT /api/settings', () => { } }) + it('rejects aktiekapital without antal aktier with a clear message (issue #1137)', async () => { + enqueue({ + data: { + entity_type: 'aktiebolag', + onboarding_complete: true, + aktiekapital: null, + antal_aktier: null, + }, + }) + + const response = await PUT(createMockRequest('/api/settings', { + method: 'PUT', + body: { aktiekapital: 25000, antal_aktier: null }, + }), { params: Promise.resolve({}) }) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(400) + expect(body.error).toContain('antal aktier') + // The guard fired before the update: only the oldSettings fetch ran. + expect(supabase.from).toHaveBeenCalledTimes(1) + }) + + it('rejects clearing only one half of a stored share-capital pair', async () => { + enqueue({ + data: { + entity_type: 'aktiebolag', + onboarding_complete: true, + aktiekapital: 25000, + antal_aktier: 500, + }, + }) + + const response = await PUT(createMockRequest('/api/settings', { + method: 'PUT', + body: { antal_aktier: null }, + }), { params: Promise.resolve({}) }) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(400) + expect(body.error).toContain('antal aktier') + expect(supabase.from).toHaveBeenCalledTimes(1) + }) + + it('allows updating one half when the other half is already stored', async () => { + enqueueMany([ + { + data: { + entity_type: 'aktiebolag', + onboarding_complete: true, + aktiekapital: 25000, + antal_aktier: 500, + }, + }, + { data: { id: 's1', aktiekapital: 50000, antal_aktier: 500 } }, + { data: null, count: 5 }, + ]) + + const response = await PUT(createMockRequest('/api/settings', { + method: 'PUT', + body: { aktiekapital: 50000 }, + }), { params: Promise.resolve({}) }) + + expect((await parseJsonResponse(response)).status).toBe(200) + }) + it('updates invoice email recipients and payment accounts', async () => { const updates = { invoice_email_cc_addresses: ['info@example.com', 'owner@example.com'], diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts index 8af61f4f..0c30ecc7 100644 --- a/app/api/settings/route.ts +++ b/app/api/settings/route.ts @@ -48,7 +48,7 @@ export const PUT = withRouteContext( // Fetch current settings to check for tax-relevant changes const { data: oldSettings } = await supabase .from('company_settings') - .select(`${DEADLINE_SETTINGS_SELECT}, vat_number, onboarding_complete, salary_vacation_year_basis, reminder_days_level_1, reminder_days_level_2, reminder_days_level_3`) + .select(`${DEADLINE_SETTINGS_SELECT}, vat_number, onboarding_complete, salary_vacation_year_basis, reminder_days_level_1, reminder_days_level_2, reminder_days_level_3, aktiekapital, antal_aktier`) .eq('company_id', companyId) .single() @@ -118,6 +118,22 @@ export const PUT = withRouteContext( ) } + // Share capital is all-or-nothing: the antal aktier/kvotvärde note (ÅRL 5 kap 34 §) + // needs both the registered amount and the share count, and the DB pair + // constraint enforces it. Validate against the effective (body-or-stored) + // values so the user gets a clear message instead of a raw constraint 500. + if (body.aktiekapital !== undefined || body.antal_aktier !== undefined) { + const old = oldSettings as { aktiekapital?: number | null; antal_aktier?: number | null } | null + const effectiveAktiekapital = body.aktiekapital !== undefined ? body.aktiekapital : old?.aktiekapital ?? null + const effectiveAntalAktier = body.antal_aktier !== undefined ? body.antal_aktier : old?.antal_aktier ?? null + if ((effectiveAktiekapital === null) !== (effectiveAntalAktier === null)) { + return NextResponse.json( + { error: 'Aktiekapital och antal aktier måste anges tillsammans. Fyll i båda fälten eller lämna båda tomma.' }, + { status: 400 }, + ) + } + } + // Vacation year basis (payroll gap-closure 3.1): changing the boundary // while OPEN vacation-ledger rows exist would orphan them (rows are keyed // by vacation_year_start). Close the current year first. diff --git a/components/settings/ShareCapitalForm.tsx b/components/settings/ShareCapitalForm.tsx index cf68c15c..4bc20a7e 100644 --- a/components/settings/ShareCapitalForm.tsx +++ b/components/settings/ShareCapitalForm.tsx @@ -57,6 +57,7 @@ export function ShareCapitalForm({ settings }: ShareCapitalFormProps) { step="1" value={aktiekapital} onChange={(e) => setAktiekapital(e.target.value)} + required={antalAktier.trim() !== ''} />

{t('aktiekapital_help')}

@@ -71,6 +72,7 @@ export function ShareCapitalForm({ settings }: ShareCapitalFormProps) { step="1" value={antalAktier} onChange={(e) => setAntalAktier(e.target.value)} + required={aktiekapital.trim() !== ''} />

{t('antal_aktier_help')}