From cc351158f8972e5677803a19f00da639e89446ec Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Thu, 21 May 2026 16:44:09 +0200 Subject: [PATCH] Invoicing & account-security polish bundle (#550) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: invoicing & account-security polish bundle Five independent improvements bundled to ship together: - BankID/password lockout fix: BankID-only users could enroll MFA and brick themselves (Supabase requires AAL2 to change password or unenroll MFA, and AAL2 needs a password sign-in). New app_metadata.has_password flag tracks this; middleware gates /mfa/enroll behind it, /account/set- password is the unlock path, SecuritySettings shows a banner, and /api/account/password is the single write path that flips the flag. Backfill script for existing users. - Swish invoice payment method: company_settings.swish + invoice_show_swish columns, validation in lib/api/schemas.ts (accepts 123XXXXXXX företag or 07XXXXXXXX mobile, strips whitespace/hyphens), rendered on invoice PDFs. - Send-reminders kill switch: per-company company_settings.send_invoice_ reminders toggle in PdfPrintSettings/Automatisering. Reminder processor also tightened: positive status allowlist (sent + overdue) so terminal statuses can never match; skip when customer already responded via reminder link; race-window re-check before send. - First-invoice logo prompt: one-shot dialog when creating the first invoice without a logo (issue #520). Self-limits via head-only count. - SIE export opening-balance fallback: route IB through getOpeningBalances so the compute_prior_opening_balances RPC supplies #IB after multi-year imports where opening_balance_entry_id is intentionally NULL. Previously #IB silently went to zero and #UB collapsed to current-period movements. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(account-polish): address PR review feedback - BankID-link path (extensions/general/tic/index.ts): read-merge-write app_metadata instead of passing { bankid_linked: true } alone. updateUserById REPLACES app_metadata wholesale, so the previous code would have wiped has_password for any user who later linked BankID, causing the set-password banner to (incorrectly) reappear and blocking the standard MFA enrollment button. The comment is now corrected. - Middleware (lib/supabase/middleware.ts): thread inner returnTo through the /mfa/enroll → /account/set-password redirect so the user lands on their original destination after the full chain completes, not on /. - safeReturnTo helper (lib/auth/safe-return-to.ts): replace the starts-with-/-but-not-// guard on mfa/enroll and set-password pages. The previous guard let /\evil.com and /@evil.com through. The new helper parses against a synthetic base origin and verifies it matches. - set-password page (app/(auth)/account/set-password/page.tsx): remove CLAUDE.md design system violations — bg-gradient-to-b on page bg, inline shadow-md style on the card, space-y-5, font-medium on the h1, rounded-xl on the card. Flat surface, hairline border, font-display h1 per the design tokens. - Swish dedup (lib/payments/swish.ts): extract normaliseSwish() and isValidSwish() helpers and use them in lib/api/schemas.ts, components/settings/BankDetailsForm.tsx, and the invoicing settings page. Single source of truth for the regex. - Password route (app/api/account/password/route.ts): emit a structured success log so the audit pipeline can detect password-set events, not just failures. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- app/(auth)/account/set-password/page.tsx | 187 ++++++++++++++++++ app/(auth)/mfa/enroll/page.tsx | 32 ++- app/(auth)/reset-password/page.tsx | 18 +- app/(dashboard)/invoices/new/page.tsx | 59 +++++- app/(dashboard)/settings/invoicing/page.tsx | 2 + .../account/password/__tests__/route.test.ts | 147 ++++++++++++++ app/api/account/password/route.ts | 93 +++++++++ .../invoices/FirstInvoiceLogoPrompt.tsx | 67 +++++++ components/settings/BankDetailsForm.tsx | 67 +++++-- components/settings/PdfPrintSettings.tsx | 30 +++ components/settings/SecuritySettings.tsx | 82 ++++++-- .../tic/__tests__/bankid-complete.test.ts | 4 +- extensions/general/tic/index.ts | 17 +- lib/api/__tests__/schemas.test.ts | 42 ++++ lib/api/schemas.ts | 14 ++ lib/auth/__tests__/has-password.test.ts | 31 +++ lib/auth/__tests__/safe-return-to.test.ts | 42 ++++ lib/auth/has-password.ts | 20 ++ lib/auth/safe-return-to.ts | 27 +++ .../__tests__/reminder-processor.test.ts | 36 +++- lib/invoices/pdf-template.tsx | 6 + lib/invoices/reminder-processor.ts | 39 +++- lib/payments/__tests__/swish.test.ts | 39 ++++ lib/payments/swish.ts | 23 +++ lib/reports/__tests__/sie-export.test.ts | 79 ++++++++ lib/reports/sie-export.ts | 35 ++-- lib/supabase/middleware.ts | 32 ++- scripts/backfill-has-password.ts | 105 ++++++++++ .../20260521120000_company_settings_swish.sql | 11 ++ ...ompany_settings_send_invoice_reminders.sql | 9 + tests/helpers.ts | 3 + types/index.ts | 5 + 32 files changed, 1327 insertions(+), 76 deletions(-) create mode 100644 app/(auth)/account/set-password/page.tsx create mode 100644 app/api/account/password/__tests__/route.test.ts create mode 100644 app/api/account/password/route.ts create mode 100644 components/invoices/FirstInvoiceLogoPrompt.tsx create mode 100644 lib/auth/__tests__/has-password.test.ts create mode 100644 lib/auth/__tests__/safe-return-to.test.ts create mode 100644 lib/auth/has-password.ts create mode 100644 lib/auth/safe-return-to.ts create mode 100644 lib/payments/__tests__/swish.test.ts create mode 100644 lib/payments/swish.ts create mode 100644 scripts/backfill-has-password.ts create mode 100644 supabase/migrations/20260521120000_company_settings_swish.sql create mode 100644 supabase/migrations/20260521120100_company_settings_send_invoice_reminders.sql diff --git a/app/(auth)/account/set-password/page.tsx b/app/(auth)/account/set-password/page.tsx new file mode 100644 index 00000000..d4a6e849 --- /dev/null +++ b/app/(auth)/account/set-password/page.tsx @@ -0,0 +1,187 @@ +'use client' + +import { useState, useEffect, Suspense } from 'react' +import { useRouter, useSearchParams } from 'next/navigation' +import { createClient } from '@/lib/supabase/client' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { useToast } from '@/components/ui/use-toast' +import { Loader2, KeyRound } from 'lucide-react' +import { userHasPassword } from '@/lib/auth/has-password' +import { safeReturnTo } from '@/lib/auth/safe-return-to' + +export default function SetPasswordPage() { + return ( + + + + ) +} + +function SetPasswordContent() { + const [password, setPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [isLoading, setIsLoading] = useState(false) + const { toast } = useToast() + const router = useRouter() + const searchParams = useSearchParams() + const supabase = createClient() + + const returnTo = safeReturnTo(searchParams.get('returnTo'), '/settings/account') + + // Users who already have a password don't belong here — bounce them away. + useEffect(() => { + let cancelled = false + ;(async () => { + const { + data: { user }, + } = await supabase.auth.getUser() + if (cancelled) return + if (!user) { + router.replace('/login') + return + } + if (userHasPassword(user)) { + router.replace(returnTo) + } + })() + return () => { + cancelled = true + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const handleSetPassword = async (e: React.FormEvent) => { + e.preventDefault() + setIsLoading(true) + + const strong = + password.length >= 8 && + /[a-z]/.test(password) && + /[A-Z]/.test(password) && + /[0-9]/.test(password) && + /[^a-zA-Z0-9]/.test(password) + + if (!strong) { + toast({ + title: 'Lösenordet är för svagt', + description: + 'Lösenordet måste vara minst 8 tecken och innehålla versaler, gemener, siffror och specialtecken.', + variant: 'destructive', + }) + setIsLoading(false) + return + } + + if (password !== confirmPassword) { + toast({ + title: 'Lösenorden matchar inte', + description: 'Kontrollera att du skrev samma lösenord i båda fälten.', + variant: 'destructive', + }) + setIsLoading(false) + return + } + + try { + const res = await fetch('/api/account/password', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password }), + }) + + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as { error?: string } + toast({ + title: 'Kunde inte spara lösenord', + description: body.error || 'Försök igen senare.', + variant: 'destructive', + }) + return + } + + toast({ + title: 'Lösenord sparat', + description: 'Du kan nu aktivera tvåfaktorsautentisering.', + }) + + router.push(returnTo) + router.refresh() + } catch { + toast({ + title: 'Något gick fel', + description: 'Försök igen senare.', + variant: 'destructive', + }) + } finally { + setIsLoading(false) + } + } + + return ( +
+
+
+
+
+ +
+
+

+ Sätt ett lösenord +

+

+ Du loggade in med BankID. För att aktivera tvåfaktorsautentisering + eller logga in med e-post behöver du först sätta ett lösenord. +

+
+ +
+
+
+ + setPassword(e.target.value)} + required + minLength={8} + disabled={isLoading} + className="h-11" + /> +
+
+ + setConfirmPassword(e.target.value)} + required + minLength={8} + disabled={isLoading} + className="h-11" + /> +
+ +
+
+
+
+ ) +} diff --git a/app/(auth)/mfa/enroll/page.tsx b/app/(auth)/mfa/enroll/page.tsx index bb96cafe..eed9c2f8 100644 --- a/app/(auth)/mfa/enroll/page.tsx +++ b/app/(auth)/mfa/enroll/page.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState, useRef, Suspense } from 'react' +import { useState, useRef, useEffect, Suspense } from 'react' import { useRouter, useSearchParams } from 'next/navigation' import { createClient } from '@/lib/supabase/client' import { Button } from '@/components/ui/button' @@ -9,6 +9,8 @@ import { Label } from '@/components/ui/label' import { useToast } from '@/components/ui/use-toast' import { Loader2, ShieldCheck, Copy, Check, ArrowLeft } from 'lucide-react' import { getBranding } from '@/lib/branding/service' +import { userHasPassword } from '@/lib/auth/has-password' +import { safeReturnTo } from '@/lib/auth/safe-return-to' export default function MfaEnrollPage() { return ( @@ -32,8 +34,32 @@ function MfaEnrollContent() { const searchParams = useSearchParams() const supabase = createClient() - const rawReturnTo = searchParams.get('returnTo') || '/' - const returnTo = rawReturnTo.startsWith('/') && !rawReturnTo.startsWith('//') ? rawReturnTo : '/' + const returnTo = safeReturnTo(searchParams.get('returnTo'), '/') + + // UX defense — middleware already blocks this route for BankID-only users + // without a password, but a stale tab might land here too. Bounce them to + // the set-password flow before they enroll a factor they cannot later + // un-enroll without AAL2. + useEffect(() => { + let cancelled = false + ;(async () => { + const { + data: { user }, + } = await supabase.auth.getUser() + if (cancelled || !user) return + if (!userHasPassword(user)) { + router.replace( + `/account/set-password?returnTo=${encodeURIComponent( + `/mfa/enroll?returnTo=${encodeURIComponent(returnTo)}`, + )}`, + ) + } + })() + return () => { + cancelled = true + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) const handleEnroll = async () => { setIsEnrolling(true) diff --git a/app/(auth)/reset-password/page.tsx b/app/(auth)/reset-password/page.tsx index dd6f130b..597a5c87 100644 --- a/app/(auth)/reset-password/page.tsx +++ b/app/(auth)/reset-password/page.tsx @@ -2,7 +2,6 @@ import { useState } from 'react' import { useRouter } from 'next/navigation' -import { createClient } from '@/lib/supabase/client' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' @@ -15,7 +14,6 @@ export default function ResetPasswordPage() { const [isLoading, setIsLoading] = useState(false) const { toast } = useToast() const router = useRouter() - const supabase = createClient() const handleResetPassword = async (e: React.FormEvent) => { e.preventDefault() @@ -48,12 +46,22 @@ export default function ResetPasswordPage() { } try { - const { error } = await supabase.auth.updateUser({ password }) + // Routed through the API so the has_password flag flips in lock-step + // with the password update. This is the unlock path for BankID-only + // users who enrolled MFA and got locked out — the recovery session + // bypasses AAL2, the API flips has_password, and the lockout banner + // disappears the next time they log in. + const res = await fetch('/api/account/password', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password }), + }) - if (error) { + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as { error?: string } toast({ title: 'Kunde inte uppdatera lösenord', - description: error.message, + description: body.error || 'Försök igen senare.', variant: 'destructive', }) return diff --git a/app/(dashboard)/invoices/new/page.tsx b/app/(dashboard)/invoices/new/page.tsx index 88562f4d..6e0aba63 100644 --- a/app/(dashboard)/invoices/new/page.tsx +++ b/app/(dashboard)/invoices/new/page.tsx @@ -27,6 +27,7 @@ import { getErrorMessage } from '@/lib/errors/get-error-message' import { useUnsavedChanges } from '@/lib/hooks/use-unsaved-changes' import CustomerForm from '@/components/customers/CustomerForm' import { BankDetailsSetupDialog } from '@/components/invoices/BankDetailsSetupDialog' +import { FirstInvoiceLogoPrompt } from '@/components/invoices/FirstInvoiceLogoPrompt' import { useCompany } from '@/contexts/CompanyContext' import type { Customer, Currency, CreateInvoiceInput, CreateCustomerInput, InvoiceDocumentType } from '@/types' @@ -85,6 +86,12 @@ export default function NewInvoicePage() { const [accountingMethod, setAccountingMethod] = useState<'accrual' | 'cash'>('accrual') const [oreRounding, setOreRounding] = useState(true) const [numberPreview, setNumberPreview] = useState(null) + const [logoUrl, setLogoUrl] = useState(null) + // True only when the user had zero invoices when this page loaded. The + // post-create flow uses this to offer a one-shot "upload a logo?" prompt + // — issue #520. Self-limits: once count > 0 it stays false. + const [hadZeroInvoices, setHadZeroInvoices] = useState(null) + const [showLogoPrompt, setShowLogoPrompt] = useState(false) const pendingCustomerRef = useRef(null) const { @@ -144,7 +151,7 @@ export default function NewInvoicePage() { if (!company?.id) return const { data } = await supabase .from('company_settings') - .select('invoice_default_notes, clearing_number, account_number, bankgiro, accounting_method, ore_rounding') + .select('invoice_default_notes, clearing_number, account_number, bankgiro, accounting_method, ore_rounding, logo_url') .eq('company_id', company.id) .single() if (data?.invoice_default_notes) { @@ -160,8 +167,29 @@ export default function NewInvoicePage() { if (typeof data?.ore_rounding === 'boolean') { setOreRounding(data.ore_rounding) } + setLogoUrl(data?.logo_url ?? null) } + // First-invoice detection (issue #520): captured at page load so the + // post-create flow can offer the logo prompt for genuinely first-time + // invoices only. head:true keeps it cheap — no rows pulled. + useEffect(() => { + if (!company?.id) return + let cancelled = false + ;(async () => { + const { count } = await supabase + .from('invoices') + .select('id', { count: 'exact', head: true }) + .eq('company_id', company.id) + if (!cancelled) setHadZeroInvoices(count === 0 || count === null) + })() + return () => { + cancelled = true + } + // supabase is a stable reference from createClient() at top of component + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [company?.id]) + // Preview the next invoice number so the user can catch a mis-set // sequence/prefix before committing. The actual allocator still runs // atomically at create time; this is read-only. @@ -319,6 +347,16 @@ export default function NewInvoicePage() { } } + function handleLogoPromptClose() { + setShowLogoPrompt(false) + // Resume the post-create flow that was deferred by the logo prompt. + if (selectedCustomer?.email && createdInvoiceId) { + setShowSendPrompt(true) + } else if (createdInvoiceId) { + router.push(`/invoices/${createdInvoiceId}`) + } + } + async function handleConfirm() { if (!pendingData) return setIsSubmitting(true) @@ -343,10 +381,15 @@ export default function NewInvoicePage() { }) setShowReview(false) + setCreatedInvoiceId(result.data.id) - // If customer has email, offer to send immediately - if (selectedCustomer?.email) { - setCreatedInvoiceId(result.data.id) + // First-invoice-only logo prompt (issue #520) takes priority over the + // send-now dialog so a fresh upload makes it onto the just-sent PDF + // (pdf-template reads logo_url live from company_settings). Once the + // prompt closes, handleLogoPromptClose resumes the regular flow. + if (hadZeroInvoices === true && !logoUrl) { + setShowLogoPrompt(true) + } else if (selectedCustomer?.email) { setShowSendPrompt(true) } else { router.push(`/invoices/${result.data.id}`) @@ -932,6 +975,14 @@ export default function NewInvoicePage() { onComplete={handleBankSetupComplete} /> + {/* First-invoice logo prompt (issue #520) */} + setLogoUrl(url)} + /> + {/* Send now prompt dialog */} { if (!open && createdInvoiceId) { diff --git a/app/(dashboard)/settings/invoicing/page.tsx b/app/(dashboard)/settings/invoicing/page.tsx index 51c80d74..592f91f4 100644 --- a/app/(dashboard)/settings/invoicing/page.tsx +++ b/app/(dashboard)/settings/invoicing/page.tsx @@ -7,6 +7,7 @@ import { SettingsFormWrapper } from '@/components/settings/SettingsFormWrapper' import { SettingsLoadingSkeleton } from '@/components/settings/SettingsLoadingSkeleton' import { useSettings } from '@/components/settings/useSettings' import { useToast } from '@/components/ui/use-toast' +import { normaliseSwish } from '@/lib/payments/swish' import type { CompanySettings } from '@/types' export default function InvoicingSettingsPage() { @@ -31,6 +32,7 @@ export default function InvoicingSettingsPage() { clearing_number: formData.get('clearing_number') as string, account_number: formData.get('account_number') as string, bankgiro: (formData.get('bankgiro') as string) || null, + swish: normaliseSwish(formData.get('swish') as string) || null, invoice_prefix: (formData.get('invoice_prefix') as string) || null, next_invoice_number: parseInt(formData.get('next_invoice_number') as string) || 1, invoice_default_days: parseInt(formData.get('invoice_default_days') as string) || 30, diff --git a/app/api/account/password/__tests__/route.test.ts b/app/api/account/password/__tests__/route.test.ts new file mode 100644 index 00000000..9dca9705 --- /dev/null +++ b/app/api/account/password/__tests__/route.test.ts @@ -0,0 +1,147 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { createMockRequest, parseJsonResponse } from '@/tests/helpers' + +vi.mock('@/lib/supabase/server', () => ({ + createClient: vi.fn(), + createServiceClient: vi.fn(), +})) + +import { createClient, createServiceClient } from '@/lib/supabase/server' +import { POST } from '../route' + +const mockCreateClient = vi.mocked(createClient) +const mockCreateServiceClient = vi.mocked(createServiceClient) + +function mockUserClient(opts: { + user: { id: string } | null + updateUserError?: { message: string; status?: number; code?: string } | null +}) { + const updateUser = vi.fn().mockResolvedValue({ + data: {}, + error: opts.updateUserError ?? null, + }) + + mockCreateClient.mockResolvedValue({ + auth: { + getUser: vi.fn().mockResolvedValue({ data: { user: opts.user } }), + updateUser, + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any) + + return { updateUser } +} + +function mockService(opts: { + priorAppMetadata?: Record + updateUserByIdError?: Error | null +}) { + const updateUserById = opts.updateUserByIdError + ? vi.fn().mockRejectedValue(opts.updateUserByIdError) + : vi.fn().mockResolvedValue({ data: {}, error: null }) + + const getUserById = vi.fn().mockResolvedValue({ + data: { user: { app_metadata: opts.priorAppMetadata ?? {} } }, + }) + + mockCreateServiceClient.mockReturnValue({ + auth: { admin: { getUserById, updateUserById } }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any) + + return { getUserById, updateUserById } +} + +const STRONG_PASSWORD = 'StrongP@ssword1' + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('POST /api/account/password', () => { + it('returns 401 when unauthenticated', async () => { + mockUserClient({ user: null }) + mockService({}) + + const req = createMockRequest('/api/account/password', { + method: 'POST', + body: { password: STRONG_PASSWORD }, + }) + const { status } = await parseJsonResponse(await POST(req)) + expect(status).toBe(401) + }) + + it('returns 400 when password is too weak', async () => { + mockUserClient({ user: { id: 'user-1' } }) + mockService({}) + + const req = createMockRequest('/api/account/password', { + method: 'POST', + body: { password: 'weak' }, + }) + const { status } = await parseJsonResponse(await POST(req)) + expect(status).toBe(400) + }) + + it('returns 400 when Supabase rejects the password update', async () => { + const { updateUser } = mockUserClient({ + user: { id: 'user-1' }, + updateUserError: { message: 'Password too similar to old', status: 400 }, + }) + const { updateUserById } = mockService({}) + + const req = createMockRequest('/api/account/password', { + method: 'POST', + body: { password: STRONG_PASSWORD }, + }) + const { status, body } = await parseJsonResponse<{ error?: string }>( + await POST(req), + ) + expect(status).toBe(400) + expect(body.error).toContain('Password too similar') + expect(updateUser).toHaveBeenCalledWith({ password: STRONG_PASSWORD }) + // Flag should NOT be flipped on a failed password update + expect(updateUserById).not.toHaveBeenCalled() + }) + + it('flips app_metadata.has_password to true on success and preserves siblings', async () => { + const { updateUser } = mockUserClient({ user: { id: 'user-1' } }) + const { getUserById, updateUserById } = mockService({ + priorAppMetadata: { bankid_linked: true, provider: 'email' }, + }) + + const req = createMockRequest('/api/account/password', { + method: 'POST', + body: { password: STRONG_PASSWORD }, + }) + const { status, body } = await parseJsonResponse<{ data?: { ok: boolean } }>( + await POST(req), + ) + expect(status).toBe(200) + expect(body.data?.ok).toBe(true) + expect(updateUser).toHaveBeenCalledWith({ password: STRONG_PASSWORD }) + expect(getUserById).toHaveBeenCalledWith('user-1') + expect(updateUserById).toHaveBeenCalledWith('user-1', { + app_metadata: { + bankid_linked: true, + provider: 'email', + has_password: true, + }, + }) + }) + + it('still returns success when the flag flip fails (password is set; logged)', async () => { + mockUserClient({ user: { id: 'user-1' } }) + mockService({ updateUserByIdError: new Error('admin down') }) + + const req = createMockRequest('/api/account/password', { + method: 'POST', + body: { password: STRONG_PASSWORD }, + }) + const { status, body } = await parseJsonResponse<{ data?: { ok: boolean } }>( + await POST(req), + ) + expect(status).toBe(200) + expect(body.data?.ok).toBe(true) + }) +}) diff --git a/app/api/account/password/route.ts b/app/api/account/password/route.ts new file mode 100644 index 00000000..602b311a --- /dev/null +++ b/app/api/account/password/route.ts @@ -0,0 +1,93 @@ +import { createClient, createServiceClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { z } from 'zod' +import { validateBody } from '@/lib/api/validate' +import { createLogger } from '@/lib/logger' + +const log = createLogger('api/account/password') + +const SetPasswordSchema = z.object({ + password: z + .string() + .min(8, 'Lösenordet måste vara minst 8 tecken') + .refine( + (v) => + /[a-z]/.test(v) && + /[A-Z]/.test(v) && + /[0-9]/.test(v) && + /[^a-zA-Z0-9]/.test(v), + 'Lösenordet måste innehålla versaler, gemener, siffror och specialtecken', + ), +}) + +/** + * POST /api/account/password + * + * Server-routed password set/change. Wraps `supabase.auth.updateUser({ password })` + * on the user's own session, then flips `app_metadata.has_password = true` via the + * service client (clients can't write app_metadata). + * + * This route is the single write path for setting a password. SecuritySettings, + * the reset-password page, and the new /account/set-password page all funnel + * through here so the flag stays in sync — see lib/auth/has-password.ts. + * + * If the password update succeeds but the flag write fails, we log and still + * return success: the user has a working password and the banner will show one + * more time, but a retry will re-flip the flag. + */ +export async function POST(request: Request) { + const supabase = await createClient() + + const { + data: { user }, + } = await supabase.auth.getUser() + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const result = await validateBody(request, SetPasswordSchema) + if (!result.success) return result.response + const { password } = result.data + + const { error: updateError } = await supabase.auth.updateUser({ password }) + if (updateError) { + log.warn('updateUser({password}) failed', { + userId: user.id, + code: (updateError as { code?: string }).code, + status: updateError.status, + }) + return NextResponse.json( + { + error: + updateError.message || + 'Kunde inte uppdatera lösenord. Försök igen.', + }, + { status: 400 }, + ) + } + + // Read-merge-write so we don't wipe sibling app_metadata keys. + // updateUserById replaces app_metadata wholesale (see lib/auth/has-password.ts + // and the comment in app/api/account/delete/route.ts). + const service = createServiceClient() + let flagWriteOk = false + try { + const { data: u } = await service.auth.admin.getUserById(user.id) + const prior = u?.user?.app_metadata ?? {} + await service.auth.admin.updateUserById(user.id, { + app_metadata: { ...prior, has_password: true }, + }) + flagWriteOk = true + } catch (err) { + log.error('failed to flip has_password flag after successful password set', { + userId: user.id, + err, + }) + // Don't surface the failure: the user has a working password. The + // banner will show once more and a retry will succeed. + } + + log.info('password set', { userId: user.id, flagWriteOk }) + + return NextResponse.json({ data: { ok: true } }) +} diff --git a/components/invoices/FirstInvoiceLogoPrompt.tsx b/components/invoices/FirstInvoiceLogoPrompt.tsx new file mode 100644 index 00000000..ac3534cf --- /dev/null +++ b/components/invoices/FirstInvoiceLogoPrompt.tsx @@ -0,0 +1,67 @@ +'use client' + +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { LogoUpload } from '@/components/settings/LogoUpload' + +interface FirstInvoiceLogoPromptProps { + open: boolean + onClose: () => void + logoUrl: string | null + onLogoUpdate: (url: string | null) => void +} + +/** + * One-shot dialog offered after the user creates their first invoice and has + * no logo on file. Reuses the same LogoUpload control as settings, so the + * upload, MIME validation, and preview are identical to the canonical flow. + * + * The PDF reads logo_url live at render time, so uploading now still applies + * to the just-created invoice when it is later previewed or sent. + */ +export function FirstInvoiceLogoPrompt({ + open, + onClose, + logoUrl, + onLogoUpdate, +}: FirstInvoiceLogoPromptProps) { + return ( + !o && onClose()}> + + + + Lägg till en logotyp? + + + Din första faktura är skapad. Vill du ladda upp en logotyp som + visas i sidhuvudet? Du kan ändra den senare i{' '} + + Inställningar + + . + + + +
+ +
+ + + + +
+
+ ) +} diff --git a/components/settings/BankDetailsForm.tsx b/components/settings/BankDetailsForm.tsx index 3d471649..21aa8592 100644 --- a/components/settings/BankDetailsForm.tsx +++ b/components/settings/BankDetailsForm.tsx @@ -5,6 +5,7 @@ import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { BankNameCombobox } from '@/components/settings/BankNameCombobox' import { validateBankgiroNumber, formatBankgiroNumber } from '@/lib/bankgiro/luhn' +import { normaliseSwish, isValidSwish } from '@/lib/payments/swish' import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions' import type { CompanySettings } from '@/types' @@ -16,6 +17,7 @@ export function BankDetailsForm({ settings }: BankDetailsFormProps) { const [bankgiroError, setBankgiroError] = useState(null) const [clearingError, setClearingError] = useState(null) const [accountNumberError, setAccountNumberError] = useState(null) + const [swishError, setSwishError] = useState(null) const hasBankingExtension = ENABLED_EXTENSION_IDS.has('enable-banking') return ( @@ -77,25 +79,48 @@ export function BankDetailsForm({ settings }: BankDetailsFormProps) { -
- - { - const val = e.target.value.trim() - if (!val) { setBankgiroError(null); return } - if (validateBankgiroNumber(val)) { - e.target.value = formatBankgiroNumber(val) - setBankgiroError(null) - } else { - setBankgiroError('Ogiltigt bankgironummer') - } - }} - /> - {bankgiroError &&

{bankgiroError}

} +
+
+ + { + const val = e.target.value.trim() + if (!val) { setBankgiroError(null); return } + if (validateBankgiroNumber(val)) { + e.target.value = formatBankgiroNumber(val) + setBankgiroError(null) + } else { + setBankgiroError('Ogiltigt bankgironummer') + } + }} + /> + {bankgiroError &&

{bankgiroError}

} +
+ +
+ + { + const val = normaliseSwish(e.target.value) + if (!val) { setSwishError(null); e.target.value = ''; return } + if (isValidSwish(val)) { + e.target.value = val + setSwishError(null) + } else { + setSwishError('Ogiltigt Swish-nummer (företagsnummer 123XXXXXXX eller mobilnummer 07XXXXXXXX)') + } + }} + /> + {swishError &&

{swishError}

} +
) @@ -107,6 +132,7 @@ export function validateBankFields(formData: FormData): { field: string; message const clearing = (formData.get('clearing_number') as string || '').trim() const account = (formData.get('account_number') as string || '').trim() const bankgiro = (formData.get('bankgiro') as string || '').trim() + const swish = normaliseSwish(formData.get('swish') as string) if (clearing && !/^\d{4,5}$/.test(clearing)) { errors.push({ field: 'clearing_number', message: 'Clearingnummer måste vara 4-5 siffror' }) @@ -117,5 +143,8 @@ export function validateBankFields(formData: FormData): { field: string; message if (bankgiro && !validateBankgiroNumber(bankgiro)) { errors.push({ field: 'bankgiro', message: 'Ogiltigt bankgironummer' }) } + if (swish && !isValidSwish(swish)) { + errors.push({ field: 'swish', message: 'Ogiltigt Swish-nummer (företagsnummer 123XXXXXXX eller mobilnummer 07XXXXXXXX)' }) + } return errors } diff --git a/components/settings/PdfPrintSettings.tsx b/components/settings/PdfPrintSettings.tsx index 1af960b3..0ad233f1 100644 --- a/components/settings/PdfPrintSettings.tsx +++ b/components/settings/PdfPrintSettings.tsx @@ -110,6 +110,17 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps) />
+
+
+ +

Visa Swish-nummer på fakturautskrift

+
+ saveToggle('invoice_show_swish', v)} + /> +
+
@@ -190,6 +201,25 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps) />
+ +
+

