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:
@@ -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 () => {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
<Text style={styles.totalValue}>{formatCurrency(invoice.vat_amount, invoice.currency)}</Text>
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.grandTotal}>
|
||||
<Text style={styles.grandTotalLabel}>{isCreditNote ? 'Att kreditera:' : 'Att betala:'}</Text>
|
||||
<Text style={styles.grandTotalValue}>{formatCurrency(
|
||||
(company.ore_rounding ?? true) && invoice.currency === 'SEK'
|
||||
? Math.round(invoice.total)
|
||||
: invoice.total,
|
||||
invoice.currency
|
||||
)}</Text>
|
||||
</View>
|
||||
{(company.ore_rounding ?? true) && invoice.currency === 'SEK' && Math.round(invoice.total) !== invoice.total && (
|
||||
<View style={styles.totalRow}>
|
||||
<Text style={[styles.totalLabel, { fontSize: 8 }]}>Öresavrundning:</Text>
|
||||
<Text style={[styles.totalValue, { fontSize: 8 }]}>{formatCurrency(Math.round(invoice.total) - invoice.total, 'SEK')}</Text>
|
||||
</View>
|
||||
)}
|
||||
{(() => {
|
||||
const rounding = getDisplayTotal(invoice, company)
|
||||
return (
|
||||
<>
|
||||
<View style={styles.grandTotal}>
|
||||
<Text style={styles.grandTotalLabel}>{isCreditNote ? 'Att kreditera:' : 'Att betala:'}</Text>
|
||||
<Text style={styles.grandTotalValue}>{formatCurrency(rounding.displayed, invoice.currency)}</Text>
|
||||
</View>
|
||||
{rounding.applies && (
|
||||
<View style={styles.totalRow}>
|
||||
<Text style={[styles.totalLabel, { fontSize: 8 }]}>Öresavrundning:</Text>
|
||||
<Text style={[styles.totalValue, { fontSize: 8 }]}>{formatCurrency(rounding.roundingDelta, 'SEK')}</Text>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
{invoice.currency !== 'SEK' && invoice.total_sek && (
|
||||
<View style={{ marginTop: 8 }}>
|
||||
{invoice.vat_amount_sek != null && invoice.vat_amount_sek !== 0 && (
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { Invoice, CompanySettings } from '@/types'
|
||||
|
||||
type InvoiceTotalShape = Pick<Invoice, 'total' | 'currency'>
|
||||
type CompanyRoundingShape = Pick<CompanySettings, 'ore_rounding'>
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user