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:
co-authored by
Claude Opus 4.7
parent
dbe634d0aa
commit
9e1e13d388
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user