feat: add user locale preference to user_preferences table (#555)
* feat: add user locale preference to user_preferences table
- Introduced a new column 'locale' in the user_preferences table to store per-user UI language preferences.
- Added a CHECK constraint to ensure only supported locales ('sv', 'en') are allowed.
- Triggered a schema reload notification for the changes.
chore: declare CSS module support in TypeScript
- Added a declaration for CSS modules in globals.d.ts to enable TypeScript support for importing CSS files.
* feat: add Swish as an invoice payment method in company settings
This commit is contained in:
@@ -12,6 +12,7 @@ import {
|
||||
getCommonTemplates,
|
||||
getAdvancedTemplates,
|
||||
validateTemplateForEntity,
|
||||
stripBankNoise,
|
||||
type BookingTemplate,
|
||||
} from '../booking-templates'
|
||||
|
||||
@@ -20,8 +21,8 @@ import {
|
||||
// ============================================================
|
||||
|
||||
describe('BOOKING_TEMPLATES data integrity', () => {
|
||||
it('has exactly 51 templates', () => {
|
||||
expect(BOOKING_TEMPLATES).toHaveLength(51)
|
||||
it('has exactly 60 templates', () => {
|
||||
expect(BOOKING_TEMPLATES).toHaveLength(60)
|
||||
})
|
||||
|
||||
it('all template IDs are unique', () => {
|
||||
@@ -140,7 +141,7 @@ describe('getTemplateGroups', () => {
|
||||
it('every template is in exactly one group', () => {
|
||||
const groups = getTemplateGroups()
|
||||
const allTemplates = groups.flatMap((g) => g.templates)
|
||||
expect(allTemplates).toHaveLength(51)
|
||||
expect(allTemplates).toHaveLength(60)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -269,6 +270,130 @@ describe('findMatchingTemplates', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// stripBankNoise — protects the matcher from bank-prefix noise
|
||||
// ============================================================
|
||||
|
||||
describe('stripBankNoise', () => {
|
||||
it('strips "överföring via internet" before keyword matching', () => {
|
||||
expect(stripBankNoise('milersättning april överföring via internet')).toBe(
|
||||
'milersättning april'
|
||||
)
|
||||
})
|
||||
|
||||
it('strips "autogiro" suffix', () => {
|
||||
expect(stripBankNoise('elräkning autogiro')).toBe('elräkning')
|
||||
})
|
||||
|
||||
it('strips "bg-betalning" and "bgmax"', () => {
|
||||
expect(stripBankNoise('vattenfall bg-betalning')).toBe('vattenfall')
|
||||
expect(stripBankNoise('kund x bgmax')).toBe('kund x')
|
||||
})
|
||||
|
||||
it('strips multiple noise phrases at once', () => {
|
||||
expect(stripBankNoise('swish till anna överföring')).toBe('anna')
|
||||
})
|
||||
|
||||
it('preserves real merchant text after stripping', () => {
|
||||
expect(stripBankNoise('kortköp ica supermarket')).toBe('ica supermarket')
|
||||
})
|
||||
|
||||
it('is a no-op when no noise phrases are present', () => {
|
||||
expect(stripBankNoise('lokalhyra mars 2026')).toBe('lokalhyra mars 2026')
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// Milersättning / traktamente routing — regression guards for
|
||||
// the "Överföring via internet" → 6230 Internet miscategorization
|
||||
// ============================================================
|
||||
|
||||
describe('milersättning routing', () => {
|
||||
it('matches personnel_mileage_taxfree (7331) for "milersättning"', () => {
|
||||
const tx = makeTransaction({
|
||||
amount: -1496,
|
||||
description: 'milersättning april',
|
||||
})
|
||||
const matches = findMatchingTemplates(tx)
|
||||
expect(matches[0]?.template.id).toBe('personnel_mileage_taxfree')
|
||||
expect(matches[0]?.template.debit_account).toBe('7331')
|
||||
})
|
||||
|
||||
it('does NOT match telecom_internet when description contains "överföring via internet"', () => {
|
||||
const tx = makeTransaction({
|
||||
amount: -1496,
|
||||
description: 'milersättning april Överföring via internet',
|
||||
})
|
||||
const matches = findMatchingTemplates(tx)
|
||||
// The fragile substring match used to fire telecom_internet (6230 + 25% VAT)
|
||||
// because the bank suffix contains the word "internet". After stripping
|
||||
// bank noise, only milersättning should match.
|
||||
expect(matches.find((m) => m.template.id === 'telecom_internet')).toBeUndefined()
|
||||
expect(matches[0]?.template.id).toBe('personnel_mileage_taxfree')
|
||||
})
|
||||
|
||||
it('emits no VAT lines for milersättning (employee reimbursement, not a purchase)', () => {
|
||||
const template = getTemplateById('personnel_mileage_taxfree')
|
||||
expect(template).toBeDefined()
|
||||
const tx = makeTransaction({ amount: -1496 })
|
||||
const result = buildMappingResultFromTemplate(template!, tx, 'aktiebolag')
|
||||
expect(result.debit_account).toBe('7331')
|
||||
expect(result.vat_lines).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('"körersättning" also matches personnel_mileage_taxfree', () => {
|
||||
const tx = makeTransaction({
|
||||
amount: -1850,
|
||||
description: 'körersättning Q1',
|
||||
})
|
||||
const matches = findMatchingTemplates(tx)
|
||||
expect(matches[0]?.template.id).toBe('personnel_mileage_taxfree')
|
||||
})
|
||||
|
||||
it('still resolves real internet bills via the telecom_internet template', () => {
|
||||
// Make sure we didn't break legitimate internet-bill matching by adding
|
||||
// noise stripping. The merchant signal carries the match.
|
||||
const tx = makeTransaction({
|
||||
amount: -399,
|
||||
description: 'Bahnhof bredband mars',
|
||||
merchant_name: 'Bahnhof',
|
||||
})
|
||||
const matches = findMatchingTemplates(tx)
|
||||
expect(matches[0]?.template.id).toBe('telecom_internet')
|
||||
})
|
||||
})
|
||||
|
||||
describe('traktamente routing', () => {
|
||||
it('matches personnel_per_diem_sweden_taxfree (7321) for "traktamente"', () => {
|
||||
const tx = makeTransaction({
|
||||
amount: -290,
|
||||
description: 'traktamente Stockholm april',
|
||||
})
|
||||
const matches = findMatchingTemplates(tx)
|
||||
expect(matches[0]?.template.id).toBe('personnel_per_diem_sweden_taxfree')
|
||||
expect(matches[0]?.template.debit_account).toBe('7321')
|
||||
})
|
||||
|
||||
it('matches utlandstraktamente → 7323', () => {
|
||||
const tx = makeTransaction({
|
||||
amount: -800,
|
||||
description: 'utlandstraktamente Tyskland',
|
||||
})
|
||||
const matches = findMatchingTemplates(tx)
|
||||
expect(matches[0]?.template.id).toBe('personnel_per_diem_abroad_taxfree')
|
||||
expect(matches[0]?.template.debit_account).toBe('7323')
|
||||
})
|
||||
|
||||
it('emits no VAT lines for traktamente', () => {
|
||||
const template = getTemplateById('personnel_per_diem_sweden_taxfree')
|
||||
expect(template).toBeDefined()
|
||||
const tx = makeTransaction({ amount: -290 })
|
||||
const result = buildMappingResultFromTemplate(template!, tx, 'aktiebolag')
|
||||
expect(result.debit_account).toBe('7321')
|
||||
expect(result.vat_lines).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// buildMappingResultFromTemplate
|
||||
// ============================================================
|
||||
@@ -519,7 +644,7 @@ describe('buildMappingResultFromTemplate', () => {
|
||||
expect(result.vat_lines[0].debit_amount).toBe(250) // 2645
|
||||
expect(result.vat_lines[1].credit_amount).toBe(250) // 2614
|
||||
expect(result.vat_lines[2].debit_amount).toBe(1000) // 4535 basbelopp
|
||||
expect(result.vat_lines[3].credit_amount).toBe(1000) // 4598
|
||||
expect(result.vat_lines[3].credit_amount).toBe(1000) // 4608
|
||||
})
|
||||
|
||||
it('emits SEK amounts for output VAT on non-SEK income', () => {
|
||||
|
||||
@@ -972,7 +972,7 @@ export const BOOKING_TEMPLATES: readonly BookingTemplate[] = [
|
||||
common: false,
|
||||
},
|
||||
|
||||
// --- PERSONNEL (3) ---
|
||||
// --- PERSONNEL (10) ---
|
||||
{
|
||||
id: 'personnel_salary',
|
||||
name_sv: 'Lön (netto)',
|
||||
@@ -1044,6 +1044,177 @@ export const BOOKING_TEMPLATES: readonly BookingTemplate[] = [
|
||||
description_sv: 'Preliminärskatt till Skatteverket',
|
||||
common: false,
|
||||
},
|
||||
{
|
||||
id: 'personnel_mileage_taxfree',
|
||||
name_sv: 'Skattefri bilersättning (mil)',
|
||||
name_en: 'Tax-free mileage reimbursement',
|
||||
group: 'personnel',
|
||||
direction: 'expense',
|
||||
entity_applicability: 'all',
|
||||
debit_account: '7331',
|
||||
credit_account: '1930',
|
||||
vat_treatment: null,
|
||||
vat_rate: 0,
|
||||
deductibility: 'full',
|
||||
special_rules_sv: 'Ersättning till anställd för bil- och körersättning, skattefri inom Skatteverkets gränsbelopp (för 2026: kontrollera aktuell mil-ersättning). Underlag: körjournal. Ingen ingående moms.',
|
||||
mcc_codes: [],
|
||||
keywords: ['milersättning', 'mil ersättning', 'milerstättning', 'körersättning', 'kör ersättning', 'bilersättning', 'reseersättning', 'kilometerersättning'],
|
||||
risk_level: 'LOW',
|
||||
requires_review: true,
|
||||
impact_score: 5,
|
||||
auto_match_confidence: 0.95,
|
||||
default_private: false,
|
||||
fallback_category: 'expense_other',
|
||||
description_sv: 'Skattefri milersättning till anställd (kräver körjournal)',
|
||||
common: true,
|
||||
},
|
||||
{
|
||||
id: 'personnel_mileage_taxable',
|
||||
name_sv: 'Skattepliktig bilersättning (mil)',
|
||||
name_en: 'Taxable mileage reimbursement',
|
||||
group: 'personnel',
|
||||
direction: 'expense',
|
||||
entity_applicability: 'all',
|
||||
debit_account: '7332',
|
||||
credit_account: '1930',
|
||||
vat_treatment: null,
|
||||
vat_rate: 0,
|
||||
deductibility: 'full',
|
||||
special_rules_sv: 'Den del av bilersättningen som överstiger Skatteverkets skattefria gränsbelopp. Skattepliktig för mottagaren och ska tas upp på AGI. Ingen ingående moms.',
|
||||
mcc_codes: [],
|
||||
keywords: ['skattepliktig milersättning', 'skattepliktig bilersättning', 'överskjutande bilersättning'],
|
||||
risk_level: 'MEDIUM',
|
||||
requires_review: true,
|
||||
impact_score: 5,
|
||||
auto_match_confidence: 0.90,
|
||||
default_private: false,
|
||||
fallback_category: 'expense_other',
|
||||
description_sv: 'Skattepliktig del av bilersättning (AGI-pliktig)',
|
||||
common: false,
|
||||
},
|
||||
{
|
||||
id: 'personnel_per_diem_sweden_taxfree',
|
||||
name_sv: 'Skattefritt traktamente (Sverige)',
|
||||
name_en: 'Tax-free per diem (Sweden)',
|
||||
group: 'personnel',
|
||||
direction: 'expense',
|
||||
entity_applicability: 'all',
|
||||
debit_account: '7321',
|
||||
credit_account: '1930',
|
||||
vat_treatment: null,
|
||||
vat_rate: 0,
|
||||
deductibility: 'full',
|
||||
special_rules_sv: 'Skattefritt traktamente för tjänsteresa i Sverige inom Skatteverkets schablonbelopp. Kräver reseräkning + övernattning >50 km från tjänstestället. Ingen ingående moms.',
|
||||
mcc_codes: [],
|
||||
// Note: keywords are intentionally narrow. "utlandstraktamente" must route
|
||||
// to the abroad template, not here — so we avoid the bare "traktament"
|
||||
// substring (which would also fire on "utlandstraktamente").
|
||||
keywords: ['traktamente', 'dagtraktamente', 'helt dygn', 'halvt dygn'],
|
||||
risk_level: 'LOW',
|
||||
requires_review: true,
|
||||
impact_score: 5,
|
||||
auto_match_confidence: 0.95,
|
||||
default_private: false,
|
||||
fallback_category: 'expense_travel',
|
||||
description_sv: 'Skattefritt traktamente för tjänsteresa i Sverige (kräver reseräkning)',
|
||||
common: true,
|
||||
},
|
||||
{
|
||||
id: 'personnel_per_diem_sweden_taxable',
|
||||
name_sv: 'Skattepliktigt traktamente (Sverige)',
|
||||
name_en: 'Taxable per diem (Sweden)',
|
||||
group: 'personnel',
|
||||
direction: 'expense',
|
||||
entity_applicability: 'all',
|
||||
debit_account: '7322',
|
||||
credit_account: '1930',
|
||||
vat_treatment: null,
|
||||
vat_rate: 0,
|
||||
deductibility: 'full',
|
||||
special_rules_sv: 'Den del av traktamentet som överstiger Skatteverkets skattefria schablon. Skattepliktigt för mottagaren och ska tas upp på AGI.',
|
||||
mcc_codes: [],
|
||||
keywords: ['skattepliktigt traktamente', 'överskjutande traktamente'],
|
||||
risk_level: 'MEDIUM',
|
||||
requires_review: true,
|
||||
impact_score: 4,
|
||||
auto_match_confidence: 0.90,
|
||||
default_private: false,
|
||||
fallback_category: 'expense_travel',
|
||||
description_sv: 'Skattepliktig del av traktamente Sverige (AGI-pliktig)',
|
||||
common: false,
|
||||
},
|
||||
{
|
||||
id: 'personnel_per_diem_abroad_taxfree',
|
||||
name_sv: 'Skattefritt traktamente (utlandet)',
|
||||
name_en: 'Tax-free per diem (abroad)',
|
||||
group: 'personnel',
|
||||
direction: 'expense',
|
||||
entity_applicability: 'all',
|
||||
debit_account: '7323',
|
||||
credit_account: '1930',
|
||||
vat_treatment: null,
|
||||
vat_rate: 0,
|
||||
deductibility: 'full',
|
||||
special_rules_sv: 'Skattefritt utlandstraktamente per land enligt Skatteverkets normalbelopp. Kräver reseräkning. Tremånadersregeln gäller efter 3 mån.',
|
||||
mcc_codes: [],
|
||||
keywords: ['utlandstraktamente', 'traktamente utland', 'utlandsresa', 'utlands', 'normalbelopp'],
|
||||
risk_level: 'LOW',
|
||||
requires_review: true,
|
||||
impact_score: 4,
|
||||
auto_match_confidence: 0.90,
|
||||
default_private: false,
|
||||
fallback_category: 'expense_travel',
|
||||
description_sv: 'Skattefritt traktamente för tjänsteresa utomlands',
|
||||
common: false,
|
||||
},
|
||||
{
|
||||
id: 'personnel_per_diem_abroad_taxable',
|
||||
name_sv: 'Skattepliktigt traktamente (utlandet)',
|
||||
name_en: 'Taxable per diem (abroad)',
|
||||
group: 'personnel',
|
||||
direction: 'expense',
|
||||
entity_applicability: 'all',
|
||||
debit_account: '7324',
|
||||
credit_account: '1930',
|
||||
vat_treatment: null,
|
||||
vat_rate: 0,
|
||||
deductibility: 'full',
|
||||
special_rules_sv: 'Del av utlandstraktamente som överstiger Skatteverkets normalbelopp. Skattepliktig och AGI-pliktig.',
|
||||
mcc_codes: [],
|
||||
keywords: ['skattepliktigt utlandstraktamente'],
|
||||
risk_level: 'MEDIUM',
|
||||
requires_review: true,
|
||||
impact_score: 3,
|
||||
auto_match_confidence: 0.90,
|
||||
default_private: false,
|
||||
fallback_category: 'expense_travel',
|
||||
description_sv: 'Skattepliktig del av utlandstraktamente (AGI-pliktig)',
|
||||
common: false,
|
||||
},
|
||||
{
|
||||
id: 'personnel_congestion_charge_taxfree',
|
||||
name_sv: 'Trängselskatt (skattefri ersättning)',
|
||||
name_en: 'Congestion charge (tax-free reimbursement)',
|
||||
group: 'personnel',
|
||||
direction: 'expense',
|
||||
entity_applicability: 'all',
|
||||
debit_account: '7333',
|
||||
credit_account: '1930',
|
||||
vat_treatment: null,
|
||||
vat_rate: 0,
|
||||
deductibility: 'full',
|
||||
special_rules_sv: 'Ersättning till anställd för trängselskatt vid tjänsteresa, skattefri enligt 11 kap 26 § IL.',
|
||||
mcc_codes: [],
|
||||
keywords: ['trängselskatt', 'trängselavgift', 'congestion'],
|
||||
risk_level: 'LOW',
|
||||
requires_review: false,
|
||||
impact_score: 3,
|
||||
auto_match_confidence: 0.85,
|
||||
default_private: false,
|
||||
fallback_category: 'expense_travel',
|
||||
description_sv: 'Ersättning för trängselskatt vid tjänsteresa',
|
||||
common: false,
|
||||
},
|
||||
|
||||
// --- REVENUE (4) ---
|
||||
{
|
||||
@@ -1092,6 +1263,29 @@ export const BOOKING_TEMPLATES: readonly BookingTemplate[] = [
|
||||
description_sv: 'Intäkter från livsmedelsförsäljning eller logi med 12% moms',
|
||||
common: true,
|
||||
},
|
||||
{
|
||||
id: 'revenue_reduced_6',
|
||||
name_sv: 'Försäljning 6% moms (böcker/kultur/transport)',
|
||||
name_en: 'Revenue 6% VAT (books/culture/transport)',
|
||||
group: 'revenue',
|
||||
direction: 'income',
|
||||
entity_applicability: 'all',
|
||||
debit_account: '1930',
|
||||
credit_account: '3003',
|
||||
vat_treatment: 'reduced_6',
|
||||
vat_rate: 0.06,
|
||||
deductibility: 'full',
|
||||
mcc_codes: [],
|
||||
keywords: ['bok', 'böcker', 'tidning', 'tidskrift', 'e-bok', 'persontransport', 'taxi', 'buss', 'tåg', 'kultur', 'konsert', 'teater', 'museum', 'bio', 'idrott', 'books', 'culture', 'transport'],
|
||||
risk_level: 'NONE',
|
||||
requires_review: false,
|
||||
impact_score: 5,
|
||||
auto_match_confidence: 0.75,
|
||||
default_private: false,
|
||||
fallback_category: 'income_services',
|
||||
description_sv: 'Intäkter med 6% moms (böcker, persontransport, kultur, idrott)',
|
||||
common: true,
|
||||
},
|
||||
{
|
||||
id: 'revenue_eu_services',
|
||||
name_sv: 'Tjänsteförsäljning EU (omvänd moms)',
|
||||
@@ -1141,6 +1335,31 @@ export const BOOKING_TEMPLATES: readonly BookingTemplate[] = [
|
||||
description_sv: 'Export av varor/tjänster utanför EU (momsfritt, ruta 40)',
|
||||
common: true,
|
||||
},
|
||||
{
|
||||
id: 'revenue_exempt_domestic',
|
||||
name_sv: 'Momsfri försäljning (Sverige)',
|
||||
name_en: 'VAT-exempt sales (domestic)',
|
||||
group: 'revenue',
|
||||
direction: 'income',
|
||||
entity_applicability: 'all',
|
||||
debit_account: '1930',
|
||||
credit_account: '3100',
|
||||
credit_account_ab: '3004',
|
||||
vat_treatment: 'exempt',
|
||||
vat_rate: 0,
|
||||
deductibility: 'full',
|
||||
special_rules_sv: 'Momsfri försäljning inom Sverige (t.ex. sjukvård, tandvård, utbildning, social omsorg, försäkring, finansiella tjänster) → rapportera i ruta 42',
|
||||
mcc_codes: [],
|
||||
keywords: ['momsfri', 'momsfritt', 'sjukvård', 'tandvård', 'utbildning', 'undervisning', 'social omsorg', 'kultur', 'försäkring', 'finansiella tjänster', 'exempt', 'healthcare', 'education'],
|
||||
risk_level: 'LOW',
|
||||
requires_review: false,
|
||||
impact_score: 5,
|
||||
auto_match_confidence: 0.70,
|
||||
default_private: false,
|
||||
fallback_category: 'income_services',
|
||||
description_sv: 'Momsfri försäljning inom Sverige (sjukvård, utbildning m.m., ruta 42)',
|
||||
common: true,
|
||||
},
|
||||
|
||||
// --- FINANCIAL (2) ---
|
||||
{
|
||||
@@ -1486,6 +1705,60 @@ export function validateTemplateForEntity(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Common Swedish bank-description prefixes/suffixes that describe HOW a payment
|
||||
* was made, not WHAT was purchased. These get stripped before keyword matching
|
||||
* so a transaction text like "milersättning april Överföring via internet" no
|
||||
* longer collides with the `telecom_internet` template's `internet` keyword.
|
||||
*
|
||||
* Order matters: list longer phrases first so substring removal hits them
|
||||
* before shorter ones (e.g. "överföring via internet" before "internet").
|
||||
*/
|
||||
const BANK_NOISE_PHRASES: readonly string[] = [
|
||||
'överföring via internet',
|
||||
'överföring via mobil',
|
||||
'överföring via app',
|
||||
'överföring inom bank',
|
||||
'överföring mellan konton',
|
||||
'internetbetalning',
|
||||
'mobilbetalning',
|
||||
'direktbetalning',
|
||||
'direktöverföring',
|
||||
'autogirobetalning',
|
||||
'autogiro',
|
||||
'bg-betalning',
|
||||
'bg betalning',
|
||||
'pg-betalning',
|
||||
'pg betalning',
|
||||
'bgmax',
|
||||
'bg-inb',
|
||||
'plusgiro',
|
||||
'bankgiro',
|
||||
'swish till',
|
||||
'swish från',
|
||||
'swish-betalning',
|
||||
'kortköp',
|
||||
'kortbetalning',
|
||||
'webbköp',
|
||||
'överföring',
|
||||
'insättning',
|
||||
]
|
||||
|
||||
/**
|
||||
* Strip bank-payment-method noise from a description so the substring matcher
|
||||
* doesn't match on the bank's own prefix vocabulary. Operates on lowercase
|
||||
* input and collapses the whitespace it leaves behind.
|
||||
*/
|
||||
export function stripBankNoise(lowerText: string): string {
|
||||
let out = lowerText
|
||||
for (const phrase of BANK_NOISE_PHRASES) {
|
||||
if (out.includes(phrase)) {
|
||||
out = out.split(phrase).join(' ')
|
||||
}
|
||||
}
|
||||
return out.replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Multi-signal matching against a transaction.
|
||||
* Returns top matches sorted by confidence descending.
|
||||
@@ -1500,7 +1773,10 @@ export function findMatchingTemplates(
|
||||
|
||||
const descLower = (transaction.description || '').toLowerCase()
|
||||
const merchantLower = (transaction.merchant_name || '').toLowerCase()
|
||||
const searchText = `${descLower} ${merchantLower}`
|
||||
const rawSearchText = `${descLower} ${merchantLower}`
|
||||
// Strip bank-method noise so e.g. "Överföring via internet" doesn't make
|
||||
// the matcher believe the merchant is "Internet" (→ 6230 telecom).
|
||||
const searchText = stripBankNoise(rawSearchText)
|
||||
|
||||
for (const t of BOOKING_TEMPLATES) {
|
||||
// Filter entity applicability
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Maps raw errors to user-friendly Swedish messages.
|
||||
* Maps raw errors to user-friendly localized messages.
|
||||
*
|
||||
* Priority chain:
|
||||
* 1. Zod validation field errors
|
||||
@@ -7,6 +7,14 @@
|
||||
* 3. HTTP status code map
|
||||
* 4. Context-specific fallback
|
||||
* 5. Generic fallback
|
||||
*
|
||||
* Callers can pass an explicit `locale` ('sv' | 'en'). Default 'sv' so existing
|
||||
* server-side callers (cron, background jobs, logs) keep their current Swedish
|
||||
* output. UI callers should pass the active locale from useLocale() / getLocale().
|
||||
*
|
||||
* Specific domain phrases (locked period, unbalanced voucher, etc.) remain
|
||||
* Swedish for now — those refer to statutory accounting concepts and English
|
||||
* users will still see them on Skatteverket-bound surfaces.
|
||||
*/
|
||||
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
@@ -22,53 +30,62 @@ type ErrorContext =
|
||||
| 'auth'
|
||||
| 'salary'
|
||||
|
||||
export type ErrorLocale = 'sv' | 'en'
|
||||
|
||||
interface GetErrorMessageOptions {
|
||||
context?: ErrorContext
|
||||
statusCode?: number
|
||||
locale?: ErrorLocale
|
||||
}
|
||||
|
||||
// Postgres error codes -> Swedish messages
|
||||
const POSTGRES_ERROR_MAP: Record<string, string> = {
|
||||
'23505': 'En post med samma uppgifter finns redan.',
|
||||
'23503': 'Posten kan inte ändras eftersom den refereras av annan data.',
|
||||
'23502': 'Ett obligatoriskt fält saknas.',
|
||||
'42501': 'Du har inte behörighet att utföra denna åtgärd.',
|
||||
'42P01': 'Resursen kunde inte hittas.',
|
||||
'23514': 'Värdet uppfyller inte de tillåtna kraven.',
|
||||
'40001': 'En annan ändring pågick samtidigt. Försök igen.',
|
||||
'40P01': 'En konflikt uppstod. Försök igen.',
|
||||
'22P02': 'Ogiltigt värde angavs.',
|
||||
'22003': 'Värdet är utanför tillåtet intervall.',
|
||||
type Bilingual = { sv: string; en: string }
|
||||
|
||||
function pick(b: Bilingual, locale: ErrorLocale): string {
|
||||
return b[locale] ?? b.sv
|
||||
}
|
||||
|
||||
// HTTP status codes -> Swedish messages
|
||||
const HTTP_STATUS_MAP: Record<number, string> = {
|
||||
400: 'Förfrågan innehåller ogiltiga uppgifter.',
|
||||
401: 'Din session har gått ut. Logga in igen.',
|
||||
403: 'Du har inte behörighet att utföra denna åtgärd.',
|
||||
404: 'Resursen kunde inte hittas.',
|
||||
409: 'En konflikt uppstod. Ladda om sidan och försök igen.',
|
||||
422: 'Uppgifterna kunde inte bearbetas. Kontrollera fälten och försök igen.',
|
||||
429: 'För många förfrågningar. Vänta en stund och försök igen.',
|
||||
500: 'Ett oväntat serverfel uppstod. Försök igen senare.',
|
||||
502: 'Servern är tillfälligt otillgänglig. Försök igen om en stund.',
|
||||
503: 'Tjänsten är tillfälligt otillgänglig. Försök igen om en stund.',
|
||||
// Postgres error codes -> localized messages
|
||||
const POSTGRES_ERROR_MAP: Record<string, Bilingual> = {
|
||||
'23505': { sv: 'En post med samma uppgifter finns redan.', en: 'A record with the same details already exists.' },
|
||||
'23503': { sv: 'Posten kan inte ändras eftersom den refereras av annan data.', en: 'This record cannot be changed because other data refers to it.' },
|
||||
'23502': { sv: 'Ett obligatoriskt fält saknas.', en: 'A required field is missing.' },
|
||||
'42501': { sv: 'Du har inte behörighet att utföra denna åtgärd.', en: 'You do not have permission to perform this action.' },
|
||||
'42P01': { sv: 'Resursen kunde inte hittas.', en: 'The resource could not be found.' },
|
||||
'23514': { sv: 'Värdet uppfyller inte de tillåtna kraven.', en: 'The value does not meet the allowed constraints.' },
|
||||
'40001': { sv: 'En annan ändring pågick samtidigt. Försök igen.', en: 'A concurrent change was in progress. Please try again.' },
|
||||
'40P01': { sv: 'En konflikt uppstod. Försök igen.', en: 'A conflict occurred. Please try again.' },
|
||||
'22P02': { sv: 'Ogiltigt värde angavs.', en: 'Invalid value supplied.' },
|
||||
'22003': { sv: 'Värdet är utanför tillåtet intervall.', en: 'Value is out of allowed range.' },
|
||||
}
|
||||
|
||||
// HTTP status codes -> localized messages
|
||||
const HTTP_STATUS_MAP: Record<number, Bilingual> = {
|
||||
400: { sv: 'Förfrågan innehåller ogiltiga uppgifter.', en: 'The request contains invalid data.' },
|
||||
401: { sv: 'Din session har gått ut. Logga in igen.', en: 'Your session has expired. Please sign in again.' },
|
||||
403: { sv: 'Du har inte behörighet att utföra denna åtgärd.', en: 'You do not have permission to perform this action.' },
|
||||
404: { sv: 'Resursen kunde inte hittas.', en: 'The resource could not be found.' },
|
||||
409: { sv: 'En konflikt uppstod. Ladda om sidan och försök igen.', en: 'A conflict occurred. Reload the page and try again.' },
|
||||
422: { sv: 'Uppgifterna kunde inte bearbetas. Kontrollera fälten och försök igen.', en: 'The data could not be processed. Check the fields and try again.' },
|
||||
429: { sv: 'För många förfrågningar. Vänta en stund och försök igen.', en: 'Too many requests. Wait a moment and try again.' },
|
||||
500: { sv: 'Ett oväntat serverfel uppstod. Försök igen senare.', en: 'An unexpected server error occurred. Please try again later.' },
|
||||
502: { sv: 'Servern är tillfälligt otillgänglig. Försök igen om en stund.', en: 'The server is temporarily unavailable. Please try again shortly.' },
|
||||
503: { sv: 'Tjänsten är tillfälligt otillgänglig. Försök igen om en stund.', en: 'The service is temporarily unavailable. Please try again shortly.' },
|
||||
}
|
||||
|
||||
// Context-specific fallbacks
|
||||
const CONTEXT_FALLBACKS: Record<ErrorContext, string> = {
|
||||
invoice: 'Kunde inte hantera fakturan. Försök igen.',
|
||||
supplier_invoice: 'Kunde inte hantera leverantörsfakturan. Försök igen.',
|
||||
customer: 'Kunde inte hantera kunden. Försök igen.',
|
||||
supplier: 'Kunde inte hantera leverantören. Försök igen.',
|
||||
transaction: 'Kunde inte hantera transaktionen. Försök igen.',
|
||||
journal_entry: 'Kunde inte hantera verifikationen. Försök igen.',
|
||||
settings: 'Kunde inte spara inställningarna. Försök igen.',
|
||||
auth: 'Ett fel uppstod vid inloggningen. Försök igen.',
|
||||
salary: 'Kunde inte hantera löneuppgifterna. Försök igen.',
|
||||
const CONTEXT_FALLBACKS: Record<ErrorContext, Bilingual> = {
|
||||
invoice: { sv: 'Kunde inte hantera fakturan. Försök igen.', en: 'Could not process the invoice. Please try again.' },
|
||||
supplier_invoice: { sv: 'Kunde inte hantera leverantörsfakturan. Försök igen.', en: 'Could not process the supplier invoice. Please try again.' },
|
||||
customer: { sv: 'Kunde inte hantera kunden. Försök igen.', en: 'Could not process the customer. Please try again.' },
|
||||
supplier: { sv: 'Kunde inte hantera leverantören. Försök igen.', en: 'Could not process the supplier. Please try again.' },
|
||||
transaction: { sv: 'Kunde inte hantera transaktionen. Försök igen.', en: 'Could not process the transaction. Please try again.' },
|
||||
journal_entry: { sv: 'Kunde inte hantera verifikationen. Försök igen.', en: 'Could not process the journal entry. Please try again.' },
|
||||
settings: { sv: 'Kunde inte spara inställningarna. Försök igen.', en: 'Could not save settings. Please try again.' },
|
||||
auth: { sv: 'Ett fel uppstod vid inloggningen. Försök igen.', en: 'An error occurred while signing in. Please try again.' },
|
||||
salary: { sv: 'Kunde inte hantera löneuppgifterna. Försök igen.', en: 'Could not process the payroll data. Please try again.' },
|
||||
}
|
||||
|
||||
const GENERIC_FALLBACK = 'Något gick fel. Försök igen.'
|
||||
const GENERIC_FALLBACK: Bilingual = { sv: 'Något gick fel. Försök igen.', en: 'Something went wrong. Please try again.' }
|
||||
|
||||
// Known error patterns → user-friendly Swedish messages
|
||||
const ERROR_PATTERN_MAP: [RegExp, string | null][] = [
|
||||
@@ -228,7 +245,7 @@ export function getErrorMessage(
|
||||
error: unknown,
|
||||
options: GetErrorMessageOptions = {}
|
||||
): string {
|
||||
const { context, statusCode } = options
|
||||
const { context, statusCode, locale = 'sv' } = options
|
||||
|
||||
// 1. If it's a string, check if it's already Swedish or matches a known pattern
|
||||
if (typeof error === 'string' && error.trim()) {
|
||||
@@ -241,19 +258,23 @@ export function getErrorMessage(
|
||||
if (typeof error === 'object' && error !== null) {
|
||||
const obj = error as Record<string, unknown>
|
||||
|
||||
// Bare envelope inner-error shape: { code, message, ... }. Happens when a
|
||||
// caller forwards `result.error` (the inner object) instead of the whole
|
||||
// `result`. Treat it the same as the wrapped form so we always end up with
|
||||
// the registry's Swedish message in the toast — never `[object Object]`.
|
||||
// Bare envelope inner-error shape: { code, message, message_en?, ... }.
|
||||
// Happens when a caller forwards `result.error` (the inner object) instead
|
||||
// of the whole `result`. Pick the English variant when the UI locale is
|
||||
// English; otherwise fall back to the Swedish `message`.
|
||||
if (typeof obj.code === 'string' && typeof obj.message === 'string' && obj.message.trim()) {
|
||||
if (locale === 'en' && typeof obj.message_en === 'string' && obj.message_en.trim()) {
|
||||
return obj.message_en
|
||||
}
|
||||
return obj.message
|
||||
}
|
||||
|
||||
// Structured application error: { error: { code, message, ... } }
|
||||
// Structured application error: { error: { code, message, message_en?, ... } }
|
||||
if (typeof obj.error === 'object' && obj.error !== null) {
|
||||
const structured = obj.error as {
|
||||
code?: unknown
|
||||
message?: unknown
|
||||
message_en?: unknown
|
||||
account_numbers?: unknown
|
||||
details?: unknown
|
||||
}
|
||||
@@ -314,6 +335,9 @@ export function getErrorMessage(
|
||||
return 'Verifikationen kunde inte sparas. Försök igen.'
|
||||
}
|
||||
|
||||
if (locale === 'en' && typeof structured.message_en === 'string' && structured.message_en.trim()) {
|
||||
return structured.message_en
|
||||
}
|
||||
if (typeof structured.message === 'string' && structured.message.trim()) {
|
||||
return structured.message
|
||||
}
|
||||
@@ -325,7 +349,7 @@ export function getErrorMessage(
|
||||
|
||||
// Try Postgres error code
|
||||
if (typeof obj.code === 'string' && POSTGRES_ERROR_MAP[obj.code]) {
|
||||
return POSTGRES_ERROR_MAP[obj.code]
|
||||
return pick(POSTGRES_ERROR_MAP[obj.code], locale)
|
||||
}
|
||||
|
||||
// Try known error patterns (e.g. locked period triggers)
|
||||
@@ -355,16 +379,16 @@ export function getErrorMessage(
|
||||
|
||||
// 4. HTTP status code map
|
||||
if (statusCode && HTTP_STATUS_MAP[statusCode]) {
|
||||
return HTTP_STATUS_MAP[statusCode]
|
||||
return pick(HTTP_STATUS_MAP[statusCode], locale)
|
||||
}
|
||||
|
||||
// 5. Context-specific fallback
|
||||
if (context && CONTEXT_FALLBACKS[context]) {
|
||||
return CONTEXT_FALLBACKS[context]
|
||||
return pick(CONTEXT_FALLBACKS[context], locale)
|
||||
}
|
||||
|
||||
// 6. Generic fallback
|
||||
return GENERIC_FALLBACK
|
||||
return pick(GENERIC_FALLBACK, locale)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -372,12 +396,13 @@ export function getErrorMessage(
|
||||
*/
|
||||
export async function getResponseErrorMessage(
|
||||
response: Response,
|
||||
context?: ErrorContext
|
||||
context?: ErrorContext,
|
||||
locale?: ErrorLocale,
|
||||
): Promise<string> {
|
||||
try {
|
||||
const body = await response.json()
|
||||
return getErrorMessage(body, { context, statusCode: response.status })
|
||||
return getErrorMessage(body, { context, statusCode: response.status, locale })
|
||||
} catch {
|
||||
return getErrorMessage(null, { context, statusCode: response.status })
|
||||
return getErrorMessage(null, { context, statusCode: response.status, locale })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
// Translation key lookup for extension and sector slugs.
|
||||
//
|
||||
// Extension manifests ship Swedish-only names/descriptions in the data layer
|
||||
// (lib/extensions/_generated/sector-definitions.ts and lib/extensions/sectors.ts).
|
||||
// Translation happens at the render layer via these slug → key helpers.
|
||||
//
|
||||
// For known slugs, return the translation key under the `extensions` namespace.
|
||||
// For unknown slugs, return null — callers fall back to the manifest values.
|
||||
|
||||
export function extensionNameKey(slug: string): string | null {
|
||||
switch (slug) {
|
||||
case 'enable-banking':
|
||||
return 'ext_enable_banking_name'
|
||||
case 'email':
|
||||
return 'ext_email_name'
|
||||
case 'arcim-migration':
|
||||
return 'ext_arcim_migration_name'
|
||||
case 'tic':
|
||||
return 'ext_tic_name'
|
||||
case 'mcp-server':
|
||||
return 'ext_mcp_server_name'
|
||||
case 'cloud-backup':
|
||||
return 'ext_cloud_backup_name'
|
||||
case 'skatteverket':
|
||||
return 'ext_skatteverket_name'
|
||||
case 'invoice-inbox':
|
||||
return 'ext_invoice_inbox_name'
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function extensionDescriptionKey(slug: string): string | null {
|
||||
switch (slug) {
|
||||
case 'enable-banking':
|
||||
return 'ext_enable_banking_description'
|
||||
case 'email':
|
||||
return 'ext_email_description'
|
||||
case 'arcim-migration':
|
||||
return 'ext_arcim_migration_description'
|
||||
case 'tic':
|
||||
return 'ext_tic_description'
|
||||
case 'mcp-server':
|
||||
return 'ext_mcp_server_description'
|
||||
case 'cloud-backup':
|
||||
return 'ext_cloud_backup_description'
|
||||
case 'skatteverket':
|
||||
return 'ext_skatteverket_description'
|
||||
case 'invoice-inbox':
|
||||
return 'ext_invoice_inbox_description'
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function extensionLongDescriptionKey(slug: string): string | null {
|
||||
switch (slug) {
|
||||
case 'enable-banking':
|
||||
return 'ext_enable_banking_long_description'
|
||||
case 'email':
|
||||
return 'ext_email_long_description'
|
||||
case 'arcim-migration':
|
||||
return 'ext_arcim_migration_long_description'
|
||||
case 'tic':
|
||||
return 'ext_tic_long_description'
|
||||
case 'mcp-server':
|
||||
return 'ext_mcp_server_long_description'
|
||||
case 'cloud-backup':
|
||||
return 'ext_cloud_backup_long_description'
|
||||
case 'skatteverket':
|
||||
return 'ext_skatteverket_long_description'
|
||||
case 'invoice-inbox':
|
||||
return 'ext_invoice_inbox_long_description'
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function sectorNameKey(slug: string): string | null {
|
||||
switch (slug) {
|
||||
case 'general':
|
||||
return 'sector_general_name'
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function sectorDescriptionKey(slug: string): string | null {
|
||||
switch (slug) {
|
||||
case 'general':
|
||||
return 'sector_general_description'
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
'use client'
|
||||
|
||||
import { useLocale } from 'next-intl'
|
||||
import { formatDateLong as formatDateLongRaw } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* Client-side hook returning locale-aware formatters.
|
||||
*
|
||||
* Currency stays SEK with sv-SE conventions (1 234,56 kr) regardless of UI
|
||||
* language — that's the Swedish accounting standard, not a UI string.
|
||||
* formatDate (ISO yyyy-MM-dd) is locale-independent and exported directly
|
||||
* from lib/utils.
|
||||
*/
|
||||
export function useFormat() {
|
||||
const locale = useLocale()
|
||||
return {
|
||||
locale,
|
||||
formatDateLong: (date: Date | string) => formatDateLongRaw(date, locale),
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,8 @@ import type { KPIPreferences } from '@/types'
|
||||
|
||||
export interface KPIDefinition {
|
||||
id: string
|
||||
label: string
|
||||
subtitle: string
|
||||
description: string
|
||||
formula: string
|
||||
// Translation key suffix; used as `kpi.def_<id>_label` / `_description` / `_formula` / `_accounts`.
|
||||
defaultAccounts: string[]
|
||||
accountDescription: string
|
||||
customizableAccounts: boolean
|
||||
defaultVisible: boolean
|
||||
format: 'currency' | 'percentage' | 'days'
|
||||
@@ -17,12 +13,7 @@ export interface KPIDefinition {
|
||||
export const KPI_DEFINITIONS: KPIDefinition[] = [
|
||||
{
|
||||
id: 'netResult',
|
||||
label: 'Resultat',
|
||||
subtitle: 'netto',
|
||||
description: 'Nettoresultat för perioden (intäkter minus kostnader)',
|
||||
formula: 'Totala intäkter − Totala kostnader ± Finansiella poster',
|
||||
defaultAccounts: [],
|
||||
accountDescription: 'Intäkter (klass 3), Kostnader (klass 4–7), Finansiellt (klass 8)',
|
||||
customizableAccounts: false,
|
||||
defaultVisible: true,
|
||||
format: 'currency',
|
||||
@@ -30,12 +21,7 @@ export const KPI_DEFINITIONS: KPIDefinition[] = [
|
||||
},
|
||||
{
|
||||
id: 'cashPosition',
|
||||
label: 'Kassa',
|
||||
subtitle: 'likvida medel',
|
||||
description: 'Totala likvida medel (bank- och kassakonton)',
|
||||
formula: 'Summa utgående saldon för valda 19xx-konton',
|
||||
defaultAccounts: ['1910', '1920', '1930', '1940', '1950', '1960', '1970', '1980'],
|
||||
accountDescription: 'Bank- och kassakonton (19xx)',
|
||||
customizableAccounts: true,
|
||||
defaultVisible: true,
|
||||
format: 'currency',
|
||||
@@ -43,12 +29,7 @@ export const KPI_DEFINITIONS: KPIDefinition[] = [
|
||||
},
|
||||
{
|
||||
id: 'outstandingReceivables',
|
||||
label: 'Kundfordringar',
|
||||
subtitle: 'utestående',
|
||||
description: 'Utestående kundfordringar',
|
||||
formula: 'Summa obetalda kundfakturor',
|
||||
defaultAccounts: ['1510'],
|
||||
accountDescription: 'Kundfordringar (1510)',
|
||||
customizableAccounts: false,
|
||||
defaultVisible: true,
|
||||
format: 'currency',
|
||||
@@ -56,12 +37,7 @@ export const KPI_DEFINITIONS: KPIDefinition[] = [
|
||||
},
|
||||
{
|
||||
id: 'vatLiability',
|
||||
label: 'Moms',
|
||||
subtitle: '',
|
||||
description: 'Momsskuld eller momsfordran för perioden',
|
||||
formula: 'Utgående moms (2611 + 2621 + 2631) − Ingående moms (2641 + 2645)',
|
||||
defaultAccounts: ['2611', '2621', '2631', '2641', '2645'],
|
||||
accountDescription: 'Utgående moms (2611, 2621, 2631), Ingående moms (2641, 2645)',
|
||||
customizableAccounts: true,
|
||||
defaultVisible: true,
|
||||
format: 'currency',
|
||||
@@ -69,12 +45,7 @@ export const KPI_DEFINITIONS: KPIDefinition[] = [
|
||||
},
|
||||
{
|
||||
id: 'grossMargin',
|
||||
label: 'Bruttomarginal',
|
||||
subtitle: 'av intäkter',
|
||||
description: 'Andel av intäkterna som blir kvar efter varuinköp',
|
||||
formula: '(Intäkter − Varukostnad klass 4) ÷ Intäkter × 100',
|
||||
defaultAccounts: [],
|
||||
accountDescription: 'Intäkter (klass 3), Varor och material (klass 4)',
|
||||
customizableAccounts: false,
|
||||
defaultVisible: false,
|
||||
format: 'percentage',
|
||||
@@ -82,12 +53,7 @@ export const KPI_DEFINITIONS: KPIDefinition[] = [
|
||||
},
|
||||
{
|
||||
id: 'expenseRatio',
|
||||
label: 'Kostnadsandel',
|
||||
subtitle: 'av intäkter',
|
||||
description: 'Andel av intäkterna som går till kostnader',
|
||||
formula: 'Totala kostnader ÷ Totala intäkter × 100',
|
||||
defaultAccounts: [],
|
||||
accountDescription: 'Intäkter (klass 3), Kostnader (klass 4–7)',
|
||||
customizableAccounts: false,
|
||||
defaultVisible: false,
|
||||
format: 'percentage',
|
||||
@@ -95,12 +61,7 @@ export const KPI_DEFINITIONS: KPIDefinition[] = [
|
||||
},
|
||||
{
|
||||
id: 'avgPaymentDays',
|
||||
label: 'Betalningstid',
|
||||
subtitle: 'snitt',
|
||||
description: 'Genomsnittligt antal dagar till kundbetalning',
|
||||
formula: 'Summa betaldagar ÷ Antal betalda fakturor (minst 5 krävs)',
|
||||
defaultAccounts: [],
|
||||
accountDescription: 'Beräknas från betalda kundfakturor, inte konton',
|
||||
customizableAccounts: false,
|
||||
defaultVisible: false,
|
||||
format: 'days',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createServerClient } from '@supabase/ssr'
|
||||
import { NextResponse, type NextRequest } from 'next/server'
|
||||
import { shouldEnforceMfa } from '@/lib/auth/mfa'
|
||||
import { DEFAULT_LOCALE, LOCALE_COOKIE, isLocale } from '@/i18n/config'
|
||||
import { userHasPassword } from '@/lib/auth/has-password'
|
||||
|
||||
export async function updateSession(request: NextRequest) {
|
||||
@@ -135,7 +136,7 @@ export async function updateSession(request: NextRequest) {
|
||||
|
||||
// MFA required but user has no factor enrolled yet → force enrollment
|
||||
// Skip for users with no companies (still setting up)
|
||||
const companyIdForMfa = await resolveCompanyForMiddleware(supabase, user.id, request)
|
||||
const { companyId: companyIdForMfa } = await resolveCompanyForMiddleware(supabase, user.id, request)
|
||||
if (companyIdForMfa) {
|
||||
const { data: factors } = await supabase.auth.mfa.listFactors()
|
||||
const hasVerifiedFactor = factors?.totp?.some(f => f.status === 'verified')
|
||||
@@ -152,7 +153,7 @@ export async function updateSession(request: NextRequest) {
|
||||
|
||||
// Company context resolution
|
||||
const cookieCompanyId = request.cookies.get('gnubok-company-id')?.value
|
||||
const companyId = await resolveCompanyForMiddleware(supabase, user.id, request)
|
||||
const { companyId, locale: dbLocale } = await resolveCompanyForMiddleware(supabase, user.id, request)
|
||||
|
||||
// If the cookie pointed at a company we can no longer resolve (e.g.
|
||||
// archived), clear it so the browser stops sending it.
|
||||
@@ -160,6 +161,20 @@ export async function updateSession(request: NextRequest) {
|
||||
supabaseResponse.cookies.set('gnubok-company-id', '', { path: '/', maxAge: 0 })
|
||||
}
|
||||
|
||||
// Sync the locale cookie from user_preferences. This keeps next-intl's
|
||||
// request config (which reads the cookie) consistent with the DB value
|
||||
// without forcing every RSC render to query the database itself.
|
||||
const cookieLocale = request.cookies.get(LOCALE_COOKIE)?.value
|
||||
const effectiveLocale = isLocale(dbLocale) ? dbLocale : DEFAULT_LOCALE
|
||||
if (cookieLocale !== effectiveLocale) {
|
||||
supabaseResponse.cookies.set(LOCALE_COOKIE, effectiveLocale, {
|
||||
path: '/',
|
||||
sameSite: 'lax',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge: 60 * 60 * 24 * 365,
|
||||
})
|
||||
}
|
||||
|
||||
// Routes that stay accessible when the user has no active company.
|
||||
// Needed so a user who archived their last company can still delete
|
||||
// their account without being trapped on /onboarding forever.
|
||||
@@ -230,14 +245,16 @@ async function resolveCompanyForMiddleware(
|
||||
supabase: ReturnType<typeof createServerClient>,
|
||||
userId: string,
|
||||
_request: NextRequest
|
||||
): Promise<string | null> {
|
||||
): Promise<{ companyId: string | null; locale: string | null }> {
|
||||
// 1. user_preferences (authoritative)
|
||||
const { data: prefs } = await supabase
|
||||
.from('user_preferences')
|
||||
.select('active_company_id')
|
||||
.select('active_company_id, locale')
|
||||
.eq('user_id', userId)
|
||||
.maybeSingle()
|
||||
|
||||
const locale = (prefs?.locale as string | undefined) ?? null
|
||||
|
||||
if (prefs?.active_company_id) {
|
||||
const { data: membership } = await supabase
|
||||
.from('company_members')
|
||||
@@ -247,7 +264,7 @@ async function resolveCompanyForMiddleware(
|
||||
.is('companies.archived_at', null)
|
||||
.maybeSingle()
|
||||
|
||||
if (membership) return membership.company_id
|
||||
if (membership) return { companyId: membership.company_id, locale }
|
||||
}
|
||||
|
||||
// 2. Fallback: first non-archived membership by created_at
|
||||
@@ -260,7 +277,7 @@ async function resolveCompanyForMiddleware(
|
||||
.limit(1)
|
||||
.maybeSingle()
|
||||
|
||||
if (!firstCompany) return null
|
||||
if (!firstCompany) return { companyId: null, locale }
|
||||
|
||||
// Write the fallback back to user_preferences so future RLS lookups
|
||||
// see the same active company without needing this fallback scan.
|
||||
@@ -271,5 +288,5 @@ async function resolveCompanyForMiddleware(
|
||||
{ onConflict: 'user_id' }
|
||||
)
|
||||
|
||||
return firstCompany.company_id
|
||||
return { companyId: firstCompany.company_id, locale }
|
||||
}
|
||||
|
||||
+8
-3
@@ -25,12 +25,17 @@ export function formatDate(date: Date | string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Long-form Swedish date for metadata/audit contexts (e.g. "9 maj 2026").
|
||||
* Long-form date for metadata/audit contexts (e.g. "9 maj 2026" / "May 9, 2026").
|
||||
* Use formatDate for transaction/voucher/invoice dates that need to align in tables.
|
||||
*
|
||||
* The locale arg is the UI language ('sv' | 'en'); default 'sv' keeps existing
|
||||
* server-side callers (logs, audit) Swedish without churn. For client UI use
|
||||
* the useFormat() hook which pulls the active locale from next-intl.
|
||||
*/
|
||||
export function formatDateLong(date: Date | string): string {
|
||||
export function formatDateLong(date: Date | string, locale: string = 'sv'): string {
|
||||
const d = typeof date === 'string' ? parseISO(date) : date
|
||||
return d.toLocaleDateString('sv-SE', {
|
||||
const intlLocale = locale === 'en' ? 'en-US' : 'sv-SE'
|
||||
return d.toLocaleDateString(intlLocale, {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
|
||||
Reference in New Issue
Block a user