diff --git a/app/(dashboard)/invoices/[id]/page.tsx b/app/(dashboard)/invoices/[id]/page.tsx index db70fff0..2b5cdb11 100644 --- a/app/(dashboard)/invoices/[id]/page.tsx +++ b/app/(dashboard)/invoices/[id]/page.tsx @@ -12,6 +12,7 @@ import { useToast } from '@/components/ui/use-toast' import { formatCurrency, formatDate, cn } from '@/lib/utils' import { getVatTreatmentLabel } from '@/lib/invoices/vat-rules' import { invoiceNumberDisplay } from '@/lib/invoices/display' +import { getDisplayTotal } from '@/lib/invoices/rounding' import { Loader2, ArrowLeft, @@ -85,6 +86,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st const [isDownloading, setIsDownloading] = useState(false) const [showDeleteDialog, setShowDeleteDialog] = useState(false) const [isDeleting, setIsDeleting] = useState(false) + const [oreRounding, setOreRounding] = useState(true) useEffect(() => { fetchInvoice() @@ -120,6 +122,16 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st setInvoice(data as InvoiceWithRelations) + // Fetch the öresavrundning setting so the detail view matches the PDF. + if (data.company_id) { + const { data: settings } = await supabase + .from('company_settings') + .select('ore_rounding') + .eq('company_id', data.company_id) + .maybeSingle() + setOreRounding(settings?.ore_rounding ?? true) + } + // Fetch reminders for this invoice const { data: reminderData } = await supabase .from('invoice_reminders') @@ -579,10 +591,23 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st )) })()} -
- Totalt - {formatCurrency(invoice.total, invoice.currency)} -
+ {(() => { + const rounding = getDisplayTotal(invoice, { ore_rounding: oreRounding }) + return ( + <> + {rounding.applies && ( +
+ Öresavrundning + {formatCurrency(rounding.roundingDelta, 'SEK')} +
+ )} +
+ Totalt + {formatCurrency(rounding.displayed, invoice.currency)} +
+ + ) + })()} {invoice.currency !== 'SEK' && invoice.total_sek && (
I SEK (kurs {invoice.exchange_rate}) diff --git a/app/(dashboard)/invoices/new/page.tsx b/app/(dashboard)/invoices/new/page.tsx index 694500ee..8d925555 100644 --- a/app/(dashboard)/invoices/new/page.tsx +++ b/app/(dashboard)/invoices/new/page.tsx @@ -79,6 +79,7 @@ export default function NewInvoicePage() { const [hasBankDetails, setHasBankDetails] = useState(null) const [showBankSetup, setShowBankSetup] = useState(false) const [accountingMethod, setAccountingMethod] = useState<'accrual' | 'cash'>('accrual') + const [numberPreview, setNumberPreview] = useState(null) const pendingCustomerRef = useRef(null) const { @@ -153,6 +154,29 @@ export default function NewInvoicePage() { } } + // 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. + useEffect(() => { + if (!company?.id) return + if (watchDocumentType === 'delivery_note') { + setNumberPreview(null) + return + } + let cancelled = false + fetch(`/api/invoices/next-number?document_type=${encodeURIComponent(watchDocumentType)}`) + .then((r) => (r.ok ? r.json() : null)) + .then((res) => { + if (!cancelled) setNumberPreview(res?.data?.preview ?? null) + }) + .catch(() => { + if (!cancelled) setNumberPreview(null) + }) + return () => { + cancelled = true + } + }, [company?.id, watchDocumentType]) + useEffect(() => { if (watchCustomerId) { const customer = customers.find((c) => c.id === watchCustomerId) @@ -257,8 +281,21 @@ export default function NewInvoicePage() { } const total = subtotal + vatAmount - function onSubmit(data: FormData) { + async function onSubmit(data: FormData) { setPendingData(data) + // Re-fetch the preview right before review so the displayed number + // reflects any concurrent invoice creations. + if (data.document_type !== 'delivery_note') { + try { + const r = await fetch(`/api/invoices/next-number?document_type=${encodeURIComponent(data.document_type)}`) + if (r.ok) { + const json = await r.json() + setNumberPreview(json?.data?.preview ?? null) + } + } catch { + // Preview is best-effort; the allocator at create time is the source of truth. + } + } if (hasBankDetails === false && watchDocumentType === 'invoice') { setShowBankSetup(true) return @@ -405,6 +442,11 @@ export default function NewInvoicePage() {

{watchDocumentType === 'proforma' ? 'Ny proformafaktura' : watchDocumentType === 'delivery_note' ? 'Ny följesedel' : 'Ny faktura'} + {numberPreview && ( + + ({numberPreview}) + + )}

{watchDocumentType === 'proforma' ? 'Skapa en proformafaktura (ingen bokföring)' : watchDocumentType === 'delivery_note' ? 'Skapa en följesedel (utan priser)' : 'Skapa en ny faktura'} @@ -853,6 +895,7 @@ export default function NewInvoicePage() { yourReference={pendingData?.your_reference} ourReference={pendingData?.our_reference} notes={pendingData?.notes} + numberPreview={numberPreview} /> )} diff --git a/app/(dashboard)/invoices/page.tsx b/app/(dashboard)/invoices/page.tsx index 4773fa77..a4964e02 100644 --- a/app/(dashboard)/invoices/page.tsx +++ b/app/(dashboard)/invoices/page.tsx @@ -14,6 +14,7 @@ import { useToast } from '@/components/ui/use-toast' import { formatCurrency, formatDate } from '@/lib/utils' import { cn } from '@/lib/utils' import { invoiceNumberDisplay } from '@/lib/invoices/display' +import { getDisplayTotal } from '@/lib/invoices/rounding' import { Plus, Search, Receipt, Lock } from 'lucide-react' import { EmptyInvoices } from '@/components/ui/empty-state' import { useCompany } from '@/contexts/CompanyContext' @@ -55,6 +56,7 @@ export default function InvoicesPage() { const { company } = useCompany() const { canWrite } = useCanWrite() const [invoices, setInvoices] = useState([]) + const [oreRounding, setOreRounding] = useState(true) const [isLoading, setIsLoading] = useState(true) const [searchTerm, setSearchTerm] = useState('') const [activeTab, setActiveTab] = useState('all') @@ -64,21 +66,29 @@ export default function InvoicesPage() { async function fetchInvoices() { if (!company) return setIsLoading(true) - const { data, error } = await supabase - .from('invoices') - .select('*, customer:customers(name)') - .eq('company_id', company.id) - .order('invoice_date', { ascending: false }) + const [invoicesResult, settingsResult] = await Promise.all([ + supabase + .from('invoices') + .select('*, customer:customers(name)') + .eq('company_id', company.id) + .order('invoice_date', { ascending: false }), + supabase + .from('company_settings') + .select('ore_rounding') + .eq('company_id', company.id) + .maybeSingle(), + ]) - if (error) { + if (invoicesResult.error) { toast({ title: 'Kunde inte ladda fakturor', description: 'Kontrollera din anslutning och försök igen.', variant: 'destructive', }) } else { - setInvoices(data || []) + setInvoices(invoicesResult.data || []) } + setOreRounding(settingsResult.data?.ore_rounding ?? true) setIsLoading(false) } @@ -293,7 +303,10 @@ export default function InvoicesPage() {

{invoiceNumberDisplay(invoice.invoice_number)}

- {formatCurrency(Number(invoice.total), invoice.currency)} + {formatCurrency( + getDisplayTotal({ total: Number(invoice.total), currency: invoice.currency }, { ore_rounding: oreRounding }).displayed, + invoice.currency, + )}

diff --git a/app/api/invoices/next-number/route.ts b/app/api/invoices/next-number/route.ts new file mode 100644 index 00000000..0b28169d --- /dev/null +++ b/app/api/invoices/next-number/route.ts @@ -0,0 +1,38 @@ +import { NextResponse } from 'next/server' +import { withRouteContext } from '@/lib/api/with-route-context' +import { errorResponse } from '@/lib/errors/get-structured-error' + +export const GET = withRouteContext( + 'invoice.peek_next_number', + async (request, ctx) => { + const { supabase, companyId, log, requestId } = ctx + + const url = new URL(request.url) + const documentType = url.searchParams.get('document_type') ?? 'invoice' + if (!['invoice', 'proforma', 'delivery_note'].includes(documentType)) { + return NextResponse.json( + { error: 'invalid document_type', requestId }, + { status: 400 }, + ) + } + + // delivery_note has its own sequence (generate_delivery_note_number); the + // peek RPC only covers the invoice/proforma F-series counter, so for + // delivery notes we return null and let the form skip the preview. + if (documentType === 'delivery_note') { + return NextResponse.json({ data: { preview: null } }) + } + + const { data, error } = await supabase.rpc('peek_next_invoice_number', { + p_company_id: companyId, + p_document_type: documentType, + }) + + if (error) { + log.error('peek_next_invoice_number failed', error) + return errorResponse(error, log, { requestId }) + } + + return NextResponse.json({ data: { preview: data ?? null } }) + }, +) diff --git a/app/api/sandbox/seed/route.ts b/app/api/sandbox/seed/route.ts index cab84bdf..65e1f951 100644 --- a/app/api/sandbox/seed/route.ts +++ b/app/api/sandbox/seed/route.ts @@ -1,6 +1,9 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import { requireCompanyId } from '@/lib/company/context' +import { getActiveCompanyId } from '@/lib/company/context' +import { createLogger } from '@/lib/logger' + +const log = createLogger('sandbox:seed') /** * POST /api/sandbox/seed @@ -19,7 +22,30 @@ export async function POST() { return NextResponse.json({ error: 'Sandbox is only available for anonymous users' }, { status: 403 }) } - const companyId = await requireCompanyId(supabase, user.id) + // Anonymous users start with no company. Create one before seeding. + // If a previous seed attempt already created a company for this user, reuse it + // (idempotency). + let companyId = await getActiveCompanyId(supabase, user.id) + + if (!companyId) { + const { data: newCompanyId, error: companyError } = await supabase.rpc( + 'create_company_with_owner', + { + p_name: 'Sandlådan Konsult', + p_entity_type: 'enskild_firma', + } + ) + + if (companyError || !newCompanyId) { + log.error('failed to create sandbox company', { error: companyError, userId: user.id }) + return NextResponse.json( + { error: 'Failed to create sandbox company' }, + { status: 500 } + ) + } + + companyId = newCompanyId as string + } // Idempotency: if already seeded, return early const { data: existing } = await supabase @@ -523,7 +549,8 @@ export async function POST() { if (dlError) throw dlError return NextResponse.json({ seeded: true }) - } catch { + } catch (err) { + log.error('failed to seed sandbox data', { error: err, userId: user.id, companyId }) return NextResponse.json( { error: 'Failed to seed sandbox data' }, { status: 500 } diff --git a/components/dashboard/CompanySwitcher.tsx b/components/dashboard/CompanySwitcher.tsx index 41626899..0549055b 100644 --- a/components/dashboard/CompanySwitcher.tsx +++ b/components/dashboard/CompanySwitcher.tsx @@ -9,7 +9,7 @@ import { switchCompany } from '@/lib/company/actions' import { Check, ChevronsUpDown, Plus, Loader2 } from 'lucide-react' export default function CompanySwitcher() { - const { company, companies } = useCompany() + const { company, companies, isSandbox } = useCompany() const [open, setOpen] = useState(false) const [isPending, setIsPending] = useState(false) const triggerRef = useRef(null) @@ -106,8 +106,9 @@ export default function CompanySwitcher() { const hasMultiple = companies.length > 1 // No companies yet — show a direct "Lägg till företag" link instead of - // the switcher so the user can still create one. + // the switcher so the user can still create one. Hidden in sandbox mode. if (!company && companies.length === 0) { + if (isSandbox) return null return ( )} -

0 && 'border-t border-border/40 mt-1 pt-1', 'px-1')}> - setOpen(false)} - className="flex items-center gap-2 px-2.5 py-2 text-[13px] text-muted-foreground hover:text-foreground hover:bg-muted/40 rounded-md transition-colors md:whitespace-nowrap" - > - - Lägg till företag - -
+ {!isSandbox && ( +
0 && 'border-t border-border/40 mt-1 pt-1', 'px-1')}> + setOpen(false)} + className="flex items-center gap-2 px-2.5 py-2 text-[13px] text-muted-foreground hover:text-foreground hover:bg-muted/40 rounded-md transition-colors md:whitespace-nowrap" + > + + Lägg till företag + +
+ )}
, document.body )} diff --git a/components/invoices/InvoiceReviewContent.tsx b/components/invoices/InvoiceReviewContent.tsx index a49fc066..c14422d7 100644 --- a/components/invoices/InvoiceReviewContent.tsx +++ b/components/invoices/InvoiceReviewContent.tsx @@ -25,6 +25,9 @@ interface InvoiceReviewContentProps { yourReference?: string ourReference?: string notes?: string + /** The invoice number that will be assigned on confirm. Null when unknown + * (e.g. delivery notes use a different sequence) or unfetched. */ + numberPreview?: string | null } export function InvoiceReviewContent({ @@ -39,6 +42,7 @@ export function InvoiceReviewContent({ yourReference, ourReference, notes, + numberPreview, }: InvoiceReviewContentProps) { const customerTypeLabel: Record = { individual: 'Privatperson', @@ -60,6 +64,12 @@ export function InvoiceReviewContent({ return (
+ {numberPreview && ( +
+ Tilldelas fakturanummer{' '} + {numberPreview} +
+ )} {/* Customer info */}
diff --git a/components/salary/AGIPanel.tsx b/components/salary/AGIPanel.tsx index af77e59f..454c1310 100644 --- a/components/salary/AGIPanel.tsx +++ b/components/salary/AGIPanel.tsx @@ -734,55 +734,61 @@ export function AGIPanel(props: AGIPanelProps) { )} {!readOnly && !isSigned && ( - <> - {/* Direct AGI submission to Skatteverket is paused while the - APIGW subscription is sorted out at SKV's end. Users still - generate and download the AGI XML from the salary run page - and upload it manually via Mina Sidor. Re-enable the three - buttons below once the subscription is in place. */} -
-

- Direktinlämning till Skatteverket är pausad -

-

- Ladda ner AGI-filen ovan och lämna in den manuellt via Mina Sidor - hos Skatteverket. Direktinlämning aktiveras igen när vår - anslutning hos Skatteverket är klar. -

-
- -
- - + + )} + Skapa signeringslänk + + + {awaitingSigning && ( -
- + )} +
)} diff --git a/components/settings/SkatteverketConnectPanel.tsx b/components/settings/SkatteverketConnectPanel.tsx index 11a08438..34afd53f 100644 --- a/components/settings/SkatteverketConnectPanel.tsx +++ b/components/settings/SkatteverketConnectPanel.tsx @@ -24,8 +24,6 @@ type Status = const SCOPE_LABELS: Record = { momsdeklaration: 'Momsdeklaration', inkforetag: 'Företagsinformation', - ska: 'Skatteinformation', - skahmst: 'Hemortskommun', skattekonto: 'Skattekonto', agd: 'Arbetsgivardeklaration', } diff --git a/extensions/general/skatteverket/__tests__/api-client.test.ts b/extensions/general/skatteverket/__tests__/api-client.test.ts index a888e659..64926d16 100644 --- a/extensions/general/skatteverket/__tests__/api-client.test.ts +++ b/extensions/general/skatteverket/__tests__/api-client.test.ts @@ -35,21 +35,48 @@ beforeEach(() => { vi.restoreAllMocks() }) -function mockFetchStatus(status: number, body = '') { +function mockFetchStatus(status: number, body = '', headers?: HeadersInit) { global.fetch = vi.fn(async () => - new Response(body, { status, statusText: String(status) }) + new Response(body, { status, statusText: String(status), headers }) ) as unknown as typeof fetch } describe('skvRequest — error mapping', () => { - it('maps 401 → SESSION_EXPIRED', async () => { + it('maps empty 401 → ACCESS_DENIED (likely missing APIGW subscription)', async () => { mockFetchStatus(401) - await expect( - skvRequest(fakeSupabase, 'user-1', 'GET', '/x'), - ).rejects.toMatchObject({ - name: 'SkatteverketAuthError', - code: 'SESSION_EXPIRED', + try { + await skvRequest(fakeSupabase, 'user-1', 'GET', '/x') + expect.fail('expected throw') + } catch (e) { + expect(e).toBeInstanceOf(SkatteverketAuthError) + expect((e as SkatteverketAuthError).code).toBe('ACCESS_DENIED') + expect((e as SkatteverketAuthError).message).toMatch(/Utvecklarportalen|prenumeration/i) + } + }) + + it('maps 401 with body text → SESSION_EXPIRED and includes body', async () => { + mockFetchStatus(401, 'token expired') + try { + await skvRequest(fakeSupabase, 'user-1', 'GET', '/x') + expect.fail('expected throw') + } catch (e) { + expect(e).toBeInstanceOf(SkatteverketAuthError) + expect((e as SkatteverketAuthError).code).toBe('SESSION_EXPIRED') + expect((e as SkatteverketAuthError).message).toContain('token expired') + } + }) + + it('maps 401 with WWW-Authenticate insufficient_scope → MISSING_SCOPE', async () => { + mockFetchStatus(401, '', { + 'WWW-Authenticate': 'Bearer error="insufficient_scope", scope="agd"', }) + try { + await skvRequest(fakeSupabase, 'user-1', 'GET', '/x') + expect.fail('expected throw') + } catch (e) { + expect(e).toBeInstanceOf(SkatteverketAuthError) + expect((e as SkatteverketAuthError).code).toBe('MISSING_SCOPE') + } }) it('maps 403 with Behörighet body → BEHORIGHET_SAKNAS', async () => { diff --git a/extensions/general/skatteverket/lib/api-client.ts b/extensions/general/skatteverket/lib/api-client.ts index 9703bcfd..334e8b7f 100644 --- a/extensions/general/skatteverket/lib/api-client.ts +++ b/extensions/general/skatteverket/lib/api-client.ts @@ -213,9 +213,55 @@ export async function skvRequest( // 1. Genuine token expiry / invalid bearer (user must re-auth) // 2. APIGW client lacks subscription for this API (developer portal fix) // — the bearer is valid but the gateway rejects the call. - // Read the body so we can distinguish and surface a useful message. + // Read the body and gateway-side headers so we can distinguish and + // surface a useful message. const text = await response.text().catch(() => '') - console.error('[skatteverket] 401 from API', { url, body: text }) + + // WWW-Authenticate carries OAuth's machine-readable failure reason + // (insufficient_scope / invalid_token). The x-skv-* / x-amzn-* / x-api-* + // families are gateway-side hints SKV's APIGW emits when it rejects the + // call before reaching the application — the body is often empty in + // that case so the headers are the only signal. + const wwwAuth = response.headers.get('WWW-Authenticate') ?? '' + const skvHeaders: Record = {} + response.headers.forEach((v, k) => { + const lk = k.toLowerCase() + if ( + lk === 'www-authenticate' || + lk.startsWith('x-skv-') || + lk.startsWith('x-amzn-') || + lk.startsWith('x-api-') + ) { + skvHeaders[k] = v + } + }) + console.error('[skatteverket] 401 from API', { url, body: text, headers: skvHeaders }) + + // (A) Surface SKV's WWW-Authenticate verbatim — when the body is empty + // this header is usually the only diagnostic SKV gives us. Carry both + // header and body into every thrown message below. + const headerSuffix = Object.keys(skvHeaders).length > 0 + ? ` Headers: ${JSON.stringify(skvHeaders)}` + : '' + const bodySuffix = text ? ` Svar: ${text}` : '' + + // OAuth's standard insufficient_scope marker. SKV sometimes emits this + // as 401 (rather than 403) when the AGI APIGW evaluates scope before + // the application sees the token. The remedy is the same as MISSING_SCOPE: + // disconnect + reconnect to mint a token covering the AGI scope. + const wwwLower = wwwAuth.toLowerCase() + if ( + wwwLower.includes('insufficient_scope') || + wwwLower.includes('invalid_scope') + ) { + throw new SkatteverketAuthError( + 'Anslutningen mot Skatteverket saknar nödvändig behörighet för denna ' + + 'tjänst. Koppla bort och anslut igen via Inställningar → Skatteverket ' + + 'för att förnya tokenen med rätt scope.' + + headerSuffix + bodySuffix, + 'MISSING_SCOPE' + ) + } // APIGW subscription / client-credential problems: the gateway responds // before the bearer is ever evaluated. The user reconnecting won't help @@ -233,15 +279,47 @@ export async function skvRequest( throw new SkatteverketAuthError( 'Skatteverkets API-gateway nekade anropet. Kontrollera att din ' + 'APIGW-klient (SKATTEVERKET_APIGW_CLIENT_ID) har prenumeration på ' + - `denna tjänst i Utvecklarportalen. Svar från Skatteverket: ${text || '(tomt svar)'}`, + 'denna tjänst i Utvecklarportalen.' + + headerSuffix + + ` Svar från Skatteverket: ${text || '(tomt svar)'}`, + 'ACCESS_DENIED' + ) + } + + // (B) Empty 401 with no diagnostic header → almost always a gateway/ + // subscription issue rather than a real session expiry. We refreshed + // the local bearer immediately above, so an empty body with no + // WWW-Authenticate means SKV's APIGW rejected the call before it + // reached the application — typically because the APIGW client isn't + // subscribed to the API at the URL we just hit. Telling the user to + // "log in again" sends them down a dead end; be explicit about the + // likely fix instead. + if (!text) { + // Extract the API segment of the URL so the message tells the user + // exactly which subscription is missing. Falls back to the raw URL + // if parsing fails. + let apiHint = url + try { + const u = new URL(url) + const parts = u.pathname.split('/').filter(Boolean) + // Take the first 3 segments — e.g. arbetsgivardeklaration/inlamning/v1 + if (parts.length >= 1) apiHint = parts.slice(0, 3).join('/') + } catch { + // keep raw url + } + throw new SkatteverketAuthError( + 'Skatteverkets API-gateway nekade anropet utan motivering. ' + + 'Trolig orsak: APIGW-klienten (SKATTEVERKET_APIGW_CLIENT_ID) har ' + + `inte prenumeration på tjänsten "${apiHint}" i Utvecklarportalen, ` + + 'eller den lagrade tokenen saknar rätt scope. Kontrollera ' + + 'prenumerationen, koppla annars bort och anslut igen via ' + + 'Inställningar → Skatteverket.' + headerSuffix, 'ACCESS_DENIED' ) } throw new SkatteverketAuthError( - text - ? `Sessionen har gått ut. Logga in med BankID igen. (Skatteverket: ${text})` - : 'Sessionen har gått ut. Logga in med BankID igen.', + `Sessionen har gått ut. Logga in med BankID igen.${headerSuffix}${bodySuffix}`, 'SESSION_EXPIRED' ) } diff --git a/extensions/general/skatteverket/lib/oauth.ts b/extensions/general/skatteverket/lib/oauth.ts index beb0a58b..8f6d62ec 100644 --- a/extensions/general/skatteverket/lib/oauth.ts +++ b/extensions/general/skatteverket/lib/oauth.ts @@ -22,7 +22,7 @@ const DEFAULT_OAUTH_BASE_URL = 'https://peroauth2.test.skatteverket.se/oauth2/v1 // section 4.1.2.2 — the 403 "Felaktigt access scope" example shows // `"description": "The required scope agd has been requested for that access token."` // The other tokens match the path segments of their respective APIs. -const DEFAULT_SCOPES = 'momsdeklaration inkforetag ska skahmst skattekonto agd' +const DEFAULT_SCOPES = 'momsdeklaration inkforetag skattekonto agd' function getOAuthBaseUrl(): string { return process.env.SKATTEVERKET_OAUTH_BASE_URL || DEFAULT_OAUTH_BASE_URL diff --git a/lib/invoices/__tests__/generate-invoice-number.pg.test.ts b/lib/invoices/__tests__/generate-invoice-number.pg.test.ts index c4b26ebd..b6bb3768 100644 --- a/lib/invoices/__tests__/generate-invoice-number.pg.test.ts +++ b/lib/invoices/__tests__/generate-invoice-number.pg.test.ts @@ -67,7 +67,7 @@ describe('generate_invoice_number RPC', () => { ) const assigned = rows[0]!.generate_invoice_number - expect(assigned).toMatch(/^F\d{4}\d{3}$/) + expect(assigned).toBe('F001') const persisted = await getPool().query<{ invoice_number: string }>( 'SELECT invoice_number FROM public.invoices WHERE id = $1', @@ -86,7 +86,7 @@ describe('generate_invoice_number RPC', () => { [companyId, invoiceId, 'proforma'], ) - expect(rows[0]!.generate_invoice_number).toMatch(/^PF-\d{4}042$/) + expect(rows[0]!.generate_invoice_number).toBe('PF-042') }) it('is idempotent: a second call on the same invoice returns the same number without bumping the counter', async () => { @@ -167,8 +167,39 @@ describe('generate_invoice_number RPC', () => { [companyId, invoiceB, 'invoice'], ) - expect(a.rows[0]!.generate_invoice_number).toMatch(/200$/) - expect(b.rows[0]!.generate_invoice_number).toMatch(/201$/) + expect(a.rows[0]!.generate_invoice_number).toBe('F200') + expect(b.rows[0]!.generate_invoice_number).toBe('F201') + }) + + it('uses bare number when invoice_prefix is null (no implicit year prefix)', async () => { + const { userId, companyId } = await seedCompany() + // Mirror the C by Sea bug report: user set next_invoice_number=10159 with + // no prefix, expected '10159', got '2026101' under the old year-prefix + // bug. After the fix the bare number is what they get. + await ensureCompanySettings({ userId, companyId, invoicePrefix: undefined, nextInvoiceNumber: 10159 }) + await getPool().query( + 'UPDATE public.company_settings SET invoice_prefix = NULL WHERE company_id = $1', + [companyId], + ) + const invoiceId = await insertDraftInvoice({ userId, companyId }) + + const { rows } = await getPool().query<{ generate_invoice_number: string }>( + 'SELECT public.generate_invoice_number($1, $2, $3)', + [companyId, invoiceId, 'invoice'], + ) + expect(rows[0]!.generate_invoice_number).toBe('10159') + }) + + it('does not zero-pad numbers that exceed three digits', async () => { + const { userId, companyId } = await seedCompany() + await ensureCompanySettings({ userId, companyId, invoicePrefix: 'F-', nextInvoiceNumber: 10159 }) + const invoiceId = await insertDraftInvoice({ userId, companyId }) + + const { rows } = await getPool().query<{ generate_invoice_number: string }>( + 'SELECT public.generate_invoice_number($1, $2, $3)', + [companyId, invoiceId, 'invoice'], + ) + expect(rows[0]!.generate_invoice_number).toBe('F-10159') }) it('raises when the invoice id does not belong to the company', async () => { diff --git a/lib/invoices/__tests__/rounding.test.ts b/lib/invoices/__tests__/rounding.test.ts new file mode 100644 index 00000000..499eda76 --- /dev/null +++ b/lib/invoices/__tests__/rounding.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { getDisplayTotal } from '@/lib/invoices/rounding' + +const inv = (total: number, currency: 'SEK' | 'EUR' = 'SEK') => ({ total, currency }) +const co = (ore_rounding: boolean) => ({ ore_rounding }) + +describe('getDisplayTotal', () => { + it('rounds SEK with rounding enabled and a non-integer total', () => { + const r = getDisplayTotal(inv(1234.56), co(true)) + expect(r.applies).toBe(true) + expect(r.displayed).toBe(1235) + expect(r.roundingDelta).toBe(0.44) + }) + + it('rounds down when fractional part < 0.5', () => { + const r = getDisplayTotal(inv(1234.4), co(true)) + expect(r.applies).toBe(true) + expect(r.displayed).toBe(1234) + expect(r.roundingDelta).toBe(-0.4) + }) + + it('does not apply when setting is disabled', () => { + const r = getDisplayTotal(inv(1234.56), co(false)) + expect(r.applies).toBe(false) + expect(r.displayed).toBe(1234.56) + expect(r.roundingDelta).toBe(0) + }) + + it('does not apply for non-SEK currencies', () => { + const r = getDisplayTotal(inv(1234.56, 'EUR'), co(true)) + expect(r.applies).toBe(false) + expect(r.displayed).toBe(1234.56) + }) + + it('does not apply when total is already an integer', () => { + const r = getDisplayTotal(inv(1235), co(true)) + expect(r.applies).toBe(false) + expect(r.displayed).toBe(1235) + expect(r.roundingDelta).toBe(0) + }) + + it('treats missing company settings as default-on', () => { + const r = getDisplayTotal(inv(99.99), null) + expect(r.applies).toBe(true) + expect(r.displayed).toBe(100) + }) +}) diff --git a/lib/invoices/pdf-template.tsx b/lib/invoices/pdf-template.tsx index 36f2030d..b7815f3a 100644 --- a/lib/invoices/pdf-template.tsx +++ b/lib/invoices/pdf-template.tsx @@ -8,6 +8,7 @@ import { } from '@react-pdf/renderer' import type { Invoice, InvoiceItem, Customer, CompanySettings, InvoiceDocumentType } from '@/types' import { generateOcrReference } from '@/lib/bankgiro/luhn' +import { getDisplayTotal } from '@/lib/invoices/rounding' // Create styles const styles = StyleSheet.create({ @@ -542,21 +543,23 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN {formatCurrency(invoice.vat_amount, invoice.currency)} )} - - {isCreditNote ? 'Att kreditera:' : 'Att betala:'} - {formatCurrency( - (company.ore_rounding ?? true) && invoice.currency === 'SEK' - ? Math.round(invoice.total) - : invoice.total, - invoice.currency - )} - - {(company.ore_rounding ?? true) && invoice.currency === 'SEK' && Math.round(invoice.total) !== invoice.total && ( - - Öresavrundning: - {formatCurrency(Math.round(invoice.total) - invoice.total, 'SEK')} - - )} + {(() => { + const rounding = getDisplayTotal(invoice, company) + return ( + <> + + {isCreditNote ? 'Att kreditera:' : 'Att betala:'} + {formatCurrency(rounding.displayed, invoice.currency)} + + {rounding.applies && ( + + Öresavrundning: + {formatCurrency(rounding.roundingDelta, 'SEK')} + + )} + + ) + })()} {invoice.currency !== 'SEK' && invoice.total_sek && ( {invoice.vat_amount_sek != null && invoice.vat_amount_sek !== 0 && ( diff --git a/lib/invoices/rounding.ts b/lib/invoices/rounding.ts new file mode 100644 index 00000000..4fde0bcf --- /dev/null +++ b/lib/invoices/rounding.ts @@ -0,0 +1,39 @@ +import type { Invoice, CompanySettings } from '@/types' + +type InvoiceTotalShape = Pick +type CompanyRoundingShape = Pick + +export interface DisplayTotal { + /** Total to render to the user (rounded if öresavrundning applies, raw otherwise). */ + displayed: number + /** displayed - raw total. Zero when rounding does not apply or the total is already an integer. */ + roundingDelta: number + /** True when both the company setting is on, currency is SEK, and there are öre to round. */ + applies: boolean +} + +/** + * Single source of truth for öresavrundning display logic. Mirrors the rule + * baked into the PDF template since day one: only SEK invoices, only when + * the company has the setting enabled, and only when there's actually a + * non-integer total to round. The helper centralizes the rule so the list, + * detail page, and PDF cannot drift apart. + */ +export function getDisplayTotal( + invoice: InvoiceTotalShape, + company: CompanyRoundingShape | null | undefined, +): DisplayTotal { + const enabled = company?.ore_rounding ?? true + if (!enabled || invoice.currency !== 'SEK') { + return { displayed: invoice.total, roundingDelta: 0, applies: false } + } + const rounded = Math.round(invoice.total) + if (rounded === invoice.total) { + return { displayed: invoice.total, roundingDelta: 0, applies: false } + } + return { + displayed: rounded, + roundingDelta: Math.round((rounded - invoice.total) * 100) / 100, + applies: true, + } +} diff --git a/supabase/migrations/20260510120000_invoice_number_drop_year_prefix.sql b/supabase/migrations/20260510120000_invoice_number_drop_year_prefix.sql new file mode 100644 index 00000000..4e1b5b89 --- /dev/null +++ b/supabase/migrations/20260510120000_invoice_number_drop_year_prefix.sql @@ -0,0 +1,120 @@ +-- Drop the unconditional year prefix from generate_invoice_number(). +-- +-- The previous version (20260427150100) always inserted EXTRACT(YEAR FROM +-- CURRENT_DATE) between the company prefix and the sequence number. That +-- silently overrode the user's "Nästa fakturanummer" setting: a customer +-- migrating from another system who set next_invoice_number = 10159 would +-- get '2026' instead of '10159'. There was no way to opt out of the +-- year injection short of leaving prefix=NULL and accepting the surprise. +-- +-- New format: +-- proforma -> 'PF-' || LPAD(number::text, 3, '0') +-- otherwise -> COALESCE(invoice_prefix, '') || LPAD(number::text, 3, '0') +-- +-- Customers who *want* a year prefix put it in invoice_prefix explicitly +-- (e.g. 'F-2026-' or '2026'). LPAD pads small numbers but never truncates, +-- so bumping next_invoice_number to a high value continues to render the +-- full number. +-- +-- Backfill: if a company has 2+ existing invoices whose numbers match the +-- old year-prefixed format (^\d{4}\d+$) and shares a single year, backfill +-- invoice_prefix to that year so their next invoice keeps visual +-- continuity. Single-invoice companies are skipped — they're likely fresh +-- migrators (like C by Sea) whose first invoice was the buggy year-prefix +-- output, and forcing the prefix on them would defeat the fix. + +DROP FUNCTION IF EXISTS public.generate_invoice_number(uuid, uuid, text); + +CREATE OR REPLACE FUNCTION public.generate_invoice_number( + p_company_id uuid, + p_invoice_id uuid, + p_document_type text DEFAULT 'invoice' +) +RETURNS text +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path TO 'public' +AS $function$ +DECLARE + v_existing text; + v_prefix text; + v_number integer; + v_final text; +BEGIN + -- 1. Lock the invoice row. Concurrent callers block here until the first + -- transaction commits, then see the persisted number on retry. + SELECT invoice_number INTO v_existing + FROM public.invoices + WHERE id = p_invoice_id AND company_id = p_company_id + FOR UPDATE; + + IF NOT FOUND THEN + RAISE EXCEPTION 'Invoice % not found in company %', p_invoice_id, p_company_id; + END IF; + + -- 2. Idempotent: if the number is already set, return it without consuming + -- the sequence. This is also the path concurrent callers take after + -- unblocking from the row lock. + IF v_existing IS NOT NULL THEN + RETURN v_existing; + END IF; + + -- 3. Allocate from per-company counter atomically. UPDATE ... RETURNING is + -- serialized by Postgres on the company_settings row. + UPDATE public.company_settings + SET next_invoice_number = next_invoice_number + 1, + updated_at = now() + WHERE company_id = p_company_id + RETURNING invoice_prefix, next_invoice_number - 1 + INTO v_prefix, v_number; + + IF v_number IS NULL THEN + RAISE EXCEPTION 'Company settings not found for company %', p_company_id; + END IF; + + -- 4. Compose: proforma -> 'PF-', otherwise the company's invoice_prefix. + -- No year injection — the prefix is the only place it can live. + v_final := CASE + WHEN p_document_type = 'proforma' THEN 'PF-' + ELSE COALESCE(v_prefix, '') + END || LPAD(v_number::text, 3, '0'); + + -- 5. Persist on the invoice row in the same transaction. + UPDATE public.invoices + SET invoice_number = v_final + WHERE id = p_invoice_id AND company_id = p_company_id; + + RETURN v_final; +END; +$function$; + +-- Backfill: preserve visual continuity for established companies that +-- relied on the implicit year prefix. Only touch companies with 2+ existing +-- invoices that all share a single 4-digit year prefix and currently have +-- invoice_prefix=NULL. +UPDATE public.company_settings cs +SET invoice_prefix = sub.year_str, + updated_at = now() +FROM ( + SELECT i.company_id, + (regexp_match(i.invoice_number, '^(\d{4})\d+$'))[1] AS year_str, + COUNT(*) AS hits + FROM public.invoices i + WHERE i.invoice_number ~ '^\d{4}\d+$' + GROUP BY i.company_id, (regexp_match(i.invoice_number, '^(\d{4})\d+$'))[1] + HAVING COUNT(*) >= 2 +) sub +WHERE cs.company_id = sub.company_id + AND cs.invoice_prefix IS NULL + -- If a company has invoices spanning multiple years (e.g. 2025001 and + -- 2026001), the subquery returns a row per year; pick the most recent. + AND sub.year_str = ( + SELECT (regexp_match(i2.invoice_number, '^(\d{4})\d+$'))[1] + FROM public.invoices i2 + WHERE i2.company_id = cs.company_id + AND i2.invoice_number ~ '^\d{4}\d+$' + ORDER BY i2.invoice_date DESC NULLS LAST, i2.created_at DESC + LIMIT 1 + ); + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260510120100_peek_next_invoice_number.sql b/supabase/migrations/20260510120100_peek_next_invoice_number.sql new file mode 100644 index 00000000..6e0334c0 --- /dev/null +++ b/supabase/migrations/20260510120100_peek_next_invoice_number.sql @@ -0,0 +1,32 @@ +-- Peek the next invoice number without consuming the sequence. +-- +-- generate_invoice_number() atomically increments and persists, which is +-- the right behavior at send/save time but unsuitable for previewing in +-- the UI. peek_next_invoice_number() reads the same fields and applies the +-- same composition rules (matching the no-year-prefix format from +-- 20260510120000) without modifying state. +-- +-- Important: this is a preview only. Two callers reading concurrently +-- might both see the same number; the actual allocator (generate_…) is +-- the source of truth and assigns atomically. The UI re-fetches before +-- submit so the preview reflects fresh state. + +CREATE OR REPLACE FUNCTION public.peek_next_invoice_number( + p_company_id uuid, + p_document_type text DEFAULT 'invoice' +) +RETURNS text +LANGUAGE sql +STABLE +SECURITY DEFINER +SET search_path TO 'public' +AS $function$ + SELECT CASE + WHEN p_document_type = 'proforma' THEN 'PF-' + ELSE COALESCE(invoice_prefix, '') + END || LPAD(next_invoice_number::text, 3, '0') + FROM public.company_settings + WHERE company_id = p_company_id +$function$; + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260510130000_invoice_number_no_truncate.sql b/supabase/migrations/20260510130000_invoice_number_no_truncate.sql new file mode 100644 index 00000000..009657fd --- /dev/null +++ b/supabase/migrations/20260510130000_invoice_number_no_truncate.sql @@ -0,0 +1,87 @@ +-- Fix LPAD truncation in invoice number generation. +-- +-- Postgres LPAD(string, length [, fill]) TRUNCATES on the right when string +-- is longer than length. So LPAD('10159', 3, '0') returns '101' — not the +-- '10159' the user expected. The previous migration (20260510120000) +-- preserved this LPAD pattern from the original 20260427150100 function +-- on the assumption that LPAD never truncates; that was wrong. +-- +-- The customer-visible symptom: setting next_invoice_number = 10159 with +-- no prefix produces invoice number '101' instead of '10159', and the +-- preview surfaced the same '101'. +-- +-- Fix: pad to at LEAST three digits, but never shorter than the actual +-- number. GREATEST(3, length(...)) is the simplest way to express that. + +CREATE OR REPLACE FUNCTION public.generate_invoice_number( + p_company_id uuid, + p_invoice_id uuid, + p_document_type text DEFAULT 'invoice' +) +RETURNS text +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path TO 'public' +AS $function$ +DECLARE + v_existing text; + v_prefix text; + v_number integer; + v_final text; +BEGIN + SELECT invoice_number INTO v_existing + FROM public.invoices + WHERE id = p_invoice_id AND company_id = p_company_id + FOR UPDATE; + + IF NOT FOUND THEN + RAISE EXCEPTION 'Invoice % not found in company %', p_invoice_id, p_company_id; + END IF; + + IF v_existing IS NOT NULL THEN + RETURN v_existing; + END IF; + + UPDATE public.company_settings + SET next_invoice_number = next_invoice_number + 1, + updated_at = now() + WHERE company_id = p_company_id + RETURNING invoice_prefix, next_invoice_number - 1 + INTO v_prefix, v_number; + + IF v_number IS NULL THEN + RAISE EXCEPTION 'Company settings not found for company %', p_company_id; + END IF; + + v_final := CASE + WHEN p_document_type = 'proforma' THEN 'PF-' + ELSE COALESCE(v_prefix, '') + END || LPAD(v_number::text, GREATEST(3, length(v_number::text)), '0'); + + UPDATE public.invoices + SET invoice_number = v_final + WHERE id = p_invoice_id AND company_id = p_company_id; + + RETURN v_final; +END; +$function$; + +CREATE OR REPLACE FUNCTION public.peek_next_invoice_number( + p_company_id uuid, + p_document_type text DEFAULT 'invoice' +) +RETURNS text +LANGUAGE sql +STABLE +SECURITY DEFINER +SET search_path TO 'public' +AS $function$ + SELECT CASE + WHEN p_document_type = 'proforma' THEN 'PF-' + ELSE COALESCE(invoice_prefix, '') + END || LPAD(next_invoice_number::text, GREATEST(3, length(next_invoice_number::text)), '0') + FROM public.company_settings + WHERE company_id = p_company_id +$function$; + +NOTIFY pgrst, 'reload schema';