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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-05-07 13:32:06 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent dbe634d0aa
commit 9e1e13d388
7 changed files with 25 additions and 22 deletions
@@ -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,
})
+1 -1
View File
@@ -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 })
+1 -1
View File
@@ -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,
@@ -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<typeof schema>
const ORG_NUMBER_REGEX = /^\d{6,8}[-\s]?\d{4}$/
interface Step2Props {
initialData: Partial<FormData>
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
}
+3 -3
View File
@@ -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 },
})
}
}
@@ -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)
})
+5 -4
View File
@@ -131,7 +131,8 @@ export function tryReconcileTransaction(
export async function runReconciliation(
supabase: SupabaseClient,
companyId: string,
options: ReconciliationOptions & { userId?: string } = {}
userId: string,
options: ReconciliationOptions = {}
): Promise<ReconciliationRunResult> {
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,
},
})