From 3fa871c7422c3f16a4e8c31ec4a713c934fcc0b1 Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Wed, 13 May 2026 00:26:04 +0200 Subject: [PATCH] Bug/accounting suggestion (#456) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add bike benefit handling and optional vacation accrual - Introduced bike benefit (cykelförmån) with calculations for annual market value and monthly taxable value. - Updated schemas to include new benefit types and validation rules. - Implemented API routes for creating, updating, and deleting employee benefits. - Enhanced salary calculation logic to accommodate new vacation rule options, including a 'none' option for no accrual. - Added UI components for managing employee benefits, including input for bike benefit specifics. - Created database migrations for employee benefits and updated salary line items to support new benefit types. * chore: remove Langfuse env var checks Co-Authored-By: Claude Opus 4.7 (1M context) * feat: enhance OAuth callback URL handling and update default scopes for Visma integration * feat: remove trade_name field and simplify company naming in invoices * refactor: destructure canWrite from useCanWrite for consistency across components * feat: enhance PATCH endpoint to validate existing benefits and handle bike benefit updates * feat: add missing label for bike benefit in salary line item types --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .../salary/employees/[id]/page.tsx | 14 +- app/(dashboard)/salary/employees/new/page.tsx | 37 +++ app/(dashboard)/salary/employees/page.tsx | 2 +- app/(dashboard)/salary/page.tsx | 2 +- .../runs/[id]/employees/[employeeId]/page.tsx | 1 + app/(dashboard)/salary/runs/[id]/page.tsx | 4 +- app/(dashboard)/settings/company/page.tsx | 1 - app/(dashboard)/settings/salary/page.tsx | 21 +- app/api/invoices/[id]/send/route.ts | 2 +- app/api/mcp-oauth/authorize/route.ts | 4 +- .../[id]/benefits/[benefitId]/route.ts | 103 +++++++ .../salary/employees/[id]/benefits/route.ts | 97 +++++++ app/api/salary/runs/[id]/calculate/route.ts | 84 +++++- app/api/salary/tax-tables/status/route.ts | 41 +++ components/salary/EmployeeBenefitsPanel.tsx | 267 ++++++++++++++++++ components/salary/PaymentFilePanel.tsx | 50 +++- components/salary/TaxTableStatus.tsx | 86 ++++++ components/settings/CompanyInfoForm.tsx | 15 +- components/settings/PdfPrintSettings.tsx | 11 + .../transactions/JournalEntryPreview.tsx | 21 +- extensions/general/arcim-migration/index.ts | 17 +- .../general/email/lib/invoice-templates.ts | 1 - .../general/email/lib/reminder-templates.ts | 1 - .../mcp-server/resources/company-current.ts | 2 +- lib/api/schemas.ts | 47 ++- lib/email/invoice-templates.ts | 1 - lib/email/reminder-templates.ts | 1 - lib/init.ts | 11 - lib/invoices/pdf-template.tsx | 29 +- lib/invoices/reminder-processor.ts | 2 +- lib/pending-operations/commit.ts | 2 +- lib/providers/visma/oauth.ts | 9 +- .../financial-statement-pdf-template.test.ts | 3 +- .../__tests__/full-archive-export.test.ts | 1 - .../financial-statement-pdf-template.tsx | 2 +- lib/reports/full-archive-export.ts | 5 +- lib/reports/ink2/ink2-engine.ts | 4 +- lib/reports/ne-bilaga/ne-engine.ts | 4 +- .../operational-report-pdf-template.tsx | 4 +- lib/reports/sie-export.ts | 2 +- lib/salary/__tests__/benefits.test.ts | 28 +- lib/salary/absence-calculator.ts | 16 +- lib/salary/account-mapping.ts | 1 + lib/salary/agi/field-codes.ts | 1 + lib/salary/benefits.ts | 50 ++++ lib/salary/calculation-engine.ts | 26 +- lib/salary/salary-entries.ts | 2 +- lib/utils.ts | 21 +- ...00000_salary_optional_vacation_accrual.sql | 17 ++ .../20260512200100_employee_benefits.sql | 93 ++++++ ...ove_trade_name_simplify_invoice_layout.sql | 32 +++ tests/helpers.ts | 2 +- types/index.ts | 7 +- 53 files changed, 1182 insertions(+), 125 deletions(-) create mode 100644 app/api/salary/employees/[id]/benefits/[benefitId]/route.ts create mode 100644 app/api/salary/employees/[id]/benefits/route.ts create mode 100644 app/api/salary/tax-tables/status/route.ts create mode 100644 components/salary/EmployeeBenefitsPanel.tsx create mode 100644 components/salary/TaxTableStatus.tsx create mode 100644 supabase/migrations/20260512200000_salary_optional_vacation_accrual.sql create mode 100644 supabase/migrations/20260512200100_employee_benefits.sql create mode 100644 supabase/migrations/20260512200200_remove_trade_name_simplify_invoice_layout.sql diff --git a/app/(dashboard)/salary/employees/[id]/page.tsx b/app/(dashboard)/salary/employees/[id]/page.tsx index fce79eb8..bcf3c441 100644 --- a/app/(dashboard)/salary/employees/[id]/page.tsx +++ b/app/(dashboard)/salary/employees/[id]/page.tsx @@ -14,6 +14,7 @@ import { useToast } from '@/components/ui/use-toast' import { useCanWrite } from '@/lib/hooks/use-can-write' import { getErrorMessage } from '@/lib/errors/get-error-message' import type { Employee } from '@/types' +import { EmployeeBenefitsPanel } from '@/components/salary/EmployeeBenefitsPanel' const EMPLOYMENT_LABELS: Record = { employee: 'Anställd', @@ -36,7 +37,7 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s const { id } = use(params) const router = useRouter() const { toast } = useToast() - const canWrite = useCanWrite() + const { canWrite } = useCanWrite() const [employee, setEmployee] = useState(null) const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) @@ -375,10 +376,16 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s - Procentregeln (12%) + Procentregeln (12 %) Sammalöneregeln + Ingen semesteravsättning + {vacationRule === 'none' && ( +

+ Ingen avsättning till 2920 bokas. Använd om semester ingår i månadslönen — vanligt för ägare som är enda anställd. +

+ )}
@@ -417,6 +424,9 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s + {/* Benefits */} + + {canWrite && (
+ )} + + + {loading ? ( +

Laddar…

+ ) : benefits.length === 0 && !adding ? ( +

+ Inga förmåner registrerade. Aktiva förmåner läggs till automatiskt som rader vid lönekörning. +

+ ) : ( + benefits.length > 0 && ( + + + + Typ + Beskrivning + Värde/mån + Period + + + + + {benefits.map(b => ( + + {BENEFIT_LABELS[b.benefit_type]} + {b.description} + {formatCurrency(b.monthly_value)} + + {formatDate(b.valid_from)} + {b.valid_to ? ` – ${formatDate(b.valid_to)}` : ' – löpande'} + + + {canWrite && ( + + )} + + + ))} + +
+ ) + )} + + {adding && ( +
+
+
+ + +
+
+ + setDescription(e.target.value)} + placeholder={BENEFIT_LABELS[type]} + /> +
+
+ + {type === 'bike' ? ( +
+ + setAnnualMarketValue(e.target.value)} + placeholder="t.ex. 8 400 (700 kr/mån)" + className="max-w-xs" + /> +

+ Skatteverkets schablon: första 3 000 kr/år är skattefri. Resterande beskattas månadsvis. + {parseFloat(annualMarketValue) > 0 && ( + + Månatligt förmånsvärde: {formatCurrency(previewMonthlyBike)} + + )} +

+
+ ) : ( +
+ + setMonthlyValue(e.target.value)} + className="max-w-xs" + /> +
+ )} + +
+
+ + setValidFrom(e.target.value)} /> +
+
+ + setValidTo(e.target.value)} /> +
+
+ +
+ + +
+
+ )} +
+ + ) +} diff --git a/components/salary/PaymentFilePanel.tsx b/components/salary/PaymentFilePanel.tsx index 1d5fdbde..ee45ea56 100644 --- a/components/salary/PaymentFilePanel.tsx +++ b/components/salary/PaymentFilePanel.tsx @@ -4,7 +4,7 @@ import { useState } from 'react' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' -import { Download, Loader2, CheckCircle2 } from 'lucide-react' +import { Download, Loader2, CheckCircle2, ChevronDown, Info } from 'lucide-react' import { useToast } from '@/components/ui/use-toast' import { getErrorMessage } from '@/lib/errors/get-error-message' @@ -30,6 +30,20 @@ const FORMAT_DESCRIPTION: Record = { pain001: 'ISO 20022. För banker som inte är anslutna till Bankgirot, eller internationell SEPA.', } +const BANK_INSTRUCTIONS: Record> = { + bg_lb: [ + { bank: 'Swedbank Företag', steps: 'Företagsbetalningar → Importera fil → välj LB-format → ladda upp och signera med BankID.' }, + { bank: 'SEB Företag', steps: 'Betalningar → Filöverföring → välj Bankgiro LB → ladda upp och attestera.' }, + { bank: 'Handelsbanken', steps: 'Betala → Filimport → välj LB → kontrollera summor → signera.' }, + { bank: 'Nordea Företag', steps: 'Filimport → Bankgiro LB → ladda upp → attestera betalningen.' }, + ], + pain001: [ + { bank: 'SEB', steps: 'Betalningar → Importera SEPA / ISO 20022 (pain.001) → ladda upp och signera.' }, + { bank: 'Handelsbanken', steps: 'Betala → Filimport → ISO 20022 → välj pain.001.' }, + { bank: 'Nordea', steps: 'Filimport → Format pain.001.001.03 → ladda upp → attestera.' }, + ], +} + export function PaymentFilePanel({ salaryRunId, periodLabel, @@ -42,6 +56,7 @@ export function PaymentFilePanel({ const { toast } = useToast() const [format, setFormat] = useState(defaultFormat) const [downloading, setDownloading] = useState(false) + const [showInstructions, setShowInstructions] = useState(false) const endpoint = format === 'bg_lb' @@ -123,6 +138,39 @@ export function PaymentFilePanel({ Ladda ner betalfil
+ + + + {showInstructions && ( +
+ {BANK_INSTRUCTIONS[format].map(b => ( +
+ {b.bank}.{' '} + {b.steps} +
+ ))} +

+ Filen innehåller både lön till anställda och beloppen är förberedda — bankens BankID-signering är det enda steget kvar. +

+
+ )} + +
+ + + Open Payments / direktbetalning via PSD2 (utan filimport) + är planerat för framtiden via Enable Banking. Initiering kräver separat PIS-avtal — vi följer upp när + tillräckligt många kunder använder lönebetalfiler regelbundet. + +
)} diff --git a/components/salary/TaxTableStatus.tsx b/components/salary/TaxTableStatus.tsx new file mode 100644 index 00000000..6da82f06 --- /dev/null +++ b/components/salary/TaxTableStatus.tsx @@ -0,0 +1,86 @@ +'use client' + +import { useEffect, useState } from 'react' +import { Globe, CheckCircle2, AlertTriangle, RefreshCcw, Loader2 } from 'lucide-react' +import { Button } from '@/components/ui/button' + +type Source = 'api' | 'fallback' | 'unavailable' + +interface Status { + year: number + source: Source + reachable: boolean + checkedAt: string +} + +interface Props { + year?: number + compact?: boolean +} + +export function TaxTableStatus({ year, compact = false }: Props) { + const [status, setStatus] = useState(null) + const [loading, setLoading] = useState(true) + + async function check() { + setLoading(true) + const params = new URLSearchParams() + if (year) params.set('year', String(year)) + const res = await fetch(`/api/salary/tax-tables/status?${params}`) + if (res.ok) { + const { data } = await res.json() + setStatus(data) + } + setLoading(false) + } + + useEffect(() => { + check() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [year]) + + if (loading && !status) { + return ( +
+ + Kontrollerar skattetabeller… +
+ ) + } + + if (!status) return null + + const Icon = status.source === 'api' ? CheckCircle2 : AlertTriangle + const iconColor = + status.source === 'api' ? 'text-success' : + status.source === 'fallback' ? 'text-warning' : + 'text-destructive' + + const label = + status.source === 'api' + ? `Skattetabeller för ${status.year} hämtas live från Skatteverket` + : status.source === 'fallback' + ? `Skatteverkets API är inte nåbart — använder lokal reservdata för ${status.year}` + : `Skattetabeller för ${status.year} kunde inte hämtas` + + if (compact) { + return ( +

+ + {label} +

+ ) + } + + return ( +
+
+ + {label} +
+ +
+ ) +} diff --git a/components/settings/CompanyInfoForm.tsx b/components/settings/CompanyInfoForm.tsx index e5b62a04..0be392d3 100644 --- a/components/settings/CompanyInfoForm.tsx +++ b/components/settings/CompanyInfoForm.tsx @@ -22,22 +22,9 @@ export function CompanyInfoForm({ settings }: CompanyInfoFormProps) { id="company_name" name="company_name" defaultValue={settings.company_name || ''} - disabled={settings.onboarding_complete === true} - /> - {settings.onboarding_complete && ( -

Kan inte ändras efter att kontot skapats

- )} -
-
- -

- Valfritt. Visas som huvudnamn på fakturor och e-post, med det juridiska namnet i parentes. + Visas på fakturor, e-post och deklarationsfiler. För enskild firma är det vanligtvis ditt eget namn (Förnamn Efternamn).

diff --git a/components/settings/PdfPrintSettings.tsx b/components/settings/PdfPrintSettings.tsx index 2166364c..5569f7e8 100644 --- a/components/settings/PdfPrintSettings.tsx +++ b/components/settings/PdfPrintSettings.tsx @@ -95,6 +95,17 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps) onCheckedChange={(v) => saveToggle('invoice_show_plusgiro', v)} />
+ +
+
+ +

Visa uppladdad logga i fakturahuvudet

+
+ saveToggle('invoice_show_logo', v)} + /> +
diff --git a/components/transactions/JournalEntryPreview.tsx b/components/transactions/JournalEntryPreview.tsx index 293f4480..99cd8e53 100644 --- a/components/transactions/JournalEntryPreview.tsx +++ b/components/transactions/JournalEntryPreview.tsx @@ -90,12 +90,25 @@ export default function JournalEntryPreview({ const vatRate = templateVatRate ?? 0 const vatAmt = extractVatAmount(absAmount, vatRate) const netAmt = extractNetAmount(absAmount, vatRate) + const isIncome = amount > 0 - result.push({ side: 'debet', account: templateDebitAccount, amount: netAmt }) - if (vatAmt > 0) { - result.push({ side: 'debet', account: '2641', amount: vatAmt }) + if (isIncome) { + // Income: debit bank gross, credit revenue net, credit output VAT + result.push({ side: 'debet', account: templateDebitAccount, amount: absAmount }) + result.push({ side: 'kredit', account: templateCreditAccount, amount: netAmt }) + if (vatAmt > 0) { + // Map rate → output VAT account (BAS 2611/2621/2631) + const outputVatAccount = vatRate === 0.06 ? '2631' : vatRate === 0.12 ? '2621' : '2611' + result.push({ side: 'kredit', account: outputVatAccount, amount: vatAmt }) + } + } else { + // Expense: debit expense net + input VAT, credit bank gross + result.push({ side: 'debet', account: templateDebitAccount, amount: netAmt }) + if (vatAmt > 0) { + result.push({ side: 'debet', account: '2641', amount: vatAmt }) + } + result.push({ side: 'kredit', account: templateCreditAccount, amount: absAmount }) } - result.push({ side: 'kredit', account: templateCreditAccount, amount: absAmount }) return result } diff --git a/extensions/general/arcim-migration/index.ts b/extensions/general/arcim-migration/index.ts index f26f4a32..bec3079b 100644 --- a/extensions/general/arcim-migration/index.ts +++ b/extensions/general/arcim-migration/index.ts @@ -248,9 +248,22 @@ export const arcimMigrationExtension: Extension = { // Generate OTC for OAuth flow const otc = await generateOtc(consent.id) - // Build the OAuth callback URL + // Build the OAuth callback URL. Prefer a provider-specific override + // (e.g. VISMA_REDIRECT_URI) when set — this lets dev environments + // route through a single registered URI (production) rather than + // requiring every ngrok URL to be registered on the OAuth client. + // Falls back to NEXT_PUBLIC_APP_URL + the canonical callback path. const appUrl = process.env.NEXT_PUBLIC_APP_URL || '' - const callbackUrl = `${appUrl}/api/extensions/ext/arcim-migration/callback` + const providerRedirectEnv = + provider === 'visma' + ? process.env.VISMA_REDIRECT_URI + : provider === 'fortnox' + ? process.env.FORTNOX_REDIRECT_URI + : undefined + const callbackUrl = + providerRedirectEnv && providerRedirectEnv.trim().length > 0 + ? providerRedirectEnv + : `${appUrl}/api/extensions/ext/arcim-migration/callback` // Encode consentId + provider in state const statePayload = JSON.stringify({ otc: otc.code, consentId: consent.id, provider }) diff --git a/extensions/general/email/lib/invoice-templates.ts b/extensions/general/email/lib/invoice-templates.ts index 6915f2cb..45cc0280 100644 --- a/extensions/general/email/lib/invoice-templates.ts +++ b/extensions/general/email/lib/invoice-templates.ts @@ -137,7 +137,6 @@ export function generateInvoiceEmailHtml(data: InvoiceEmailData): string {

Med vänliga hälsningar,
${getCompanyPrimaryName(company)} - ${company.trade_name && company.company_name ? `
(${company.company_name})` : ''}

${company.org_number ? `

diff --git a/extensions/general/email/lib/reminder-templates.ts b/extensions/general/email/lib/reminder-templates.ts index 5d5cc6e3..392a386b 100644 --- a/extensions/general/email/lib/reminder-templates.ts +++ b/extensions/general/email/lib/reminder-templates.ts @@ -180,7 +180,6 @@ export function generateReminderEmailHtml(data: ReminderEmailData): string {

Med vänliga hälsningar,
${getCompanyPrimaryName(company)} - ${company.trade_name && company.company_name ? `
(${company.company_name})` : ''}

${company.org_number ? `

diff --git a/extensions/general/mcp-server/resources/company-current.ts b/extensions/general/mcp-server/resources/company-current.ts index 50c6a0e8..d186d436 100644 --- a/extensions/general/mcp-server/resources/company-current.ts +++ b/extensions/general/mcp-server/resources/company-current.ts @@ -19,7 +19,7 @@ export const companyCurrentResource: McpResource = { const { data: settings } = await supabase .from('company_settings') .select(` - company_name, trade_name, address_line1, address_line2, postal_code, city, country, + company_name, address_line1, address_line2, postal_code, city, country, phone, email, website, pays_salaries, f_skatt, vat_registered, vat_number, moms_period, fiscal_year_start_month, diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index 1f7898d1..a5106e29 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -359,7 +359,6 @@ export const MatchSupplierInvoiceSchema = z.object({ export const UpdateSettingsSchema = z.object({ entity_type: EntityTypeSchema.optional(), company_name: z.string().optional(), - trade_name: z.string().nullable().optional(), org_number: z.string().optional(), address_line1: z.string().optional(), address_line2: z.string().optional(), @@ -399,6 +398,7 @@ export const UpdateSettingsSchema = z.object({ invoice_show_ocr: z.boolean().optional(), invoice_show_bankgiro: z.boolean().optional(), invoice_show_plusgiro: z.boolean().optional(), + invoice_show_logo: z.boolean().optional(), invoice_late_fee_text: z.string().nullable().optional(), invoice_credit_terms_text: z.string().nullable().optional(), // AI agent flow @@ -677,13 +677,13 @@ export const SupplierImportExecuteSchema = z.object({ export const EmploymentTypeSchema = z.enum(['employee', 'company_owner', 'board_member']) export const SalaryTypeSchema = z.enum(['monthly', 'hourly']) export const FSkattStatusSchema = z.enum(['a_skatt', 'f_skatt', 'fa_skatt', 'not_verified']) -export const VacationRuleSchema = z.enum(['procentregeln', 'sammaloneregeln']) +export const VacationRuleSchema = z.enum(['procentregeln', 'sammaloneregeln', 'none']) export const SalaryRunStatusSchema = z.enum(['draft', 'review', 'approved', 'paid', 'booked', 'corrected']) export const SalaryLineItemTypeSchema = z.enum([ 'monthly_salary', 'hourly_salary', 'overtime', 'bonus', 'commission', 'gross_deduction_pension', 'gross_deduction_other', - 'benefit_car', 'benefit_housing', 'benefit_meals', 'benefit_wellness', 'benefit_other', + 'benefit_car', 'benefit_housing', 'benefit_meals', 'benefit_wellness', 'benefit_bike', 'benefit_other', 'sick_karens', 'sick_day2_14', 'sick_day15_plus', 'vab', 'parental_leave', 'vacation', 'traktamente_taxfree', 'traktamente_taxable', @@ -795,6 +795,47 @@ export const UpdateEmployeeSchema = EmployeeSchemaBase.partial().superRefine((da } }) +export const EmployeeBenefitTypeSchema = z.enum(['bike', 'car', 'meals', 'housing', 'wellness', 'other']) + +export const CreateEmployeeBenefitSchema = z.object({ + benefit_type: EmployeeBenefitTypeSchema, + description: z.string().min(1).max(200), + monthly_value: z.number().nonnegative().optional(), + /** For bike benefit: annual market value of the förmån. The server computes + * monthly_value = max(0, annual − 3000) / 12 per Skatteverket schablon. */ + annual_market_value: z.number().nonnegative().optional(), + valid_from: isoDate, + valid_to: isoDate.optional(), + metadata: z.record(z.string(), z.unknown()).optional(), + is_active: z.boolean().optional(), +}).superRefine((data, ctx) => { + if (data.benefit_type === 'bike') { + if (data.annual_market_value === undefined && data.monthly_value === undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Cykelförmån kräver årligt marknadsvärde', + path: ['annual_market_value'], + }) + } + } else if (data.monthly_value === undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Månatligt förmånsvärde krävs', + path: ['monthly_value'], + }) + } +}) + +export const UpdateEmployeeBenefitSchema = z.object({ + description: z.string().min(1).max(200).optional(), + monthly_value: z.number().nonnegative().optional(), + annual_market_value: z.number().nonnegative().optional(), + valid_from: isoDate.optional(), + valid_to: isoDate.nullable().optional(), + metadata: z.record(z.string(), z.unknown()).optional(), + is_active: z.boolean().optional(), +}) + export const CreateSalaryRunSchema = z.object({ period_year: z.number().int().min(2020).max(2100), period_month: z.number().int().min(1).max(12), diff --git a/lib/email/invoice-templates.ts b/lib/email/invoice-templates.ts index 6915f2cb..45cc0280 100644 --- a/lib/email/invoice-templates.ts +++ b/lib/email/invoice-templates.ts @@ -137,7 +137,6 @@ export function generateInvoiceEmailHtml(data: InvoiceEmailData): string {

Med vänliga hälsningar,
${getCompanyPrimaryName(company)} - ${company.trade_name && company.company_name ? `
(${company.company_name})` : ''}

${company.org_number ? `

diff --git a/lib/email/reminder-templates.ts b/lib/email/reminder-templates.ts index 5d5cc6e3..392a386b 100644 --- a/lib/email/reminder-templates.ts +++ b/lib/email/reminder-templates.ts @@ -180,7 +180,6 @@ export function generateReminderEmailHtml(data: ReminderEmailData): string {

Med vänliga hälsningar,
${getCompanyPrimaryName(company)} - ${company.trade_name && company.company_name ? `
(${company.company_name})` : ''}

${company.org_number ? `

diff --git a/lib/init.ts b/lib/init.ts index 7e52f57d..05ecffc2 100644 --- a/lib/init.ts +++ b/lib/init.ts @@ -24,11 +24,6 @@ const REQUIRED_EXTENSION_VARS = [ 'OPENAI_API_KEY', ] as const -const OPTIONAL_VARS = [ - 'LANGFUSE_SECRET_KEY', - 'LANGFUSE_PUBLIC_KEY', -] as const - function validateEnvironment(): void { // During builds (CI, Docker, Vercel), env vars may be absent or set to // placeholder sentinels. Skip validation so Next.js page collection @@ -54,12 +49,6 @@ function validateEnvironment(): void { if (missingExt.length > 0) { log.warn(`Missing extension environment variables (extensions needing them may not work): ${missingExt.join(', ')}`) } - - for (const v of OPTIONAL_VARS) { - if (!process.env[v]) { - log.warn(`Optional environment variable ${v} is not set`) - } - } } /** diff --git a/lib/invoices/pdf-template.tsx b/lib/invoices/pdf-template.tsx index 907ff008..5e228859 100644 --- a/lib/invoices/pdf-template.tsx +++ b/lib/invoices/pdf-template.tsx @@ -374,21 +374,10 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN {/* Header */} - {company.logo_url && ( + {company.logo_url && (company.invoice_show_logo ?? true) && ( )} - {company.trade_name || company.company_name} - {company.trade_name && company.company_name && ( - ({company.company_name}) - )} - {company.address_line1 && {company.address_line1}} - {(company.postal_code || company.city) && ( - {company.postal_code} {company.city} - )} - {company.org_number && ( - Org.nr: {formatOrgNumber(company.org_number)} - )} - {company.vat_number && VAT: {company.vat_number}} + {company.company_name} @@ -672,14 +661,16 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN )} - {/* Footer */} + {/* Footer — collected legal info per ML 17 kap 24§ */} - {company.trade_name || company.company_name} - {company.trade_name && company.company_name ? ` (${company.company_name})` : ''} - {company.org_number ? ` | Org.nr: ${formatOrgNumber(company.org_number)}` : ''} - {company.f_skatt ? ' | Godkänd för F-skatt' : ''} - {company.vat_number ? ` | Momsreg.nr: ${company.vat_number}` : ''} + {[ + company.address_line1, + (company.postal_code || company.city) ? `${company.postal_code ?? ''} ${company.city ?? ''}`.trim() : null, + company.org_number ? `Org.nr: ${formatOrgNumber(company.org_number)}` : null, + company.vat_number ? `Momsreg.nr: ${company.vat_number}` : null, + company.f_skatt ? 'Godkänd för F-skatt' : null, + ].filter(Boolean).join(' · ')} diff --git a/lib/invoices/reminder-processor.ts b/lib/invoices/reminder-processor.ts index d51b08aa..b09c386c 100644 --- a/lib/invoices/reminder-processor.ts +++ b/lib/invoices/reminder-processor.ts @@ -116,7 +116,7 @@ export async function sendReminder( html: generateReminderEmailHtml(emailData), text: generateReminderEmailText(emailData), replyTo: company.email || undefined, - fromName: company.trade_name || company.company_name || undefined + fromName: company.company_name || undefined }) return result diff --git a/lib/pending-operations/commit.ts b/lib/pending-operations/commit.ts index 81e24386..888769da 100644 --- a/lib/pending-operations/commit.ts +++ b/lib/pending-operations/commit.ts @@ -616,7 +616,7 @@ async function commitSendInvoice( html: generateInvoiceEmailHtml(emailData), text: generateInvoiceEmailText(emailData), replyTo: company.email || undefined, - fromName: company.trade_name || company.company_name, + fromName: company.company_name, attachments: [{ filename, content: pdfBuffer, contentType: 'application/pdf' }], }) diff --git a/lib/providers/visma/oauth.ts b/lib/providers/visma/oauth.ts index 34b8d260..8dc9f253 100644 --- a/lib/providers/visma/oauth.ts +++ b/lib/providers/visma/oauth.ts @@ -7,11 +7,12 @@ import { } from '@/lib/http/fetch-with-timeout'; const DEFAULT_SCOPES = [ - 'ea:api', 'offline_access', - 'ea:sales_readonly', - 'ea:accounting_readonly', - 'ea:purchase_readonly', + 'ea:api', + 'ea:sales', + 'ea:accounting', + 'ea:purchase', + 'vls:api', ]; const EACCOUNTING_ACR_VALUE = 'service:44643EB1-3F76-4C1C-A672-402AE8085934'; diff --git a/lib/reports/__tests__/financial-statement-pdf-template.test.ts b/lib/reports/__tests__/financial-statement-pdf-template.test.ts index e15c176b..0176352c 100644 --- a/lib/reports/__tests__/financial-statement-pdf-template.test.ts +++ b/lib/reports/__tests__/financial-statement-pdf-template.test.ts @@ -5,8 +5,7 @@ import type { CompanySettings } from '@/types' function fakeCompany(): CompanySettings { return { - company_name: 'Gnubok AB', - trade_name: 'Gnubok', + company_name: 'Gnubok', org_number: '5566778899', vat_number: 'SE556677889901', address_line1: 'Kungsgatan 1', diff --git a/lib/reports/__tests__/full-archive-export.test.ts b/lib/reports/__tests__/full-archive-export.test.ts index a26c03d7..512113f1 100644 --- a/lib/reports/__tests__/full-archive-export.test.ts +++ b/lib/reports/__tests__/full-archive-export.test.ts @@ -67,7 +67,6 @@ const mockGetAuditLog = vi.mocked(getAuditLog) const COMPANY_ROW = { company_name: 'Test AB', - trade_name: null, org_number: '5566778899', moms_period: 'quarterly', } diff --git a/lib/reports/financial-statement-pdf-template.tsx b/lib/reports/financial-statement-pdf-template.tsx index a1ac08ba..ab918f28 100644 --- a/lib/reports/financial-statement-pdf-template.tsx +++ b/lib/reports/financial-statement-pdf-template.tsx @@ -265,7 +265,7 @@ export function FinancialStatementPDF({ company, generatedAt, }: FinancialStatementPDFProps) { - const companyDisplayName = company.trade_name || company.company_name || '' + const companyDisplayName = company.company_name || '' const periodLabel = period.start && period.end ? `${formatDateSv(period.start)} – ${formatDateSv(period.end)}` : '' diff --git a/lib/reports/full-archive-export.ts b/lib/reports/full-archive-export.ts index a1171a37..b7fad124 100644 --- a/lib/reports/full-archive-export.ts +++ b/lib/reports/full-archive-export.ts @@ -43,7 +43,6 @@ interface FiscalPeriodRow { interface CompanyInfo { company_name: string | null - trade_name: string | null org_number: string | null moms_period: string | null } @@ -116,7 +115,6 @@ export async function generateFullArchive( const sie = await generateSIEExport(supabase, companyId, { fiscal_period_id: period.id, company_name: company.company_name || 'Unknown', - trade_name: company.trade_name, org_number: company.org_number, }) sieFolder.file(`${periodLabel(period)}.se`, sie) @@ -132,7 +130,6 @@ export async function generateFullArchive( const sie = await generateSIEExport(supabase, companyId, { fiscal_period_id: period.id, company_name: company.company_name || 'Unknown', - trade_name: company.trade_name, org_number: company.org_number, }) zip.file('bokforing.se', sie) @@ -227,7 +224,7 @@ export async function estimateArchiveSize( async function fetchCompany(supabase: SupabaseClient, companyId: string): Promise { const { data } = await supabase .from('company_settings') - .select('company_name, trade_name, org_number, moms_period') + .select('company_name, org_number, moms_period') .eq('company_id', companyId) .single() diff --git a/lib/reports/ink2/ink2-engine.ts b/lib/reports/ink2/ink2-engine.ts index c4221cee..e9f95ea7 100644 --- a/lib/reports/ink2/ink2-engine.ts +++ b/lib/reports/ink2/ink2-engine.ts @@ -699,7 +699,7 @@ export async function generateINK2Declaration( // Fetch company settings const { data: settings } = await supabase .from('company_settings') - .select('company_name, trade_name, org_number, entity_type, address_line1, postal_code, city, email') + .select('company_name, org_number, entity_type, address_line1, postal_code, city, email') .eq('company_id', companyId) .single() @@ -934,7 +934,7 @@ export async function generateINK2Declaration( resultAfterFinancial, }, companyInfo: { - companyName: settings?.trade_name || settings?.company_name || 'Okänt företag', + companyName: settings?.company_name || 'Okänt företag', orgNumber: settings?.org_number || null, addressLine1: settings?.address_line1 || null, postalCode: settings?.postal_code || null, diff --git a/lib/reports/ne-bilaga/ne-engine.ts b/lib/reports/ne-bilaga/ne-engine.ts index bcbc399e..404f09fe 100644 --- a/lib/reports/ne-bilaga/ne-engine.ts +++ b/lib/reports/ne-bilaga/ne-engine.ts @@ -175,7 +175,7 @@ export async function generateNEDeclaration( // Fetch company settings const { data: settings } = await supabase .from('company_settings') - .select('company_name, trade_name, org_number, entity_type') + .select('company_name, org_number, entity_type') .eq('company_id', companyId) .single() @@ -331,7 +331,7 @@ export async function generateNEDeclaration( rutor, breakdown, companyInfo: { - companyName: settings?.trade_name || settings?.company_name || 'Okänt företag', + companyName: settings?.company_name || 'Okänt företag', orgNumber: settings?.org_number || null, }, warnings, diff --git a/lib/reports/operational-report-pdf-template.tsx b/lib/reports/operational-report-pdf-template.tsx index 9ee6225e..44678bb3 100644 --- a/lib/reports/operational-report-pdf-template.tsx +++ b/lib/reports/operational-report-pdf-template.tsx @@ -255,7 +255,7 @@ interface CommonHeaderProps { } function HeaderBlock({ title, company, period }: CommonHeaderProps) { - const companyDisplayName = company.trade_name || company.company_name || '' + const companyDisplayName = company.company_name || '' const periodLabel = period.start && period.end ? `${formatDateSv(period.start)} – ${formatDateSv(period.end)}` : '' @@ -288,7 +288,7 @@ function HeaderBlock({ title, company, period }: CommonHeaderProps) { } function FooterBlock({ company, generatedAt }: { company: CompanySettings; generatedAt: string }) { - const companyDisplayName = company.trade_name || company.company_name || '' + const companyDisplayName = company.company_name || '' return ( diff --git a/lib/reports/sie-export.ts b/lib/reports/sie-export.ts index 0c6ab025..d9249395 100644 --- a/lib/reports/sie-export.ts +++ b/lib/reports/sie-export.ts @@ -94,7 +94,7 @@ export async function generateSIEExport( lines.push(`#ORGNR ${options.org_number}`) } - lines.push(`#FNAMN "${escapeQuotes(options.trade_name || options.company_name)}"`) + lines.push(`#FNAMN "${escapeQuotes(options.company_name)}"`) // === Fiscal year === // #RAR 0 = current year, #RAR -1 = previous year (both should be present per spec) diff --git a/lib/salary/__tests__/benefits.test.ts b/lib/salary/__tests__/benefits.test.ts index 1c8e96c4..9993d8c1 100644 --- a/lib/salary/__tests__/benefits.test.ts +++ b/lib/salary/__tests__/benefits.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { calculateCarBenefit, getMealBenefitValue, calculateWellnessBenefit } from '../benefits' +import { calculateCarBenefit, getMealBenefitValue, calculateWellnessBenefit, calculateBikeBenefit, BIKE_BENEFIT_TAX_FREE_ALLOWANCE } from '../benefits' import type { PayrollConfig } from '../payroll-config' const config: PayrollConfig = { @@ -151,3 +151,29 @@ describe('calculateWellnessBenefit', () => { expect(result.taxableAmount).toBe(6000) }) }) + +describe('calculateBikeBenefit', () => { + it('is tax-free entirely when annual value is below the 3 000 kr schablon', () => { + const result = calculateBikeBenefit(2500) + expect(result.annualTaxable).toBe(0) + expect(result.monthlyValue).toBe(0) + expect(result.taxFreePortion).toBe(2500) + }) + + it('taxes only the excess over 3 000 kr', () => { + const result = calculateBikeBenefit(8400) + // 8400 - 3000 = 5400 taxable annually → 450/month + expect(result.annualTaxable).toBe(5400) + expect(result.monthlyValue).toBe(450) + expect(result.taxFreePortion).toBe(3000) + }) + + it('handles 0 or negative input gracefully', () => { + expect(calculateBikeBenefit(0).monthlyValue).toBe(0) + expect(calculateBikeBenefit(-100).monthlyValue).toBe(0) + }) + + it('exposes the schablon constant', () => { + expect(BIKE_BENEFIT_TAX_FREE_ALLOWANCE).toBe(3000) + }) +}) diff --git a/lib/salary/absence-calculator.ts b/lib/salary/absence-calculator.ts index 52f842ec..69cb8a76 100644 --- a/lib/salary/absence-calculator.ts +++ b/lib/salary/absence-calculator.ts @@ -173,13 +173,27 @@ export function calculateParentalLeaveDeduction( export function calculateVacationPay(params: { monthlySalary: number vacationDaysTaken: number - vacationRule: 'procentregeln' | 'sammaloneregeln' + vacationRule: 'procentregeln' | 'sammaloneregeln' | 'none' semestertillaggRate: number vacationDaysPerYear: number }): { amount: number; tillagg: number; steps: AbsenceStep[] } { const r = (x: number) => Math.round(x * 100) / 100 const dailyRate = r(params.monthlySalary / 21) + if (params.vacationRule === 'none') { + // No accrual — vacation is included in monthly pay. No tillägg paid. + return { + amount: 0, + tillagg: 0, + steps: [{ + label: 'Semesterlön (avstängd)', + formula: 'ingen separat semesterlön — ingår i månadslönen', + input: { days: params.vacationDaysTaken }, + output: 0, + }], + } + } + if (params.vacationRule === 'sammaloneregeln') { // Sammalöneregeln: regular pay + semestertillägg per day const tillagg = r(params.monthlySalary * params.semestertillaggRate * params.vacationDaysTaken) diff --git a/lib/salary/account-mapping.ts b/lib/salary/account-mapping.ts index 038261c4..549727b8 100644 --- a/lib/salary/account-mapping.ts +++ b/lib/salary/account-mapping.ts @@ -21,6 +21,7 @@ const LINE_ITEM_ACCOUNTS: Record = { benefit_housing: '7381', benefit_meals: '7382', benefit_wellness: '7699', + benefit_bike: '7388', benefit_other: '7389', // Absence sick_karens: '7281', diff --git a/lib/salary/agi/field-codes.ts b/lib/salary/agi/field-codes.ts index debbaf43..5742a2dd 100644 --- a/lib/salary/agi/field-codes.ts +++ b/lib/salary/agi/field-codes.ts @@ -80,6 +80,7 @@ export const BENEFIT_RUTA_MAP: Record = { benefit_housing: INDIVID_FIELDS.RUTA_014, benefit_meals: INDIVID_FIELDS.RUTA_015, benefit_wellness: INDIVID_FIELDS.RUTA_019, + benefit_bike: INDIVID_FIELDS.RUTA_019, benefit_other: INDIVID_FIELDS.RUTA_019, } diff --git a/lib/salary/benefits.ts b/lib/salary/benefits.ts index c110aa5e..48a3dffc 100644 --- a/lib/salary/benefits.ts +++ b/lib/salary/benefits.ts @@ -120,6 +120,56 @@ export function getMealBenefitValue(mealType: MealType, config: PayrollConfig): } } +// ============================================================ +// Bike Benefit (Cykelförmån) +// ============================================================ + +/** + * Annual tax-free allowance for cykelförmån, in SEK. + * Per Skatteverket schablon effective from 2022: the first 3 000 kr/year of + * a bike benefit is tax-free; the excess is taxable. The employer must offer + * the benefit on equal terms to all employees. + */ +export const BIKE_BENEFIT_TAX_FREE_ALLOWANCE = 3000 + +/** + * Calculate monthly taxable cykelförmån from an annual market value. + * + * taxable_annual = max(0, annual_market_value - 3000) + * taxable_monthly = taxable_annual / 12 + * + * `annualMarketValue` is the yearly värde av förmånen — typically the + * marknadsmässiga hyran or the subscription cost the employer pays. + */ +export function calculateBikeBenefit(annualMarketValue: number): { + monthlyValue: number + annualTaxable: number + taxFreePortion: number + steps: BenefitStep[] +} { + const r = (x: number) => Math.round(x * 100) / 100 + const annual = Math.max(0, annualMarketValue) + const taxFreePortion = Math.min(annual, BIKE_BENEFIT_TAX_FREE_ALLOWANCE) + const annualTaxable = r(annual - taxFreePortion) + const monthlyValue = r(annualTaxable / 12) + + return { + monthlyValue, + annualTaxable, + taxFreePortion, + steps: [{ + label: 'Cykelförmån (schablon)', + formula: 'max(0, årligt marknadsvärde − 3 000 kr skattefritt) / 12', + input: { + annual_market_value: annual, + tax_free_allowance: BIKE_BENEFIT_TAX_FREE_ALLOWANCE, + annual_taxable: annualTaxable, + }, + output: monthlyValue, + }], + } +} + // ============================================================ // Wellness Benefit (Friskvårdsbidrag) // ============================================================ diff --git a/lib/salary/calculation-engine.ts b/lib/salary/calculation-engine.ts index 47a3d329..bfe4bb8e 100644 --- a/lib/salary/calculation-engine.ts +++ b/lib/salary/calculation-engine.ts @@ -31,7 +31,7 @@ export interface SalaryCalculationInput { paymentDate: string /** Vacation */ - vacationRule: 'procentregeln' | 'sammaloneregeln' + vacationRule: 'procentregeln' | 'sammaloneregeln' | 'none' vacationDaysPerYear: number semestertillaggRate: number @@ -217,7 +217,7 @@ export function calculateSalary( // ─── Step 5: Add förmånsvärden to tax base ─── const benefitItems = input.lineItems.filter( - li => ['benefit_car', 'benefit_housing', 'benefit_meals', 'benefit_wellness', 'benefit_other'].includes(li.itemType) + li => ['benefit_car', 'benefit_housing', 'benefit_meals', 'benefit_wellness', 'benefit_bike', 'benefit_other'].includes(li.itemType) ) const totalBenefits = r(benefitItems.reduce((sum, li) => sum + li.amount, 0)) if (totalBenefits > 0) { @@ -355,7 +355,15 @@ export function calculateSalary( baseSalary + vacationBasisItems.reduce((sum, li) => sum + li.amount, 0) ) let vacationAccrual: number - if (input.vacationRule === 'procentregeln') { + if (input.vacationRule === 'none') { + vacationAccrual = 0 + steps.push({ + label: 'Semesteravsättning (avstängd)', + formula: 'ingen semesteravsättning bokas — semester ingår i månadslönen', + input: {}, + output: 0, + }) + } else if (input.vacationRule === 'procentregeln') { const rate = input.vacationDaysPerYear >= 30 ? 0.144 : 0.12 vacationAccrual = r(vacationBasis * rate) steps.push({ @@ -585,13 +593,23 @@ export function calculateSjuklon( */ export function calculateVacationAccrual(params: { monthlySalary: number - vacationRule: 'procentregeln' | 'sammaloneregeln' + vacationRule: 'procentregeln' | 'sammaloneregeln' | 'none' vacationDaysPerYear: number semestertillaggRate: number vacationBasis: number }): { accrual: number; steps: CalculationStep[] } { const steps: CalculationStep[] = [] + if (params.vacationRule === 'none') { + steps.push({ + label: 'Semesteravsättning (avstängd)', + formula: 'ingen semesteravsättning', + input: {}, + output: 0, + }) + return { accrual: 0, steps } + } + if (params.vacationRule === 'procentregeln') { const rate = params.vacationDaysPerYear >= 30 ? 0.144 : 0.12 const accrual = r(params.vacationBasis * rate) diff --git a/lib/salary/salary-entries.ts b/lib/salary/salary-entries.ts index 91f1126d..e2058cb6 100644 --- a/lib/salary/salary-entries.ts +++ b/lib/salary/salary-entries.ts @@ -142,7 +142,7 @@ async function createSalaryEntry( // Add salary line items that are cash expenses // Förmånsvärden (benefits) are excluded — they affect the tax base but // have no cash flow and should not appear as expense lines in the journal. - const BENEFIT_TYPES = ['benefit_car', 'benefit_housing', 'benefit_meals', 'benefit_wellness', 'benefit_other'] + const BENEFIT_TYPES = ['benefit_car', 'benefit_housing', 'benefit_meals', 'benefit_wellness', 'benefit_bike', 'benefit_other'] let lineItemTotal = 0 for (const li of emp.line_items) { if (li.is_net_deduction || li.is_gross_deduction) continue diff --git a/lib/utils.ts b/lib/utils.ts index c452cb77..d8c16e22 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -46,25 +46,12 @@ export function formatOrgNumber(orgNumber: string): string { return orgNumber } -/** - * Returns the display name for a company, using trade name as primary - * with legal name in parentheses if both exist. - */ -export function getCompanyDisplayName(settings: { trade_name?: string | null; company_name?: string | null }): string { - const tradeName = settings.trade_name?.trim() - const legalName = settings.company_name?.trim() - if (tradeName && legalName) { - return `${tradeName} (${legalName})` - } - return legalName || tradeName || '' +export function getCompanyDisplayName(settings: { company_name?: string | null }): string { + return settings.company_name?.trim() || '' } -/** - * Returns just the primary name for contexts where a short name is needed - * (e.g. email from name). Uses trade name if set, otherwise legal name. - */ -export function getCompanyPrimaryName(settings: { trade_name?: string | null; company_name?: string | null }): string { - return settings.trade_name?.trim() || settings.company_name?.trim() || '' +export function getCompanyPrimaryName(settings: { company_name?: string | null }): string { + return settings.company_name?.trim() || '' } export function generateInvoiceNumber(): string { diff --git a/supabase/migrations/20260512200000_salary_optional_vacation_accrual.sql b/supabase/migrations/20260512200000_salary_optional_vacation_accrual.sql new file mode 100644 index 00000000..a66b6ad9 --- /dev/null +++ b/supabase/migrations/20260512200000_salary_optional_vacation_accrual.sql @@ -0,0 +1,17 @@ +-- ============================================================================= +-- Salary: optional vacation accrual ("none" mode) +-- ============================================================================= +-- Adds a third value to employees.vacation_rule so a company can disable +-- semesteravsättning entirely. Useful for sole owners (ägare = enda anställd) +-- who don't accrue semester — booking otherwise creates a phantom 2920 liability. +-- +-- procentregeln — 12% (or 14.4% for 30+ days) accrued to 2920 (Semesterlagen 26§) +-- sammaloneregeln — semestertillägg only (Semesterlagen 16a§) +-- none — no accrual; salary expense is the full cost + +ALTER TABLE public.employees DROP CONSTRAINT IF EXISTS employees_vacation_rule_check; + +ALTER TABLE public.employees ADD CONSTRAINT employees_vacation_rule_check + CHECK (vacation_rule IN ('procentregeln', 'sammaloneregeln', 'none')); + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260512200100_employee_benefits.sql b/supabase/migrations/20260512200100_employee_benefits.sql new file mode 100644 index 00000000..799dca4c --- /dev/null +++ b/supabase/migrations/20260512200100_employee_benefits.sql @@ -0,0 +1,93 @@ +-- ============================================================================= +-- Employee benefits (förmånshantering) +-- ============================================================================= +-- Per-employee benefit assignments that drive auto-generated line items on +-- every salary run. Today the engine has helpers for car/meal/wellness benefits +-- but no link from a specific employee to a specific benefit instance — meaning +-- users had to add a benefit_* line item manually each month. This table closes +-- that gap. +-- +-- Includes bike benefit (cykelförmån, skattefri schablon 3 000 kr/år from 2022, +-- Skatteverket). + +CREATE TABLE public.employee_benefits ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + employee_id uuid NOT NULL REFERENCES public.employees(id) ON DELETE CASCADE, + company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE, + user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + + benefit_type text NOT NULL + CHECK (benefit_type IN ('bike', 'car', 'meals', 'housing', 'wellness', 'other')), + description text NOT NULL, + + -- Monthly taxable förmånsvärde (SEK). For bike, computed as + -- max(0, annual_market_value - 3000) / 12 per Skatteverket schablon. + monthly_value numeric NOT NULL CHECK (monthly_value >= 0), + + valid_from date NOT NULL, + valid_to date, + + -- Free-form context: bike → { annual_market_value, tax_free_allowance } + -- car → { nybilspris, environmental_type, ... } + metadata jsonb NOT NULL DEFAULT '{}'::jsonb, + + is_active boolean NOT NULL DEFAULT true, + + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + + CHECK (valid_to IS NULL OR valid_to >= valid_from) +); + +ALTER TABLE public.employee_benefits ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "employee_benefits_select" ON public.employee_benefits + FOR SELECT USING (company_id IN (SELECT public.user_company_ids())); + +CREATE POLICY "employee_benefits_insert" ON public.employee_benefits + FOR INSERT WITH CHECK (company_id IN (SELECT public.user_company_ids())); + +CREATE POLICY "employee_benefits_update" ON public.employee_benefits + FOR UPDATE USING (company_id IN (SELECT public.user_company_ids())); + +CREATE POLICY "employee_benefits_delete" ON public.employee_benefits + FOR DELETE USING (company_id IN (SELECT public.user_company_ids())); + +CREATE INDEX idx_employee_benefits_employee ON public.employee_benefits (employee_id); +CREATE INDEX idx_employee_benefits_company ON public.employee_benefits (company_id); +CREATE INDEX idx_employee_benefits_active + ON public.employee_benefits (employee_id, valid_from, valid_to) + WHERE is_active = true; + +CREATE TRIGGER employee_benefits_updated_at + BEFORE UPDATE ON public.employee_benefits + FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); + +-- Link a salary_line_items row back to the employee_benefits row that produced +-- it. Used by the calculate flow to delete-then-recreate benefit line items +-- without disturbing manually-added line items. +ALTER TABLE public.salary_line_items + ADD COLUMN source_benefit_id uuid REFERENCES public.employee_benefits(id) ON DELETE SET NULL; + +CREATE INDEX idx_salary_line_items_source_benefit + ON public.salary_line_items (source_benefit_id) + WHERE source_benefit_id IS NOT NULL; + +-- Extend salary_line_items to accept a benefit_bike type for cykelförmån. +ALTER TABLE public.salary_line_items DROP CONSTRAINT IF EXISTS salary_line_items_item_type_check; +ALTER TABLE public.salary_line_items ADD CONSTRAINT salary_line_items_item_type_check + CHECK (item_type IN ( + 'monthly_salary', 'hourly_salary', 'overtime', 'bonus', 'commission', + 'gross_deduction_pension', 'gross_deduction_other', + 'benefit_car', 'benefit_housing', 'benefit_meals', 'benefit_wellness', + 'benefit_bike', 'benefit_other', + 'sick_karens', 'sick_day2_14', 'sick_day15_plus', + 'vab', 'parental_leave', 'vacation', + 'traktamente_taxfree', 'traktamente_taxable', + 'mileage_taxfree', 'mileage_taxable', + 'net_deduction_advance', 'net_deduction_union', 'net_deduction_benefit_payment', + 'net_deduction_other', + 'correction', 'other' + )); + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260512200200_remove_trade_name_simplify_invoice_layout.sql b/supabase/migrations/20260512200200_remove_trade_name_simplify_invoice_layout.sql new file mode 100644 index 00000000..be10770c --- /dev/null +++ b/supabase/migrations/20260512200200_remove_trade_name_simplify_invoice_layout.sql @@ -0,0 +1,32 @@ +-- Simplify company naming: remove trade_name field entirely. +-- Add invoice_show_logo toggle for invoice PDF header. +-- Migrate any existing trade_name into company_name so users who set a +-- handelsnamn keep their preferred invoice display name. Existing users +-- should be notified to verify company_name against Bolagsverket since +-- this value is used in SIE/INK2/NE filings. + +ALTER TABLE public.company_settings + ADD COLUMN IF NOT EXISTS invoice_show_logo boolean DEFAULT true; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'company_settings' + AND column_name = 'trade_name' + ) THEN + EXECUTE $sql$ + UPDATE public.company_settings + SET company_name = trade_name + WHERE trade_name IS NOT NULL + AND trade_name <> '' + AND trade_name <> company_name + $sql$; + END IF; +END $$; + +ALTER TABLE public.company_settings + DROP COLUMN IF EXISTS trade_name; + +NOTIFY pgrst, 'reload schema'; diff --git a/tests/helpers.ts b/tests/helpers.ts index 36f7e326..6f1ceedf 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -494,7 +494,6 @@ export function makeCompanySettings( company_id: 'company-1', entity_type: 'enskild_firma', company_name: 'Test Firma', - trade_name: null, org_number: '199001011234', address_line1: 'Testgatan 1', address_line2: null, @@ -531,6 +530,7 @@ export function makeCompanySettings( invoice_show_ocr: true, invoice_show_bankgiro: true, invoice_show_plusgiro: true, + invoice_show_logo: true, invoice_late_fee_text: null, invoice_credit_terms_text: null, logo_url: null, diff --git a/types/index.ts b/types/index.ts index 53756b3b..8c2ad43b 100644 --- a/types/index.ts +++ b/types/index.ts @@ -177,7 +177,6 @@ export interface CompanySettings { // Entity info entity_type: EntityType company_name: string | null - trade_name: string | null org_number: string | null // Address @@ -240,6 +239,7 @@ export interface CompanySettings { invoice_show_ocr: boolean invoice_show_bankgiro: boolean invoice_show_plusgiro: boolean + invoice_show_logo: boolean invoice_late_fee_text: string | null invoice_credit_terms_text: string | null @@ -1264,7 +1264,6 @@ export interface BalansrapportReport { export interface SIEExportOptions { fiscal_period_id: string company_name: string - trade_name?: string | null org_number: string | null program_name?: string } @@ -2481,7 +2480,7 @@ export interface KPIPreferences { export type EmploymentType = 'employee' | 'company_owner' | 'board_member' export type SalaryType = 'monthly' | 'hourly' export type FSkattStatus = 'a_skatt' | 'f_skatt' | 'fa_skatt' | 'not_verified' -export type VacationRule = 'procentregeln' | 'sammaloneregeln' +export type VacationRule = 'procentregeln' | 'sammaloneregeln' | 'none' export type SalaryRunStatus = 'draft' | 'review' | 'approved' | 'paid' | 'booked' | 'corrected' export type AGIStatus = | 'generated' // XML built from a salary run; nothing sent to SKV yet @@ -2494,7 +2493,7 @@ export type AGIStatus = export type SalaryLineItemType = | 'monthly_salary' | 'hourly_salary' | 'overtime' | 'bonus' | 'commission' | 'gross_deduction_pension' | 'gross_deduction_other' - | 'benefit_car' | 'benefit_housing' | 'benefit_meals' | 'benefit_wellness' | 'benefit_other' + | 'benefit_car' | 'benefit_housing' | 'benefit_meals' | 'benefit_wellness' | 'benefit_bike' | 'benefit_other' | 'sick_karens' | 'sick_day2_14' | 'sick_day15_plus' | 'vab' | 'parental_leave' | 'vacation' | 'traktamente_taxfree' | 'traktamente_taxable'