+ Automatisering +

+ +
+
+ +

+ Skicka påminnelser till kunder för försenade fakturor enligt din inställning för påminnelseintervall. +

+
+ saveToggle('send_invoice_reminders', v)} + /> +
+
) } diff --git a/components/settings/SecuritySettings.tsx b/components/settings/SecuritySettings.tsx index 48b6f0d4..7247c88c 100644 --- a/components/settings/SecuritySettings.tsx +++ b/components/settings/SecuritySettings.tsx @@ -12,6 +12,7 @@ import { Loader2, ShieldCheck, ShieldOff, KeyRound } from 'lucide-react' import { isMfaRequired } from '@/lib/auth/mfa' import { isBankIdEnabled } from '@/lib/auth/bankid' import { BankIdSettings } from '@/components/settings/BankIdSettings' +import { userHasPassword } from '@/lib/auth/has-password' const isSelfHosted = process.env.NEXT_PUBLIC_SELF_HOSTED === 'true' const mfaRequired = isMfaRequired() @@ -25,19 +26,24 @@ export function SecuritySettings() { const [isLoadingMfa, setIsLoadingMfa] = useState(true) const [isUnenrolling, setIsUnenrolling] = useState(false) const [mfaFactorId, setMfaFactorId] = useState(null) + const [hasPassword, setHasPassword] = useState(null) const { toast } = useToast() const router = useRouter() const supabase = createClient() useEffect(() => { - async function loadMfaStatus() { - const { data } = await supabase.auth.mfa.listFactors() - const verifiedFactor = data?.totp?.find(f => f.status === 'verified') + async function loadStatus() { + const [{ data: factors }, { data: userData }] = await Promise.all([ + supabase.auth.mfa.listFactors(), + supabase.auth.getUser(), + ]) + const verifiedFactor = factors?.totp?.find(f => f.status === 'verified') setHasMfa(!!verifiedFactor) setMfaFactorId(verifiedFactor?.id ?? null) + setHasPassword(userData?.user ? userHasPassword(userData.user) : null) setIsLoadingMfa(false) } - loadMfaStatus() + loadStatus() // eslint-disable-next-line react-hooks/exhaustive-deps }, []) @@ -72,12 +78,17 @@ export function SecuritySettings() { } try { - const { error } = await supabase.auth.updateUser({ password: newPassword }) + const res = await fetch('/api/account/password', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password: newPassword }), + }) - if (error) { + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as { error?: string } toast({ title: 'Kunde inte uppdatera lösenord', - description: error.message, + description: body.error || 'Försök igen senare.', variant: 'destructive', }) return @@ -89,6 +100,7 @@ export function SecuritySettings() { }) setNewPassword('') setConfirmPassword('') + setHasPassword(true) } catch { toast({ title: 'Något gick fel', @@ -136,7 +148,36 @@ export function SecuritySettings() { return (
{bankIdEnabled && } - {/* Change password */} + + {/* BankID-only users with no password — banner above everything else */} + {hasPassword === false && ( + + + + + Sätt ett lösenord + + + Du loggade in med BankID och har inget lösenord ännu. Sätt ett + lösenord för att kunna aktivera 2FA eller logga in när BankID + inte är tillgängligt. + + + + + + + )} + + {/* Change password — hidden when the user has no password (the banner + above handles the set-initial-password flow). */} + {hasPassword !== false && ( @@ -190,6 +231,7 @@ export function SecuritySettings() { + )} {/* MFA — hidden for self-hosted */} {!isSelfHosted && ( @@ -257,12 +299,24 @@ export function SecuritySettings() {

- + {hasPassword === false ? ( + + ) : ( + + )} )} diff --git a/extensions/general/tic/__tests__/bankid-complete.test.ts b/extensions/general/tic/__tests__/bankid-complete.test.ts index 0c53a8a5..7fe76c4c 100644 --- a/extensions/general/tic/__tests__/bankid-complete.test.ts +++ b/extensions/general/tic/__tests__/bankid-complete.test.ts @@ -159,7 +159,9 @@ describe('POST /bankid/complete', () => { ) expect(admin.updateUserById).toHaveBeenCalledWith( 'new-user-uuid', - expect.objectContaining({ app_metadata: { bankid_linked: true } }) + expect.objectContaining({ + app_metadata: { bankid_linked: true, has_password: false }, + }) ) }) }) diff --git a/extensions/general/tic/index.ts b/extensions/general/tic/index.ts index 58e467b6..f039a8ca 100644 --- a/extensions/general/tic/index.ts +++ b/extensions/general/tic/index.ts @@ -756,9 +756,12 @@ export const ticExtension: Extension = { const userId = newUser.user.id - // Mark user as BankID-linked (skips TOTP MFA) + // Mark user as BankID-linked (skips TOTP MFA) and record that they + // do not have a password yet — the BankID signup gave them a random + // server-side password they will never see. This flag gates MFA + // enrollment (see lib/auth/has-password.ts). await supabase.auth.admin.updateUserById(userId, { - app_metadata: { bankid_linked: true }, + app_metadata: { bankid_linked: true, has_password: false }, }) // Store BankID identity @@ -911,9 +914,15 @@ export const ticExtension: Extension = { ) } - // Mark user as BankID-linked (skips TOTP MFA) + // Read-merge-write: updateUserById REPLACES app_metadata wholesale + // (see app/api/account/password/route.ts). Passing just + // { bankid_linked: true } would wipe has_password for users who + // already set one — they'd then be incorrectly shown the + // set-password banner on their next session. + const { data: priorUser } = await supabase.auth.admin.getUserById(ctx.userId) + const priorMeta = priorUser?.user?.app_metadata ?? {} await supabase.auth.admin.updateUserById(ctx.userId, { - app_metadata: { bankid_linked: true }, + app_metadata: { ...priorMeta, bankid_linked: true }, }) return NextResponse.json({ data: { linked: true } }) diff --git a/lib/api/__tests__/schemas.test.ts b/lib/api/__tests__/schemas.test.ts index da10d836..56feaa71 100644 --- a/lib/api/__tests__/schemas.test.ts +++ b/lib/api/__tests__/schemas.test.ts @@ -1129,6 +1129,48 @@ describe('UpdateSettingsSchema', () => { const result = UpdateSettingsSchema.safeParse({ invoice_default_days: 30.5 }) expect(result.success).toBe(false) }) + + describe('swish', () => { + it('accepts a Swish-företag number (123XXXXXXX)', () => { + const result = UpdateSettingsSchema.safeParse({ swish: '1234567890' }) + expect(result.success).toBe(true) + if (result.success) expect(result.data.swish).toBe('1234567890') + }) + + it('accepts a Swedish mobile number (07XXXXXXXX)', () => { + const result = UpdateSettingsSchema.safeParse({ swish: '0701234567' }) + expect(result.success).toBe(true) + if (result.success) expect(result.data.swish).toBe('0701234567') + }) + + it('strips whitespace and hyphens before validating', () => { + const result = UpdateSettingsSchema.safeParse({ swish: '123 456 78 90' }) + expect(result.success).toBe(true) + if (result.success) expect(result.data.swish).toBe('1234567890') + }) + + it('rejects a non-Swish-företag, non-mobile number', () => { + const result = UpdateSettingsSchema.safeParse({ swish: '0123456789' }) + expect(result.success).toBe(false) + }) + + it('accepts empty string for clearing the value', () => { + const result = UpdateSettingsSchema.safeParse({ swish: '' }) + expect(result.success).toBe(true) + }) + + it('accepts invoice_show_swish toggle', () => { + const result = UpdateSettingsSchema.safeParse({ invoice_show_swish: false }) + expect(result.success).toBe(true) + }) + }) + + describe('send_invoice_reminders', () => { + it('accepts the kill-switch toggle', () => { + const result = UpdateSettingsSchema.safeParse({ send_invoice_reminders: false }) + expect(result.success).toBe(true) + }) + }) }) // ============================================================ diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index 0bea2c4a..963aad3d 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -1,4 +1,5 @@ import { z } from 'zod' +import { normaliseSwish, isValidSwish } from '@/lib/payments/swish' // ============================================================ // Shared primitives @@ -481,6 +482,16 @@ export const UpdateSettingsSchema = z.object({ account_number: z.string().regex(/^\d{6,12}$/, 'Kontonummer måste vara 6-12 siffror').optional().or(z.literal('')), bankgiro: z.string().regex(/^(\d{3,4}-\d{4}|\d{7,8})$/, 'Ogiltigt bankgironummer (7-8 siffror)').nullable().optional().or(z.literal('')), plusgiro: z.string().regex(/^\d{1,7}-\d{1}$/, 'Ogiltigt plusgironummer').nullable().optional().or(z.literal('')), + swish: z.string() + .transform(normaliseSwish) + .pipe( + z.string().refine( + isValidSwish, + 'Ogiltigt Swish-nummer (företagsnummer 123XXXXXXX eller mobilnummer 07XXXXXXXX)', + ), + ) + .nullable() + .optional(), iban: z.string().optional(), bic: z.string().optional(), accounting_method: AccountingMethodSchema.optional(), @@ -503,11 +514,14 @@ export const UpdateSettingsSchema = z.object({ invoice_show_ocr: z.boolean().optional(), invoice_show_bankgiro: z.boolean().optional(), invoice_show_plusgiro: z.boolean().optional(), + invoice_show_swish: z.boolean().optional(), invoice_show_logo: z.boolean().optional(), invoice_show_company_name: z.boolean().optional(), invoice_company_name_position: z.enum(['header', 'footer']).optional(), invoice_late_fee_text: z.string().nullable().optional(), invoice_credit_terms_text: z.string().nullable().optional(), + // Automation + send_invoice_reminders: z.boolean().optional(), // AI agent flow ai_flow_enabled: z.boolean().optional(), // Salary payment file diff --git a/lib/auth/__tests__/has-password.test.ts b/lib/auth/__tests__/has-password.test.ts new file mode 100644 index 00000000..3eca902f --- /dev/null +++ b/lib/auth/__tests__/has-password.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from 'vitest' +import { userHasPassword } from '../has-password' + +describe('userHasPassword', () => { + it('returns true when has_password === true', () => { + expect(userHasPassword({ app_metadata: { has_password: true } })).toBe(true) + }) + + it('returns false when has_password === false', () => { + expect(userHasPassword({ app_metadata: { has_password: false } })).toBe(false) + }) + + it('returns false when flag is missing but bankid_linked === true', () => { + expect(userHasPassword({ app_metadata: { bankid_linked: true } })).toBe(false) + }) + + it('returns true when flag is missing and bankid_linked is not true (legacy email/password user)', () => { + expect(userHasPassword({ app_metadata: {} })).toBe(true) + expect(userHasPassword({ app_metadata: { bankid_linked: false } })).toBe(true) + }) + + it('returns true when app_metadata is missing entirely', () => { + expect(userHasPassword({ app_metadata: undefined as unknown as Record })).toBe(true) + }) + + it('prefers explicit has_password over bankid_linked (BankID user who later set a password)', () => { + expect( + userHasPassword({ app_metadata: { bankid_linked: true, has_password: true } }), + ).toBe(true) + }) +}) diff --git a/lib/auth/__tests__/safe-return-to.test.ts b/lib/auth/__tests__/safe-return-to.test.ts new file mode 100644 index 00000000..3f14d2fe --- /dev/null +++ b/lib/auth/__tests__/safe-return-to.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from 'vitest' +import { safeReturnTo } from '../safe-return-to' + +describe('safeReturnTo', () => { + it('returns the value when it is a same-origin relative path', () => { + expect(safeReturnTo('/settings/account', '/')).toBe('/settings/account') + expect(safeReturnTo('/invoices/new', '/')).toBe('/invoices/new') + }) + + it('preserves search and hash', () => { + expect(safeReturnTo('/invoices?status=overdue', '/')).toBe('/invoices?status=overdue') + expect(safeReturnTo('/settings/account#mfa', '/')).toBe('/settings/account#mfa') + }) + + it('returns the fallback for missing or empty values', () => { + expect(safeReturnTo(null, '/home')).toBe('/home') + expect(safeReturnTo(undefined, '/home')).toBe('/home') + expect(safeReturnTo('', '/home')).toBe('/home') + }) + + it('rejects absolute URLs', () => { + expect(safeReturnTo('https://evil.com/path', '/')).toBe('/') + expect(safeReturnTo('http://evil.com', '/')).toBe('/') + }) + + it('rejects protocol-relative URLs', () => { + expect(safeReturnTo('//evil.com/path', '/')).toBe('/') + }) + + it('rejects the /\\evil.com browser-quirk form', () => { + expect(safeReturnTo('/\\evil.com', '/')).toBe('/') + }) + + it('rejects the /@user@host form some clients resolve off-origin', () => { + expect(safeReturnTo('/@evil.com', '/')).toBe('/') + }) + + it('rejects values that do not start with /', () => { + expect(safeReturnTo('settings', '/')).toBe('/') + expect(safeReturnTo('javascript:alert(1)', '/')).toBe('/') + }) +}) diff --git a/lib/auth/has-password.ts b/lib/auth/has-password.ts new file mode 100644 index 00000000..f7d1b25f --- /dev/null +++ b/lib/auth/has-password.ts @@ -0,0 +1,20 @@ +import type { User } from '@supabase/supabase-js' + +/** + * True iff the user has set a password they actually know. + * + * Source of truth: `app_metadata.has_password` (server-only, set by us — clients + * cannot fake it through `updateUser`). + * + * - BankID signup writes `has_password: false` (they got a random server-side + * password they will never see). + * - Email/password signup doesn't set the flag — we infer `true` because the + * user supplied a password to reach signUp at all. + * - The flag flips to `true` after a successful POST /api/account/password. + */ +export function userHasPassword(user: Pick): boolean { + const meta = user.app_metadata ?? {} + if (meta.has_password === true) return true + if (meta.has_password === false) return false + return meta.bankid_linked !== true +} diff --git a/lib/auth/safe-return-to.ts b/lib/auth/safe-return-to.ts new file mode 100644 index 00000000..92376c04 --- /dev/null +++ b/lib/auth/safe-return-to.ts @@ -0,0 +1,27 @@ +/** + * Validate that a returnTo query param is a same-origin relative path. + * + * The previous guard only rejected protocol-relative URLs (`//evil.com`), + * but `/\evil.com`, `/?@evil.com`, and various other forms can still + * redirect off-origin in some browsers. Parse with a real URL and verify + * the origin matches. + * + * Returns the normalised path-with-search-and-hash on success, or the + * provided `fallback` if `value` is missing, malformed, or off-origin. + */ +export function safeReturnTo(value: string | null | undefined, fallback: string): string { + if (!value) return fallback + if (!value.startsWith('/')) return fallback + // Reject protocol-relative and the two known browser-quirk forms. + if (value.startsWith('//') || value.startsWith('/\\') || value.startsWith('/@')) { + return fallback + } + try { + const base = 'https://gnubok.invalid' + const parsed = new URL(value, base) + if (parsed.origin !== base) return fallback + return parsed.pathname + parsed.search + parsed.hash + } catch { + return fallback + } +} diff --git a/lib/invoices/__tests__/reminder-processor.test.ts b/lib/invoices/__tests__/reminder-processor.test.ts index 902e7f86..571d1b08 100644 --- a/lib/invoices/__tests__/reminder-processor.test.ts +++ b/lib/invoices/__tests__/reminder-processor.test.ts @@ -91,11 +91,11 @@ describe('processOverdueReminders — credit-note filter', () => { expect(isCall?.args[1]).toBeNull() }) - it('combines the credit-note filter with status=sent and due_date cutoff', async () => { + it('combines the credit-note filter with status allowlist and due_date cutoff', async () => { await processOverdueReminders() - const eqStatus = chainCalls.find( - (c) => c.method === 'eq' && c.args[0] === 'status', + const inStatus = chainCalls.find( + (c) => c.method === 'in' && c.args[0] === 'status', ) const isCreditedNull = chainCalls.find( (c) => c.method === 'is' && c.args[0] === 'credited_invoice_id', @@ -104,8 +104,36 @@ describe('processOverdueReminders — credit-note filter', () => { (c) => c.method === 'lte' && c.args[0] === 'due_date', ) - expect(eqStatus?.args[1]).toBe('sent') + expect(inStatus?.args[1]).toEqual(['sent', 'overdue']) expect(isCreditedNull?.args[1]).toBeNull() expect(lteDueDate).toBeDefined() }) + + it('uses a positive allowlist (sent + overdue) so paid / partially_paid / cancelled / credited can never match', async () => { + await processOverdueReminders() + + const inStatus = chainCalls.find( + (c) => c.method === 'in' && c.args[0] === 'status', + ) + expect(inStatus?.args[1]).toEqual(['sent', 'overdue']) + + // Defense in depth: ensure no .eq('status', terminal) somehow snuck in. + const eqTerminal = chainCalls.find( + (c) => + c.method === 'eq' && + c.args[0] === 'status' && + ['paid', 'partially_paid', 'cancelled', 'credited'].includes( + c.args[1] as string, + ), + ) + expect(eqTerminal).toBeUndefined() + }) + + it('includes overdue in the allowlist so level-2 and level-3 reminders re-fire after the first reminder flips status', async () => { + await processOverdueReminders() + const inStatus = chainCalls.find( + (c) => c.method === 'in' && c.args[0] === 'status', + ) + expect(inStatus?.args[1]).toContain('overdue') + }) }) diff --git a/lib/invoices/pdf-template.tsx b/lib/invoices/pdf-template.tsx index 444b04d0..0b9b7359 100644 --- a/lib/invoices/pdf-template.tsx +++ b/lib/invoices/pdf-template.tsx @@ -609,6 +609,12 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN {company.plusgiro} )} + {company.swish && (company.invoice_show_swish ?? true) && ( + + Swish: + {company.swish} + + )} {company.iban && ( IBAN: diff --git a/lib/invoices/reminder-processor.ts b/lib/invoices/reminder-processor.ts index aea9f57a..15d4f304 100644 --- a/lib/invoices/reminder-processor.ts +++ b/lib/invoices/reminder-processor.ts @@ -136,13 +136,16 @@ export async function processOverdueReminders(): Promise const cutoffDate = new Date() cutoffDate.setDate(cutoffDate.getDate() - minOverdueDays) + // Positive allowlist — inherently excludes 'paid', 'partially_paid', 'cancelled', 'credited'. + // Including 'overdue' ensures level-2 / level-3 reminders re-fire after the first reminder + // flips status to 'overdue' (see status update below). const { data: overdueInvoices, error: invoiceError } = await supabase .from('invoices') .select(` *, customer:customers(*) `) - .eq('status', 'sent') + .in('status', ['sent', 'overdue']) .is('credited_invoice_id', null) .lte('due_date', cutoffDate.toISOString().split('T')[0]) .order('due_date', { ascending: true }) @@ -172,9 +175,21 @@ export async function processOverdueReminders(): Promise // Get existing reminders for this invoice const { data: existingReminders } = await supabase .from('invoice_reminders') - .select('reminder_level') + .select('reminder_level, response_type') .eq('invoice_id', invoice.id) + // Skip if customer already responded (marked paid OR disputed) — they've + // told us they don't want another reminder. The business owner still needs + // to record the actual payment (mark-paid / match-invoice) to flip status + // and post the journal entry; we don't do that here because the customer + // action is unauthenticated and posting a JE without a verified payment + // would put the books out of sync. + const customerResponded = existingReminders?.some(r => r.response_type !== null) + if (customerResponded) { + log.info(`Skipping invoice ${invoice.invoice_number}: customer already responded via reminder link`) + continue + } + const existingLevels = existingReminders?.map(r => r.reminder_level) || [] const daysOverdue = calculateDaysOverdue(invoice.due_date) const reminderLevel = determineReminderLevel(daysOverdue, existingLevels) @@ -205,6 +220,26 @@ export async function processOverdueReminders(): Promise continue } + // Per-company kill switch (settings → Fakturering → "Skicka automatiska påminnelser") + if (company.send_invoice_reminders === false) { + log.info(`Skipping invoice ${invoice.invoice_number}: automatic reminders disabled for company ${invoice.company_id}`) + continue + } + + // Race-window guard — re-check invoice status immediately before sending. + // The cron runs at 08:00; a payment match arriving during the run shouldn't + // produce a reminder for an already-paid invoice. + const { data: currentInvoice } = await supabase + .from('invoices') + .select('status') + .eq('id', invoice.id) + .single() + + if (!currentInvoice || !['sent', 'overdue'].includes(currentInvoice.status as string)) { + log.info(`Skipping invoice ${invoice.invoice_number}: status changed to ${currentInvoice?.status ?? 'unknown'} mid-run`) + continue + } + // Create reminder record first (to get action token) const { data: reminderRecord, error: reminderError } = await supabase .from('invoice_reminders') diff --git a/lib/payments/__tests__/swish.test.ts b/lib/payments/__tests__/swish.test.ts new file mode 100644 index 00000000..41bb98f4 --- /dev/null +++ b/lib/payments/__tests__/swish.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from 'vitest' +import { normaliseSwish, isValidSwish } from '../swish' + +describe('normaliseSwish', () => { + it('strips whitespace and hyphens', () => { + expect(normaliseSwish('123 456 78 90')).toBe('1234567890') + expect(normaliseSwish('070-123 45 67')).toBe('0701234567') + expect(normaliseSwish(' 1234567890 ')).toBe('1234567890') + }) + + it('returns empty string for null/undefined/empty', () => { + expect(normaliseSwish(null)).toBe('') + expect(normaliseSwish(undefined)).toBe('') + expect(normaliseSwish('')).toBe('') + }) +}) + +describe('isValidSwish', () => { + it('accepts Swish Företag numbers (123XXXXXXX)', () => { + expect(isValidSwish('1234567890')).toBe(true) + expect(isValidSwish('1230000000')).toBe(true) + }) + + it('accepts Swedish mobile numbers (07XXXXXXXX)', () => { + expect(isValidSwish('0701234567')).toBe(true) + expect(isValidSwish('0700000000')).toBe(true) + }) + + it('accepts empty string for clearing the field', () => { + expect(isValidSwish('')).toBe(true) + }) + + it('rejects non-conforming numbers', () => { + expect(isValidSwish('0123456789')).toBe(false) + expect(isValidSwish('1239')).toBe(false) + expect(isValidSwish('12345678901')).toBe(false) + expect(isValidSwish('123abc4567')).toBe(false) + }) +}) diff --git a/lib/payments/swish.ts b/lib/payments/swish.ts new file mode 100644 index 00000000..8b92f422 --- /dev/null +++ b/lib/payments/swish.ts @@ -0,0 +1,23 @@ +/** + * Swish number normalisation + validation. + * + * Two accepted shapes: + * - Swish Företag: `123XXXXXXX` (10 digits starting with `123`) + * - Swedish mobile: `07XXXXXXXX` (10 digits starting with `07`) + * + * Whitespace and hyphens are stripped before validation so users can paste + * formatted numbers (`123 456 78 90` or `070-123 45 67`) and have them + * canonicalised. + */ + +const SWISH_FORETAG = /^123\d{7}$/ +const SWEDISH_MOBILE = /^07\d{8}$/ + +export function normaliseSwish(value: string | null | undefined): string { + if (!value) return '' + return value.replace(/[\s-]/g, '') +} + +export function isValidSwish(normalised: string): boolean { + return normalised === '' || SWISH_FORETAG.test(normalised) || SWEDISH_MOBILE.test(normalised) +} diff --git a/lib/reports/__tests__/sie-export.test.ts b/lib/reports/__tests__/sie-export.test.ts index 5f5506e3..4eaaa8db 100644 --- a/lib/reports/__tests__/sie-export.test.ts +++ b/lib/reports/__tests__/sie-export.test.ts @@ -20,6 +20,10 @@ function makeBuilder() { function makeClient() { return { from: vi.fn().mockImplementation(() => makeBuilder()), + // `rpc` drains the same queue so tests can intersperse RPC + table fetches. + // SIE export calls `compute_prior_opening_balances` via getOpeningBalances + // whenever `opening_balance_entry_id` is null (the multi-year-import path). + rpc: vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null }), // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any } @@ -67,6 +71,8 @@ describe('generateSIEExport', () => { { data: [], error: null }, // 5: projects (empty) { data: [], error: null }, + // 6: compute_prior_opening_balances RPC (empty — no IB) + { data: [], error: null }, ] const output = await generateSIEExport(supabase, 'company-1', baseOptions) @@ -90,6 +96,7 @@ describe('generateSIEExport', () => { { data: [], error: null }, { data: [], error: null }, { data: [], error: null }, + { data: [], error: null }, // RPC fallback ] const output = await generateSIEExport(supabase, 'company-1', { @@ -114,6 +121,7 @@ describe('generateSIEExport', () => { { data: [], error: null }, { data: [], error: null }, { data: [], error: null }, + { data: [], error: null }, // RPC fallback ] const output = await generateSIEExport(supabase, 'company-1', baseOptions) @@ -150,6 +158,7 @@ describe('generateSIEExport', () => { }, { data: [], error: null }, // cost_centers { data: [], error: null }, // projects + { data: [], error: null }, // RPC fallback ] const output = await generateSIEExport(supabase, 'company-1', baseOptions) @@ -180,6 +189,7 @@ describe('generateSIEExport', () => { ], error: null, }, + { data: [], error: null }, // RPC fallback ] const output = await generateSIEExport(supabase, 'company-1', baseOptions) @@ -214,6 +224,7 @@ describe('generateSIEExport', () => { }, { data: [{ code: 'CC1', name: 'Avdelning 1', is_active: true }], error: null }, { data: [{ code: 'P001', name: 'Projekt Alpha', is_active: true }], error: null }, + { data: [], error: null }, // RPC fallback ] const output = await generateSIEExport(supabase, 'company-1', baseOptions) @@ -247,6 +258,7 @@ describe('generateSIEExport', () => { }, { data: [], error: null }, { data: [], error: null }, + { data: [], error: null }, // RPC fallback ] const output = await generateSIEExport(supabase, 'company-1', baseOptions) @@ -283,6 +295,7 @@ describe('generateSIEExport', () => { }, { data: [], error: null }, { data: [], error: null }, + { data: [], error: null }, // RPC fallback ] const output = await generateSIEExport(supabase, 'company-1', baseOptions) @@ -298,6 +311,7 @@ describe('generateSIEExport', () => { { data: [], error: null }, { data: [], error: null }, { data: [], error: null }, + { data: [], error: null }, // RPC fallback ] const output = await generateSIEExport(supabase, 'company-1', baseOptions) @@ -321,6 +335,7 @@ describe('generateSIEExport', () => { { data: [], error: null }, { data: [], error: null }, { data: [], error: null }, + { data: [], error: null }, // RPC fallback ] const output = await generateSIEExport(supabase, 'company-1', baseOptions) @@ -337,6 +352,7 @@ describe('generateSIEExport', () => { { data: [], error: null }, { data: [], error: null }, { data: [], error: null }, + { data: [], error: null }, // RPC fallback ] const output = await generateSIEExport(supabase, 'company-1', baseOptions) @@ -344,4 +360,67 @@ describe('generateSIEExport', () => { expect(output).not.toContain('#DIM') expect(output).not.toContain('#OBJEKT') }) + + it('emits #IB from compute_prior_opening_balances RPC fallback when opening_balance_entry_id is null', async () => { + // Reproduces the user-reported bug: after a multi-year SIE import the + // continuation-import guard intentionally leaves opening_balance_entry_id + // NULL, and previously the SIE export silently produced zero #IB records, + // collapsing #UB to current-period movements only. The fix wires SIE + // export to getOpeningBalances() so the RPC backs up the missing link. + results = [ + // period — note: no opening_balance_entry_id, so getOpeningBalances + // falls through to the RPC path + { data: { id: 'period-1', period_start: '2024-01-01', period_end: '2024-12-31', opening_balance_entry_id: null }, error: null }, + { data: null, error: null }, // prevPeriod + { data: [], error: null }, // accounts + { data: [], error: null }, // journal_entries (no movements this period) + { data: [], error: null }, // cost_centers + { data: [], error: null }, // projects + // RPC fallback returns prior IBs derived from historical journal lines + { + data: [ + { account_number: '1930', debit: 50000, credit: 0 }, + { account_number: '2440', debit: 0, credit: 50000 }, + ], + error: null, + }, + ] + + const output = await generateSIEExport(supabase, 'company-1', baseOptions) + + expect(output).toContain('#IB 0 1930 50000.00') + expect(output).toContain('#IB 0 2440 -50000.00') + // UB = IB + period movements (zero this period), so #UB mirrors #IB + expect(output).toContain('#UB 0 1930 50000.00') + expect(output).toContain('#UB 0 2440 -50000.00') + }) + + it('reads #IB from explicit opening_balance_entry_id when set', async () => { + // When opening_balance_entry_id is set, getOpeningBalances uses the + // journal_entry_lines path (fetchAllRows) instead of the RPC, so the + // queue here serves the line rows rather than RPC rows. + results = [ + { data: { id: 'period-1', period_start: '2024-01-01', period_end: '2024-12-31', opening_balance_entry_id: 'ob-entry-1' }, error: null }, + { data: null, error: null }, // prevPeriod + { data: [], error: null }, // accounts + { data: [], error: null }, // journal_entries + { data: [], error: null }, // cost_centers + { data: [], error: null }, // projects + // fetchAllRows page 1 — explicit OB entry lines + { + data: [ + { account_number: '1930', debit_amount: 12000, credit_amount: 0 }, + { account_number: '2440', debit_amount: 0, credit_amount: 12000 }, + ], + error: null, + }, + ] + + const output = await generateSIEExport(supabase, 'company-1', baseOptions) + + expect(output).toContain('#IB 0 1930 12000.00') + expect(output).toContain('#IB 0 2440 -12000.00') + expect(output).toContain('#UB 0 1930 12000.00') + expect(output).toContain('#UB 0 2440 -12000.00') + }) }) diff --git a/lib/reports/sie-export.ts b/lib/reports/sie-export.ts index d9249395..106f5ce1 100644 --- a/lib/reports/sie-export.ts +++ b/lib/reports/sie-export.ts @@ -1,6 +1,7 @@ import type { SupabaseClient } from '@supabase/supabase-js' import { fetchAllRows } from '@/lib/supabase/fetch-all' import { getBranding } from '@/lib/branding/service' +import { getOpeningBalances } from './opening-balances' import type { SIEExportOptions, JournalEntry, JournalEntryLine, BASAccount } from '@/types' function sanitizeProgramName(str: string): string { @@ -136,27 +137,23 @@ export async function generateSIEExport( } // === Opening balances (IB) === - // Collect IB per account for UB calculation (UB = IB + movements) + // Routes through getOpeningBalances() so we get the same fallback as trial + // balance / balance sheet: when opening_balance_entry_id is NULL — which is + // expected after continuation SIE imports (sie-import.ts skips creating an + // IB entry once prior posted activity exists) — the compute_prior_opening_ + // balances RPC derives IB from earlier journal lines instead of silently + // emitting zero #IB records and producing wrong #UB values. const openingBalancesByAccount = new Map() + const { balances: obBalances } = await getOpeningBalances(supabase, companyId, { + period_start: period.period_start, + opening_balance_entry_id: period.opening_balance_entry_id ?? null, + }) - if (period.opening_balance_entry_id) { - const { data: obEntry } = await supabase - .from('journal_entries') - .select('*, lines:journal_entry_lines(*)') - .eq('id', period.opening_balance_entry_id) - .eq('company_id', companyId) - .single() - - if (obEntry?.lines) { - for (const line of (obEntry.lines as JournalEntryLine[])) { - const amount = (Number(line.debit_amount) || 0) - (Number(line.credit_amount) || 0) - lines.push(`#IB 0 ${line.account_number} ${formatAmount(amount)}`) - openingBalancesByAccount.set( - line.account_number, - (openingBalancesByAccount.get(line.account_number) || 0) + amount - ) - } - } + for (const [accountNumber, { debit, credit }] of obBalances) { + const amount = Math.round(((Number(debit) || 0) - (Number(credit) || 0)) * 100) / 100 + if (amount === 0) continue + lines.push(`#IB 0 ${accountNumber} ${formatAmount(amount)}`) + openingBalancesByAccount.set(accountNumber, amount) } // === Journal entries (VER + TRANS) === diff --git a/lib/supabase/middleware.ts b/lib/supabase/middleware.ts index 6b64462d..e0b144f5 100644 --- a/lib/supabase/middleware.ts +++ b/lib/supabase/middleware.ts @@ -1,6 +1,7 @@ import { createServerClient } from '@supabase/ssr' import { NextResponse, type NextRequest } from 'next/server' import { shouldEnforceMfa } from '@/lib/auth/mfa' +import { userHasPassword } from '@/lib/auth/has-password' export async function updateSession(request: NextRequest) { let supabaseResponse = NextResponse.next({ @@ -89,11 +90,40 @@ export async function updateSession(request: NextRequest) { return NextResponse.redirect(url) } - // MFA pages — accessible to authenticated users (AAL1+), skip MFA enforcement + // /mfa/enroll: gate behind has-password. BankID-only users who reach this + // page can lock themselves out — Supabase requires AAL2 to change password + // or unenroll MFA, and AAL2 needs a prior password sign-in. Force them to + // set a password first. The /account/set-password page does that and routes + // back here via ?returnTo. Thread the inner returnTo through so the user + // ends up on their original destination after the full chain completes. + if (pathname.startsWith('/mfa/enroll')) { + if (!userHasPassword(user)) { + const innerReturnTo = request.nextUrl.searchParams.get('returnTo') + const mfaTarget = `/mfa/enroll${ + innerReturnTo ? `?returnTo=${encodeURIComponent(innerReturnTo)}` : '' + }` + return NextResponse.redirect( + new URL( + `/account/set-password?returnTo=${encodeURIComponent(mfaTarget)}`, + request.url, + ), + ) + } + return supabaseResponse + } + + // Other MFA pages — accessible to authenticated users (AAL1+), skip MFA enforcement if (pathname.startsWith('/mfa/')) { return supabaseResponse } + // /account/set-password is the escape hatch from the BankID/MFA lockout + // and must be reachable even when the user has no company yet (e.g. mid- + // onboarding) and is at AAL1. + if (pathname.startsWith('/account/set-password')) { + return supabaseResponse + } + // MFA enforcement (application-side only, not RLS) if (shouldEnforceMfa(user)) { const { data: aal } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel() diff --git a/scripts/backfill-has-password.ts b/scripts/backfill-has-password.ts new file mode 100644 index 00000000..e515644a --- /dev/null +++ b/scripts/backfill-has-password.ts @@ -0,0 +1,105 @@ +#!/usr/bin/env npx tsx +/** + * Backfill auth.users.app_metadata.has_password for the BankID-MFA lockout fix. + * + * - BankID-linked users (app_metadata.bankid_linked === true) with the flag + * unset → set has_password = false. Banner in SecuritySettings will then + * guide them through /account/set-password before MFA enroll is unlocked. + * + * - All other users with the flag unset (legacy email/password signups) → + * set has_password = true. They have a real password. + * + * - Anyone with the flag already set is left alone — fully idempotent. + * + * Usage: + * npx tsx scripts/backfill-has-password.ts # apply + * npx tsx scripts/backfill-has-password.ts --dry-run # report only + */ + +import { config } from 'dotenv' +config({ path: '.env.local' }) +import { createClient } from '@supabase/supabase-js' + +const DRY_RUN = process.argv.includes('--dry-run') + +const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL +const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY + +if (!supabaseUrl || !serviceRoleKey) { + console.error( + 'Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY in .env.local', + ) + process.exit(1) +} + +const supabase = createClient(supabaseUrl, serviceRoleKey) + +async function main() { + let page = 1 + const perPage = 200 + let totalScanned = 0 + let setFalse = 0 + let setTrue = 0 + let skipped = 0 + + for (;;) { + const { data, error } = await supabase.auth.admin.listUsers({ + page, + perPage, + }) + if (error) { + console.error('listUsers failed', error) + process.exit(1) + } + if (!data.users || data.users.length === 0) break + + for (const user of data.users) { + totalScanned++ + const meta = (user.app_metadata ?? {}) as Record + const flagAlreadySet = + meta.has_password === true || meta.has_password === false + + if (flagAlreadySet) { + skipped++ + continue + } + + const isBankIdLinked = meta.bankid_linked === true + const nextValue = isBankIdLinked ? false : true + + if (DRY_RUN) { + if (nextValue) setTrue++ + else setFalse++ + continue + } + + const merged = { ...meta, has_password: nextValue } + const { error: updateError } = await supabase.auth.admin.updateUserById( + user.id, + { app_metadata: merged }, + ) + if (updateError) { + console.error(`failed to update user ${user.id}`, updateError) + continue + } + if (nextValue) setTrue++ + else setFalse++ + } + + if (data.users.length < perPage) break + page++ + } + + console.log(JSON.stringify({ + mode: DRY_RUN ? 'dry-run' : 'apply', + scanned: totalScanned, + set_true: setTrue, + set_false: setFalse, + skipped_already_set: skipped, + }, null, 2)) +} + +main().catch((err) => { + console.error(err) + process.exit(1) +}) diff --git a/supabase/migrations/20260521120000_company_settings_swish.sql b/supabase/migrations/20260521120000_company_settings_swish.sql new file mode 100644 index 00000000..d5f57c5d --- /dev/null +++ b/supabase/migrations/20260521120000_company_settings_swish.sql @@ -0,0 +1,11 @@ +-- Add Swish as an invoice payment method. +-- Mirrors invoice_show_bankgiro / invoice_show_plusgiro from 20260401200000. +-- swish accepts either a Swish-företag/handel number (1230000000–1239999999) +-- or a Swedish mobile (07X). Validation is enforced in lib/api/schemas.ts; +-- the column is plain text so historical/unusual values remain accepted. + +ALTER TABLE public.company_settings + ADD COLUMN IF NOT EXISTS swish text, + ADD COLUMN IF NOT EXISTS invoice_show_swish boolean DEFAULT true; + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260521120100_company_settings_send_invoice_reminders.sql b/supabase/migrations/20260521120100_company_settings_send_invoice_reminders.sql new file mode 100644 index 00000000..47d78076 --- /dev/null +++ b/supabase/migrations/20260521120100_company_settings_send_invoice_reminders.sql @@ -0,0 +1,9 @@ +-- Per-company kill switch for automated invoice reminder emails. +-- Default true preserves current behavior. When false, the daily +-- /api/invoices/reminders/cron run skips this company entirely +-- (see lib/invoices/reminder-processor.ts). + +ALTER TABLE public.company_settings + ADD COLUMN IF NOT EXISTS send_invoice_reminders boolean DEFAULT true; + +NOTIFY pgrst, 'reload schema'; diff --git a/tests/helpers.ts b/tests/helpers.ts index 39b172c0..6dc74759 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -522,6 +522,7 @@ export function makeCompanySettings( account_number: null, bankgiro: null, plusgiro: null, + swish: null, iban: null, bic: null, accounting_method: 'accrual', @@ -537,11 +538,13 @@ export function makeCompanySettings( invoice_show_ocr: true, invoice_show_bankgiro: true, invoice_show_plusgiro: true, + invoice_show_swish: true, invoice_show_logo: true, invoice_show_company_name: true, invoice_company_name_position: 'header', invoice_late_fee_text: null, invoice_credit_terms_text: null, + send_invoice_reminders: true, logo_url: null, onboarding_step: 6, onboarding_complete: true, diff --git a/types/index.ts b/types/index.ts index fa87b4d9..06a0686b 100644 --- a/types/index.ts +++ b/types/index.ts @@ -220,6 +220,7 @@ export interface CompanySettings { account_number: string | null bankgiro: string | null plusgiro: string | null + swish: string | null iban: string | null bic: string | null @@ -245,12 +246,16 @@ export interface CompanySettings { invoice_show_ocr: boolean invoice_show_bankgiro: boolean invoice_show_plusgiro: boolean + invoice_show_swish: boolean invoice_show_logo: boolean invoice_show_company_name: boolean invoice_company_name_position: 'header' | 'footer' invoice_late_fee_text: string | null invoice_credit_terms_text: string | null + // Automation + send_invoice_reminders: boolean + // Logo logo_url: string | null