Bug/accounting suggestion (#456)
* 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
cd96e5ec26
commit
3fa871c742
+44
-3
@@ -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),
|
||||
|
||||
@@ -137,7 +137,6 @@ export function generateInvoiceEmailHtml(data: InvoiceEmailData): string {
|
||||
<p style="margin: 0; color: #666; font-size: 14px;">
|
||||
Med vänliga hälsningar,<br>
|
||||
<strong>${getCompanyPrimaryName(company)}</strong>
|
||||
${company.trade_name && company.company_name ? `<br><span style="font-weight: normal; font-size: 12px; color: #999;">(${company.company_name})</span>` : ''}
|
||||
</p>
|
||||
${company.org_number ? `
|
||||
<p style="margin: 10px 0 0 0; color: #999; font-size: 12px;">
|
||||
|
||||
@@ -180,7 +180,6 @@ export function generateReminderEmailHtml(data: ReminderEmailData): string {
|
||||
<p style="margin: 0; color: #666; font-size: 14px;">
|
||||
Med vänliga hälsningar,<br>
|
||||
<strong>${getCompanyPrimaryName(company)}</strong>
|
||||
${company.trade_name && company.company_name ? `<br><span style="font-weight: normal; font-size: 12px; color: #999;">(${company.company_name})</span>` : ''}
|
||||
</p>
|
||||
${company.org_number ? `
|
||||
<p style="margin: 10px 0 0 0; color: #999; font-size: 12px;">
|
||||
|
||||
-11
@@ -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`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -374,21 +374,10 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<View style={styles.companyInfo}>
|
||||
{company.logo_url && (
|
||||
{company.logo_url && (company.invoice_show_logo ?? true) && (
|
||||
<Image src={company.logo_url} style={{ maxHeight: 40, maxWidth: 150, marginBottom: 6, alignSelf: 'flex-start' }} />
|
||||
)}
|
||||
<Text style={styles.companyName}>{company.trade_name || company.company_name}</Text>
|
||||
{company.trade_name && company.company_name && (
|
||||
<Text style={{ fontSize: 8, color: '#666' }}>({company.company_name})</Text>
|
||||
)}
|
||||
{company.address_line1 && <Text>{company.address_line1}</Text>}
|
||||
{(company.postal_code || company.city) && (
|
||||
<Text>{company.postal_code} {company.city}</Text>
|
||||
)}
|
||||
{company.org_number && (
|
||||
<Text style={{ marginTop: 4 }}>Org.nr: {formatOrgNumber(company.org_number)}</Text>
|
||||
)}
|
||||
{company.vat_number && <Text>VAT: {company.vat_number}</Text>}
|
||||
<Text style={styles.companyName}>{company.company_name}</Text>
|
||||
</View>
|
||||
<View style={{ textAlign: 'right' }}>
|
||||
<Text style={[styles.title, isCreditNote ? styles.creditNoteTitle : {}]}>
|
||||
@@ -672,14 +661,16 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
{/* Footer — collected legal info per ML 17 kap 24§ */}
|
||||
<View style={styles.footer}>
|
||||
<Text style={styles.footerText}>
|
||||
{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(' · ')}
|
||||
</Text>
|
||||
</View>
|
||||
</Page>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' }],
|
||||
})
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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)}`
|
||||
: ''
|
||||
|
||||
@@ -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<CompanyInfo> {
|
||||
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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
<View style={styles.footer} fixed>
|
||||
<Text style={styles.footerText}>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -21,6 +21,7 @@ const LINE_ITEM_ACCOUNTS: Record<SalaryLineItemType, string> = {
|
||||
benefit_housing: '7381',
|
||||
benefit_meals: '7382',
|
||||
benefit_wellness: '7699',
|
||||
benefit_bike: '7388',
|
||||
benefit_other: '7389',
|
||||
// Absence
|
||||
sick_karens: '7281',
|
||||
|
||||
@@ -80,6 +80,7 @@ export const BENEFIT_RUTA_MAP: Record<string, string> = {
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
// ============================================================
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
+4
-17
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user