From 9e1e13d388bb0e5927b311d539650b44bf346213 Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Thu, 7 May 2026 13:32:06 +0200 Subject: [PATCH] Fix/bank and onboarding (#413) * fix(onboarding): validate org number at step 2 instead of final submit Run normalizeOrgNumber (Luhn + 10/12-digit check) inside the Step 2 Zod schema and gate the duplicate-check and TIC lookup effects on it, so users get an inline error on the field they just typed instead of filling out two more steps and being bounced back from the server. Server-side check stays as defense in depth. Also fixes the old regex incorrectly rejecting valid 12-digit org numbers. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(reconciliation): pass real userId to transaction.reconciled events runReconciliation and manualLink fell back to companyId when no userId was provided, causing FK violations on event_log.user_id (which references auth.users). Make userId a required arg in both functions, drop the fallback, and forward a real user id from every caller (user.id from authed routes, connection.user_id from the cron path). Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(enable-banking): use local companyId instead of reinlining ctx fallback Three sites in the enable-banking sync handler reinline `ctx?.companyId ?? user.id` instead of using the local `companyId` declared at the top of the block. Collapse all three to the local for consistency and to remove drift risk if the fallback expression ever changes. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .../extensions/enable-banking/sync/cron/route.ts | 2 +- app/api/reconciliation/bank/link/route.ts | 2 +- app/api/reconciliation/bank/run/route.ts | 2 +- components/onboarding/Step2CompanyDetails.tsx | 12 +++++++----- extensions/general/enable-banking/index.ts | 6 +++--- .../__tests__/bank-reconciliation.test.ts | 14 +++++++------- lib/reconciliation/bank-reconciliation.ts | 9 +++++---- 7 files changed, 25 insertions(+), 22 deletions(-) diff --git a/app/api/extensions/enable-banking/sync/cron/route.ts b/app/api/extensions/enable-banking/sync/cron/route.ts index 9c988c01..114475f5 100644 --- a/app/api/extensions/enable-banking/sync/cron/route.ts +++ b/app/api/extensions/enable-banking/sync/cron/route.ts @@ -181,7 +181,7 @@ export const GET = withCronContext('cron.bank_sync', async (_request, ctx) => { // Batch reconciliation sweep when SIE overlap detected if (sieOverlap && totalImported > 0) { try { - await runReconciliation(supabase, connection.company_id, { + await runReconciliation(supabase, connection.company_id, connection.user_id, { dateFrom: fromDate, dateTo: toDate, }) diff --git a/app/api/reconciliation/bank/link/route.ts b/app/api/reconciliation/bank/link/route.ts index b620f508..807cfae2 100644 --- a/app/api/reconciliation/bank/link/route.ts +++ b/app/api/reconciliation/bank/link/route.ts @@ -26,7 +26,7 @@ export async function POST(request: Request) { if (!validation.success) return validation.response const { transaction_id, journal_entry_id } = validation.data - const result = await manualLink(supabase, companyId, transaction_id, journal_entry_id) + const result = await manualLink(supabase, companyId, transaction_id, journal_entry_id, user.id) if (!result.success) { return NextResponse.json({ error: result.error }, { status: 400 }) diff --git a/app/api/reconciliation/bank/run/route.ts b/app/api/reconciliation/bank/run/route.ts index aac0be02..586b0631 100644 --- a/app/api/reconciliation/bank/run/route.ts +++ b/app/api/reconciliation/bank/run/route.ts @@ -26,7 +26,7 @@ export async function POST(request: Request) { if (!validation.success) return validation.response const { date_from, date_to, dry_run } = validation.data - const result = await runReconciliation(supabase, companyId, { + const result = await runReconciliation(supabase, companyId, user.id, { dateFrom: date_from, dateTo: date_to, dryRun: dry_run ?? false, diff --git a/components/onboarding/Step2CompanyDetails.tsx b/components/onboarding/Step2CompanyDetails.tsx index 86b5166b..d03a5c9c 100644 --- a/components/onboarding/Step2CompanyDetails.tsx +++ b/components/onboarding/Step2CompanyDetails.tsx @@ -12,6 +12,7 @@ import { Loader2, ArrowRight, ArrowLeft, CheckCircle2, AlertTriangle } from 'luc import type { EntityType } from '@/types' import type { CompanyLookupResult } from '@/lib/company-lookup/types' import { getBranding } from '@/lib/branding/service' +import { normalizeOrgNumber } from '@/lib/company-lookup/normalize-org-number' const branding = getBranding() @@ -19,7 +20,10 @@ const schema = z.object({ company_name: z.string().min(1, 'Företagsnamn krävs'), org_number: z.string() .min(1, 'Organisationsnummer krävs') - .regex(/^\d{6,8}[-\s]?\d{4}$/, 'Ogiltigt format. Ange XXXXXX-XXXX'), + .refine( + (val) => normalizeOrgNumber(val) !== null, + 'Ogiltigt organisationsnummer. Kontrollera att du angett ett giltigt 10- eller 12-siffrigt organisationsnummer.', + ), address_line1: z.string().optional(), postal_code: z.string().optional(), city: z.string().optional(), @@ -27,8 +31,6 @@ const schema = z.object({ type FormData = z.infer -const ORG_NUMBER_REGEX = /^\d{6,8}[-\s]?\d{4}$/ - interface Step2Props { initialData: Partial entityType?: EntityType @@ -82,7 +84,7 @@ export default function Step2CompanyDetails({ // button is disabled; the server action would also reject ('org_number_exists') // but blocking client-side avoids a wasted roundtrip. useEffect(() => { - if (!orgNumber || !ORG_NUMBER_REGEX.test(orgNumber)) { + if (!orgNumber || normalizeOrgNumber(orgNumber) === null) { setOrgNumberExists(false) return } @@ -109,7 +111,7 @@ export default function Step2CompanyDetails({ }, [orgNumber]) useEffect(() => { - if (!ticEnabled || !orgNumber || !ORG_NUMBER_REGEX.test(orgNumber)) { + if (!ticEnabled || !orgNumber || normalizeOrgNumber(orgNumber) === null) { return } diff --git a/extensions/general/enable-banking/index.ts b/extensions/general/enable-banking/index.ts index 2138ba57..349c766b 100644 --- a/extensions/general/enable-banking/index.ts +++ b/extensions/general/enable-banking/index.ts @@ -318,7 +318,7 @@ export const enableBankingExtension: Extension = { // Skip for viewers — reconciliation updates transactions which viewers cannot do. if (sieOverlap && totalImported > 0 && !isViewer) { try { - const reconResult = await runReconciliation(supabase, ctx?.companyId ?? user.id, { + const reconResult = await runReconciliation(supabase, companyId, user.id, { dateFrom: fromDate, dateTo: toDate, }) @@ -346,7 +346,7 @@ export const enableBankingExtension: Extension = { const { data: syncedTransactions } = await supabase .from('transactions') .select('*') - .eq('company_id', ctx?.companyId ?? user.id) + .eq('company_id', companyId) .eq('bank_connection_id', connection.id) .gte('created_at', syncStartedAt) .order('created_at', { ascending: false }) @@ -356,7 +356,7 @@ export const enableBankingExtension: Extension = { const emit = ctx?.emit ?? (await import('@/lib/events/bus')).eventBus.emit.bind((await import('@/lib/events/bus')).eventBus) await emit({ type: 'transaction.synced', - payload: { transactions: syncedTransactions as Transaction[], userId: user.id, companyId: ctx?.companyId ?? user.id }, + payload: { transactions: syncedTransactions as Transaction[], userId: user.id, companyId }, }) } } diff --git a/lib/reconciliation/__tests__/bank-reconciliation.test.ts b/lib/reconciliation/__tests__/bank-reconciliation.test.ts index c4ad8606..d458cca7 100644 --- a/lib/reconciliation/__tests__/bank-reconciliation.test.ts +++ b/lib/reconciliation/__tests__/bank-reconciliation.test.ts @@ -319,7 +319,7 @@ describe('runReconciliation', () => { // from('transactions').select — unmatched enqueue({ data: [] }) - const result = await runReconciliation(supabase as never, 'company-1') + const result = await runReconciliation(supabase as never, 'company-1', 'user-1') expect(result.matches).toEqual([]) expect(result.applied).toBe(0) @@ -341,7 +341,7 @@ describe('runReconciliation', () => { // from('transactions') returns unmatched transactions enqueue({ data: [tx] }) - const result = await runReconciliation(supabase as never, 'company-1', { dryRun: true }) + const result = await runReconciliation(supabase as never, 'company-1', 'user-1', { dryRun: true }) expect(result.matches).toHaveLength(1) expect(result.matches[0].method).toBe('auto_exact') @@ -366,7 +366,7 @@ describe('runReconciliation', () => { // Update transaction with link enqueue({ data: null, error: null }) - const result = await runReconciliation(supabase as never, 'company-1', { dryRun: false }) + const result = await runReconciliation(supabase as never, 'company-1', 'user-1', { dryRun: false }) expect(result.matches).toHaveLength(1) expect(result.applied).toBe(1) @@ -420,7 +420,7 @@ describe('manualLink', () => { // Transaction query returns null enqueue({ data: null, error: { message: 'Not found' } }) - const result = await manualLink(supabase as never, 'company-1', 'tx-1', 'je-1') + const result = await manualLink(supabase as never, 'company-1', 'tx-1', 'je-1', 'user-1') expect(result.success).toBe(false) expect(result.error).toBe('Transaction not found') @@ -433,7 +433,7 @@ describe('manualLink', () => { // Transaction found but already linked enqueue({ data: tx }) - const result = await manualLink(supabase as never, 'company-1', 'tx-1', 'je-1') + const result = await manualLink(supabase as never, 'company-1', 'tx-1', 'je-1', 'user-1') expect(result.success).toBe(false) expect(result.error).toBe('Transaction is already linked to a journal entry') @@ -450,7 +450,7 @@ describe('manualLink', () => { // No 1930 lines enqueue({ data: [] }) - const result = await manualLink(supabase as never, 'company-1', 'tx-1', 'je-1') + const result = await manualLink(supabase as never, 'company-1', 'tx-1', 'je-1', 'user-1') expect(result.success).toBe(false) expect(result.error).toBe('Verifikationen saknar rad på bankkonto (19xx)') @@ -471,7 +471,7 @@ describe('manualLink', () => { // Update succeeds enqueue({ data: null, error: null }) - const result = await manualLink(supabase as never, 'company-1', 'tx-1', 'je-1') + const result = await manualLink(supabase as never, 'company-1', 'tx-1', 'je-1', 'user-1') expect(result.success).toBe(true) }) diff --git a/lib/reconciliation/bank-reconciliation.ts b/lib/reconciliation/bank-reconciliation.ts index 11eb23fb..673ddf1e 100644 --- a/lib/reconciliation/bank-reconciliation.ts +++ b/lib/reconciliation/bank-reconciliation.ts @@ -131,7 +131,8 @@ export function tryReconcileTransaction( export async function runReconciliation( supabase: SupabaseClient, companyId: string, - options: ReconciliationOptions & { userId?: string } = {} + userId: string, + options: ReconciliationOptions = {} ): Promise { const { dateFrom, dateTo, dryRun = false } = options @@ -189,7 +190,7 @@ export async function runReconciliation( transaction: match.transaction, journalEntryId: match.glLine.journal_entry_id, method: match.method, - userId: options.userId ?? companyId, + userId, companyId, }, }) @@ -292,7 +293,7 @@ export async function manualLink( companyId: string, transactionId: string, journalEntryId: string, - userId?: string + userId: string ): Promise<{ success: boolean; error?: string }> { // Fetch transaction const { data: tx, error: txError } = await supabase @@ -372,7 +373,7 @@ export async function manualLink( transaction: tx as Transaction, journalEntryId, method: 'manual' as ReconciliationMethod, - userId: userId ?? companyId, + userId, companyId, }, })