feat(invoices): implement öresavrundning logic and next invoice numbe… (#429)

* feat(invoices): implement öresavrundning logic and next invoice number preview

- Added `getDisplayTotal` utility to handle rounding for SEK invoices based on company settings.
- Updated `InvoicesPage` to utilize the new rounding logic when displaying totals.
- Introduced `peek_next_invoice_number` function to allow previewing the next invoice number without consuming the sequence.
- Modified invoice number generation to remove the year prefix and prevent truncation of numbers exceeding three digits.
- Enhanced tests for invoice number generation and rounding functionality to ensure correctness.
- Updated PDF template to reflect new rounding logic for totals and display appropriate values.
- Adjusted company switcher to hide options in sandbox mode.
- Improved error handling and logging in sandbox seeding process.

* fix(skatteverket): remove unused scope labels from SCOPE_LABELS and DEFAULT_SCOPES
This commit is contained in:
Mattsson
2026-05-10 14:21:26 +02:00
committed by GitHub
parent c110d3ef99
commit 0ee5219b6c
19 changed files with 733 additions and 106 deletions
@@ -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 () => {
+47
View File
@@ -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)
})
})