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
@@ -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<string, string> = {
|
||||
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<Employee | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
@@ -375,10 +376,16 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="procentregeln">Procentregeln (12%)</SelectItem>
|
||||
<SelectItem value="procentregeln">Procentregeln (12 %)</SelectItem>
|
||||
<SelectItem value="sammaloneregeln">Sammalöneregeln</SelectItem>
|
||||
<SelectItem value="none">Ingen semesteravsättning</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{vacationRule === 'none' && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vacation_days_per_year">Semesterdagar per år</Label>
|
||||
@@ -417,6 +424,9 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Benefits */}
|
||||
<EmployeeBenefitsPanel employeeId={id} canWrite={canWrite} />
|
||||
|
||||
{canWrite && (
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="outline" asChild>
|
||||
|
||||
@@ -24,6 +24,7 @@ export default function NewEmployeePage() {
|
||||
const [salaryType, setSalaryType] = useState('monthly')
|
||||
const [fSkattStatus, setFSkattStatus] = useState('a_skatt')
|
||||
const [isSidoinkomst, setIsSidoinkomst] = useState(false)
|
||||
const [vacationRule, setVacationRule] = useState('procentregeln')
|
||||
|
||||
const requiresTaxTable = fSkattStatus === 'a_skatt' && !isSidoinkomst
|
||||
|
||||
@@ -54,6 +55,8 @@ export default function NewEmployeePage() {
|
||||
city: form.get('city') as string || undefined,
|
||||
clearing_number: form.get('clearing_number') as string || undefined,
|
||||
bank_account_number: form.get('bank_account_number') as string || undefined,
|
||||
vacation_rule: vacationRule,
|
||||
vacation_days_per_year: parseInt(form.get('vacation_days_per_year') as string) || 25,
|
||||
}
|
||||
|
||||
const res = await fetch('/api/salary/employees', {
|
||||
@@ -274,6 +277,40 @@ export default function NewEmployeePage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Vacation */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Semester</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vacation_rule">Semesterregel</Label>
|
||||
<Select value={vacationRule} onValueChange={setVacationRule}>
|
||||
<SelectTrigger id="vacation_rule">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="procentregeln">Procentregeln (12 %)</SelectItem>
|
||||
<SelectItem value="sammaloneregeln">Sammalöneregeln</SelectItem>
|
||||
<SelectItem value="none">Ingen semesteravsättning</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{vacationRule === 'none' && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Ingen avsättning till 2920 bokas. Vanligt för ägare som är enda anställd.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vacation_days_per_year">Semesterdagar per år</Label>
|
||||
<Input id="vacation_days_per_year" name="vacation_days_per_year" type="number" min="25" max="40" defaultValue="25" />
|
||||
<p className="text-xs text-muted-foreground">Lagstadgat minimum: 25 dagar</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Bank */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
||||
@@ -21,7 +21,7 @@ const EMPLOYMENT_LABELS: Record<string, string> = {
|
||||
export default function EmployeesPage() {
|
||||
const [employees, setEmployees] = useState<Employee[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const canWrite = useCanWrite()
|
||||
const { canWrite } = useCanWrite()
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
|
||||
@@ -34,7 +34,7 @@ export default function SalaryPage() {
|
||||
const [runs, setRuns] = useState<SalaryRun[]>([])
|
||||
const [employeeCount, setEmployeeCount] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const canWrite = useCanWrite()
|
||||
const { canWrite } = useCanWrite()
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
|
||||
@@ -21,6 +21,7 @@ const LINE_ITEM_TYPE_LABELS: Record<SalaryLineItemType, string> = {
|
||||
benefit_housing: 'Bostadsförmån',
|
||||
benefit_meals: 'Kostförmån',
|
||||
benefit_wellness: 'Friskvård',
|
||||
benefit_bike: 'Cykelförmån',
|
||||
benefit_other: 'Övrig förmån',
|
||||
sick_karens: 'Karensavdrag',
|
||||
sick_day2_14: 'Sjuklön (dag 2–14, 80 %)',
|
||||
|
||||
@@ -21,6 +21,7 @@ import type { SalaryRun, SalaryRunEmployee, Employee, CreateJournalEntryLineInpu
|
||||
import { AGIPanel } from '@/components/salary/AGIPanel'
|
||||
import { PaymentFilePanel } from '@/components/salary/PaymentFilePanel'
|
||||
import { TaxPaymentPanel } from '@/components/salary/TaxPaymentPanel'
|
||||
import { TaxTableStatus } from '@/components/salary/TaxTableStatus'
|
||||
|
||||
type SalaryRunWithArbetsgivare = SalaryRun & { arbetsgivare?: string | null }
|
||||
|
||||
@@ -57,7 +58,7 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id:
|
||||
const { id } = use(params)
|
||||
const router = useRouter()
|
||||
const { toast } = useToast()
|
||||
const canWrite = useCanWrite()
|
||||
const { canWrite } = useCanWrite()
|
||||
|
||||
const [run, setRun] = useState<SalaryRun | null>(null)
|
||||
const [availableEmployees, setAvailableEmployees] = useState<Employee[]>([])
|
||||
@@ -353,6 +354,7 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id:
|
||||
<CardTitle className="text-base">Beräkningsdetaljer</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<TaxTableStatus year={run.period_year} compact />
|
||||
{employees.filter(e => e.calculation_breakdown).map(sre => {
|
||||
const breakdown = sre.calculation_breakdown as { steps?: Array<{ label: string; formula: string; output: number | null }> }
|
||||
return (
|
||||
|
||||
@@ -18,7 +18,6 @@ export default function CompanySettingsPage() {
|
||||
function handleSave(formData: FormData) {
|
||||
const updates: Record<string, unknown> = {
|
||||
...(formData.has('company_name') && { company_name: formData.get('company_name') as string }),
|
||||
trade_name: (formData.get('trade_name') as string) || null,
|
||||
...(formData.has('org_number') && { org_number: formData.get('org_number') as string }),
|
||||
address_line1: formData.get('address_line1') as string,
|
||||
postal_code: formData.get('postal_code') as string,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { PageHeader } from '@/components/ui/page-header'
|
||||
import { TaxTableStatus } from '@/components/salary/TaxTableStatus'
|
||||
|
||||
export default function SalarySettingsPage() {
|
||||
return (
|
||||
@@ -26,6 +27,20 @@ export default function SalarySettingsPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Skattetabeller</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<TaxTableStatus />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Skattetabeller och kommunala skattesatser hämtas automatiskt från Skatteverkets öppna data
|
||||
vid varje lönekörning. Ingen manuell uppdatering krävs. Om Skatteverkets API är otillgängligt
|
||||
används en inbäddad reservkopia tills tjänsten är uppe igen.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Semester</CardTitle>
|
||||
@@ -34,8 +49,9 @@ export default function SalarySettingsPage() {
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Standard semesterregel</label>
|
||||
<select className="flex h-9 w-full max-w-xs rounded-md border border-input bg-transparent px-3 py-1 text-sm" defaultValue="procentregeln">
|
||||
<option value="procentregeln">Procentregeln (12%)</option>
|
||||
<option value="procentregeln">Procentregeln (12 %)</option>
|
||||
<option value="sammaloneregeln">Sammalöneregeln</option>
|
||||
<option value="none">Ingen semesteravsättning</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@@ -58,9 +74,8 @@ export default function SalarySettingsPage() {
|
||||
<CardContent>
|
||||
<div className="text-sm text-muted-foreground space-y-2">
|
||||
<p>Lönemodulen hanterar löner för aktiebolag. Enskild firma-ägare använder eget uttag istället.</p>
|
||||
<p>Arbetsgivaravgifter och skattetabeller uppdateras årligen baserat på Skatteverkets publiceringar.</p>
|
||||
<p>
|
||||
<strong>Aktuellt år:</strong> 2026 — Arbetsgivaravgifter 31,42%, prisbasbelopp 59 200 SEK
|
||||
<strong>Aktuellt år:</strong> 2026 — Arbetsgivaravgifter 31,42 %, prisbasbelopp 59 200 SEK
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -155,7 +155,7 @@ export const POST = withRouteContext(
|
||||
html: generateInvoiceEmailHtml(emailData),
|
||||
text: generateInvoiceEmailText(emailData),
|
||||
replyTo: company.email || undefined,
|
||||
fromName: company.trade_name || company.company_name,
|
||||
fromName: company.company_name,
|
||||
attachments: [
|
||||
{
|
||||
filename,
|
||||
|
||||
@@ -95,11 +95,11 @@ export async function GET(request: Request) {
|
||||
// Get company name for the consent page
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('company_name, trade_name')
|
||||
.select('company_name')
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
const companyName = settings?.trade_name || settings?.company_name || user.email
|
||||
const companyName = settings?.company_name || user.email
|
||||
|
||||
const appNameLower = escapeHtml(getBranding().appName.toLowerCase())
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import { UpdateEmployeeBenefitSchema } from '@/lib/api/schemas'
|
||||
import { calculateBikeBenefit } from '@/lib/salary/benefits'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
export async function PATCH(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string; benefitId: string }> }
|
||||
) {
|
||||
const { id, benefitId } = await params
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const writeCheck = await requireWritePermission(supabase, user.id)
|
||||
if (!writeCheck.ok) return writeCheck.response
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
|
||||
const validation = await validateBody(request, UpdateEmployeeBenefitSchema)
|
||||
if (!validation.success) return validation.response
|
||||
const body = validation.data
|
||||
|
||||
const { data: existing, error: fetchError } = await supabase
|
||||
.from('employee_benefits')
|
||||
.select('benefit_type, metadata')
|
||||
.eq('id', benefitId)
|
||||
.eq('employee_id', id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (fetchError || !existing) {
|
||||
return NextResponse.json({ error: 'Förmån hittades inte' }, { status: 404 })
|
||||
}
|
||||
|
||||
const updates: Record<string, unknown> = { ...body }
|
||||
|
||||
if (body.annual_market_value !== undefined) {
|
||||
if (existing.benefit_type !== 'bike') {
|
||||
return NextResponse.json(
|
||||
{ error: 'annual_market_value gäller endast cykelförmån' },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
const calc = calculateBikeBenefit(body.annual_market_value)
|
||||
updates.monthly_value = calc.monthlyValue
|
||||
updates.metadata = {
|
||||
...(existing.metadata as Record<string, unknown> ?? {}),
|
||||
...(body.metadata ?? {}),
|
||||
annual_market_value: body.annual_market_value,
|
||||
annual_taxable: calc.annualTaxable,
|
||||
tax_free_portion: calc.taxFreePortion,
|
||||
}
|
||||
delete (updates as Record<string, unknown>).annual_market_value
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('employee_benefits')
|
||||
.update(updates)
|
||||
.eq('id', benefitId)
|
||||
.eq('employee_id', id)
|
||||
.eq('company_id', companyId)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error || !data) {
|
||||
return NextResponse.json({ error: 'Förmån hittades inte' }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string; benefitId: string }> }
|
||||
) {
|
||||
const { id, benefitId } = await params
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const writeCheck = await requireWritePermission(supabase, user.id)
|
||||
if (!writeCheck.ok) return writeCheck.response
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
|
||||
const { error } = await supabase
|
||||
.from('employee_benefits')
|
||||
.delete()
|
||||
.eq('id', benefitId)
|
||||
.eq('employee_id', id)
|
||||
.eq('company_id', companyId)
|
||||
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
|
||||
return NextResponse.json({ data: { id: benefitId, deleted: true } })
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import { CreateEmployeeBenefitSchema } from '@/lib/api/schemas'
|
||||
import { calculateBikeBenefit } from '@/lib/salary/benefits'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('employee_benefits')
|
||||
.select('*')
|
||||
.eq('employee_id', id)
|
||||
.eq('company_id', companyId)
|
||||
.order('valid_from', { ascending: false })
|
||||
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const writeCheck = await requireWritePermission(supabase, user.id)
|
||||
if (!writeCheck.ok) return writeCheck.response
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
|
||||
const validation = await validateBody(request, CreateEmployeeBenefitSchema)
|
||||
if (!validation.success) return validation.response
|
||||
const body = validation.data
|
||||
|
||||
// Confirm employee belongs to the company
|
||||
const { data: emp } = await supabase
|
||||
.from('employees')
|
||||
.select('id')
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
if (!emp) return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 })
|
||||
|
||||
// Bike benefit: derive monthly_value + metadata from the annual market value
|
||||
let monthlyValue = body.monthly_value ?? 0
|
||||
let metadata: Record<string, unknown> = body.metadata ?? {}
|
||||
|
||||
if (body.benefit_type === 'bike' && body.annual_market_value !== undefined) {
|
||||
const calc = calculateBikeBenefit(body.annual_market_value)
|
||||
monthlyValue = calc.monthlyValue
|
||||
metadata = {
|
||||
...metadata,
|
||||
annual_market_value: body.annual_market_value,
|
||||
annual_taxable: calc.annualTaxable,
|
||||
tax_free_portion: calc.taxFreePortion,
|
||||
}
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('employee_benefits')
|
||||
.insert({
|
||||
employee_id: id,
|
||||
company_id: companyId,
|
||||
user_id: user.id,
|
||||
benefit_type: body.benefit_type,
|
||||
description: body.description,
|
||||
monthly_value: monthlyValue,
|
||||
valid_from: body.valid_from,
|
||||
valid_to: body.valid_to ?? null,
|
||||
metadata,
|
||||
is_active: body.is_active ?? true,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
|
||||
return NextResponse.json({ data }, { status: 201 })
|
||||
}
|
||||
@@ -17,6 +17,15 @@ const DERIVED_ABSENCE_TYPES: SalaryLineItemType[] = [
|
||||
'parental_leave',
|
||||
]
|
||||
|
||||
const BENEFIT_TYPE_TO_LINE_ITEM: Record<string, SalaryLineItemType> = {
|
||||
bike: 'benefit_bike',
|
||||
car: 'benefit_car',
|
||||
meals: 'benefit_meals',
|
||||
housing: 'benefit_housing',
|
||||
wellness: 'benefit_wellness',
|
||||
other: 'benefit_other',
|
||||
}
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
export const POST = withRouteContext(
|
||||
@@ -239,6 +248,61 @@ export const POST = withRouteContext(
|
||||
return errorResponse(delAbsErr, opLog, { requestId })
|
||||
}
|
||||
|
||||
// ── Derive benefit line items from employee_benefits ──
|
||||
// Active benefits = is_active AND payment_date ∈ [valid_from, valid_to]
|
||||
const { data: activeBenefits, error: benefitsErr } = await supabase
|
||||
.from('employee_benefits')
|
||||
.select('id, benefit_type, description, monthly_value')
|
||||
.eq('employee_id', emp.id)
|
||||
.eq('company_id', companyId)
|
||||
.eq('is_active', true)
|
||||
.lte('valid_from', run.payment_date)
|
||||
.or(`valid_to.is.null,valid_to.gte.${run.payment_date}`)
|
||||
if (benefitsErr) {
|
||||
return errorResponse(benefitsErr, opLog, { requestId })
|
||||
}
|
||||
|
||||
// Replace prior auto-generated benefit rows for this sre.
|
||||
const { error: delBenefitErr } = await supabase
|
||||
.from('salary_line_items')
|
||||
.delete()
|
||||
.eq('salary_run_employee_id', sre.id)
|
||||
.not('source_benefit_id', 'is', null)
|
||||
if (delBenefitErr) {
|
||||
return errorResponse(delBenefitErr, opLog, { requestId })
|
||||
}
|
||||
|
||||
const derivedBenefitRows = (activeBenefits ?? [])
|
||||
.filter(b => b.monthly_value > 0)
|
||||
.map((b, idx) => {
|
||||
const itemType = BENEFIT_TYPE_TO_LINE_ITEM[b.benefit_type] ?? 'benefit_other'
|
||||
return {
|
||||
salary_run_employee_id: sre.id,
|
||||
company_id: companyId,
|
||||
item_type: itemType,
|
||||
description: b.description,
|
||||
quantity: 1,
|
||||
amount: Math.round(b.monthly_value * 100) / 100,
|
||||
is_taxable: true,
|
||||
is_avgift_basis: true,
|
||||
is_vacation_basis: false,
|
||||
is_gross_deduction: false,
|
||||
is_net_deduction: false,
|
||||
account_number: getLineItemAccount(itemType, emp.employment_type),
|
||||
sort_order: 200 + idx,
|
||||
source_benefit_id: b.id,
|
||||
}
|
||||
})
|
||||
|
||||
if (derivedBenefitRows.length > 0) {
|
||||
const { error: insBenefitErr } = await supabase
|
||||
.from('salary_line_items')
|
||||
.insert(derivedBenefitRows)
|
||||
if (insBenefitErr) {
|
||||
return errorResponse(insBenefitErr, opLog, { requestId })
|
||||
}
|
||||
}
|
||||
|
||||
if (absenceResult.lineItems.length > 0) {
|
||||
const rows = absenceResult.lineItems.map((li, idx) => ({
|
||||
salary_run_employee_id: sre.id,
|
||||
@@ -265,10 +329,13 @@ export const POST = withRouteContext(
|
||||
|
||||
// Build the merged in-memory line items: keep non-derived items from
|
||||
// the originally-loaded sre.line_items, then append the freshly-derived
|
||||
// absence items.
|
||||
// absence and benefit items.
|
||||
const manualLineItems = (sre.line_items || [])
|
||||
.filter((li: Record<string, unknown>) =>
|
||||
!DERIVED_ABSENCE_TYPES.includes(li.item_type as SalaryLineItemType))
|
||||
.filter((li: Record<string, unknown>) => {
|
||||
if (DERIVED_ABSENCE_TYPES.includes(li.item_type as SalaryLineItemType)) return false
|
||||
if (li.source_benefit_id) return false
|
||||
return true
|
||||
})
|
||||
.map((li: Record<string, unknown>) => ({
|
||||
itemType: li.item_type as SalaryLineItemType,
|
||||
amount: li.amount as number,
|
||||
@@ -287,7 +354,16 @@ export const POST = withRouteContext(
|
||||
isGrossDeduction: li.is_gross_deduction,
|
||||
isNetDeduction: false,
|
||||
}))
|
||||
const lineItems = [...manualLineItems, ...derivedLineItems]
|
||||
const derivedBenefitLineItems = derivedBenefitRows.map(row => ({
|
||||
itemType: row.item_type as SalaryLineItemType,
|
||||
amount: row.amount,
|
||||
isTaxable: true,
|
||||
isAvgiftBasis: true,
|
||||
isVacationBasis: false,
|
||||
isGrossDeduction: false,
|
||||
isNetDeduction: false,
|
||||
}))
|
||||
const lineItems = [...manualLineItems, ...derivedLineItems, ...derivedBenefitLineItems]
|
||||
|
||||
const result = calculateSalary(
|
||||
{
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { fetchTaxTableRates, TaxTableUnavailableError } from '@/lib/salary/tax-tables'
|
||||
|
||||
/**
|
||||
* Probe Skatteverket's open data API to confirm tax tables are reachable.
|
||||
* Used by the salary settings page to surface that fetching is automatic.
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const year = parseInt(searchParams.get('year') || String(new Date().getFullYear()))
|
||||
|
||||
try {
|
||||
const { source } = await fetchTaxTableRates(year, 30, 1)
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
year,
|
||||
source,
|
||||
reachable: source === 'api',
|
||||
checkedAt: new Date().toISOString(),
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
if (err instanceof TaxTableUnavailableError) {
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
year,
|
||||
source: 'unavailable' as const,
|
||||
reachable: false,
|
||||
checkedAt: new Date().toISOString(),
|
||||
message: err.message,
|
||||
},
|
||||
})
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { Plus, Trash2, Loader2 } from 'lucide-react'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
|
||||
type BenefitType = 'bike' | 'car' | 'meals' | 'housing' | 'wellness' | 'other'
|
||||
|
||||
interface EmployeeBenefit {
|
||||
id: string
|
||||
benefit_type: BenefitType
|
||||
description: string
|
||||
monthly_value: number
|
||||
valid_from: string
|
||||
valid_to: string | null
|
||||
metadata: Record<string, unknown>
|
||||
is_active: boolean
|
||||
}
|
||||
|
||||
const BENEFIT_LABELS: Record<BenefitType, string> = {
|
||||
bike: 'Cykelförmån',
|
||||
car: 'Bilförmån',
|
||||
meals: 'Kostförmån',
|
||||
housing: 'Bostadsförmån',
|
||||
wellness: 'Friskvård (skattepliktig del)',
|
||||
other: 'Övrig förmån',
|
||||
}
|
||||
|
||||
export function EmployeeBenefitsPanel({ employeeId, canWrite }: { employeeId: string; canWrite: boolean }) {
|
||||
const { toast } = useToast()
|
||||
const [benefits, setBenefits] = useState<EmployeeBenefit[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [adding, setAdding] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const [type, setType] = useState<BenefitType>('bike')
|
||||
const [description, setDescription] = useState('')
|
||||
const [monthlyValue, setMonthlyValue] = useState('')
|
||||
const [annualMarketValue, setAnnualMarketValue] = useState('')
|
||||
const [validFrom, setValidFrom] = useState(() => new Date().toISOString().slice(0, 10))
|
||||
const [validTo, setValidTo] = useState('')
|
||||
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
const res = await fetch(`/api/salary/employees/${employeeId}/benefits`)
|
||||
if (res.ok) {
|
||||
const { data } = await res.json()
|
||||
setBenefits(data || [])
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [employeeId])
|
||||
|
||||
function reset() {
|
||||
setType('bike')
|
||||
setDescription('')
|
||||
setMonthlyValue('')
|
||||
setAnnualMarketValue('')
|
||||
setValidFrom(new Date().toISOString().slice(0, 10))
|
||||
setValidTo('')
|
||||
setAdding(false)
|
||||
}
|
||||
|
||||
async function handleAdd() {
|
||||
setSubmitting(true)
|
||||
const body: Record<string, unknown> = {
|
||||
benefit_type: type,
|
||||
description: description || BENEFIT_LABELS[type],
|
||||
valid_from: validFrom,
|
||||
}
|
||||
if (validTo) body.valid_to = validTo
|
||||
if (type === 'bike') {
|
||||
body.annual_market_value = parseFloat(annualMarketValue) || 0
|
||||
} else {
|
||||
body.monthly_value = parseFloat(monthlyValue) || 0
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/salary/employees/${employeeId}/benefits`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
toast({ title: 'Förmån tillagd' })
|
||||
reset()
|
||||
await load()
|
||||
} else {
|
||||
const result = await res.json()
|
||||
toast({
|
||||
title: 'Kunde inte spara förmån',
|
||||
description: getErrorMessage(result, { statusCode: res.status }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
setSubmitting(false)
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
const res = await fetch(`/api/salary/employees/${employeeId}/benefits/${id}`, { method: 'DELETE' })
|
||||
if (res.ok) {
|
||||
toast({ title: 'Förmån borttagen' })
|
||||
await load()
|
||||
} else {
|
||||
toast({ title: 'Kunde inte ta bort', variant: 'destructive' })
|
||||
}
|
||||
}
|
||||
|
||||
const previewMonthlyBike = (() => {
|
||||
const annual = parseFloat(annualMarketValue) || 0
|
||||
return Math.max(0, annual - 3000) / 12
|
||||
})()
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-base">Förmåner</CardTitle>
|
||||
{canWrite && !adding && (
|
||||
<Button size="sm" variant="outline" onClick={() => setAdding(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Lägg till förmån
|
||||
</Button>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{loading ? (
|
||||
<p className="text-sm text-muted-foreground">Laddar…</p>
|
||||
) : benefits.length === 0 && !adding ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Inga förmåner registrerade. Aktiva förmåner läggs till automatiskt som rader vid lönekörning.
|
||||
</p>
|
||||
) : (
|
||||
benefits.length > 0 && (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Typ</TableHead>
|
||||
<TableHead>Beskrivning</TableHead>
|
||||
<TableHead className="text-right">Värde/mån</TableHead>
|
||||
<TableHead>Period</TableHead>
|
||||
<TableHead></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{benefits.map(b => (
|
||||
<TableRow key={b.id}>
|
||||
<TableCell>{BENEFIT_LABELS[b.benefit_type]}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{b.description}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatCurrency(b.monthly_value)}</TableCell>
|
||||
<TableCell className="text-muted-foreground tabular-nums text-xs">
|
||||
{formatDate(b.valid_from)}
|
||||
{b.valid_to ? ` – ${formatDate(b.valid_to)}` : ' – löpande'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{canWrite && (
|
||||
<Button size="icon" variant="ghost" onClick={() => handleDelete(b.id)} aria-label="Ta bort">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
)}
|
||||
|
||||
{adding && (
|
||||
<div className="space-y-4 rounded-md border bg-muted/30 p-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="benefit_type">Typ</Label>
|
||||
<Select value={type} onValueChange={(v) => setType(v as BenefitType)}>
|
||||
<SelectTrigger id="benefit_type">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(Object.keys(BENEFIT_LABELS) as BenefitType[]).map(k => (
|
||||
<SelectItem key={k} value={k}>{BENEFIT_LABELS[k]}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="benefit_description">Beskrivning</Label>
|
||||
<Input
|
||||
id="benefit_description"
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
placeholder={BENEFIT_LABELS[type]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{type === 'bike' ? (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="annual_market_value">Årligt marknadsvärde (SEK)</Label>
|
||||
<Input
|
||||
id="annual_market_value"
|
||||
type="number"
|
||||
step="1"
|
||||
min="0"
|
||||
value={annualMarketValue}
|
||||
onChange={e => setAnnualMarketValue(e.target.value)}
|
||||
placeholder="t.ex. 8 400 (700 kr/mån)"
|
||||
className="max-w-xs"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Skatteverkets schablon: första 3 000 kr/år är skattefri. Resterande beskattas månadsvis.
|
||||
{parseFloat(annualMarketValue) > 0 && (
|
||||
<span className="ml-1">
|
||||
Månatligt förmånsvärde: <strong className="tabular-nums">{formatCurrency(previewMonthlyBike)}</strong>
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="monthly_value">Månatligt förmånsvärde (SEK)</Label>
|
||||
<Input
|
||||
id="monthly_value"
|
||||
type="number"
|
||||
step="1"
|
||||
min="0"
|
||||
value={monthlyValue}
|
||||
onChange={e => setMonthlyValue(e.target.value)}
|
||||
className="max-w-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="valid_from">Gäller från</Label>
|
||||
<Input id="valid_from" type="date" value={validFrom} onChange={e => setValidFrom(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="valid_to">Gäller till (valfritt)</Label>
|
||||
<Input id="valid_to" type="date" value={validTo} onChange={e => setValidTo(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" size="sm" onClick={reset} disabled={submitting}>Avbryt</Button>
|
||||
<Button size="sm" onClick={handleAdd} disabled={submitting}>
|
||||
{submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Spara
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -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<PaymentFormat, string> = {
|
||||
pain001: 'ISO 20022. För banker som inte är anslutna till Bankgirot, eller internationell SEPA.',
|
||||
}
|
||||
|
||||
const BANK_INSTRUCTIONS: Record<PaymentFormat, Array<{ bank: string; steps: string }>> = {
|
||||
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<PaymentFormat>(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
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowInstructions(s => !s)}
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground"
|
||||
aria-expanded={showInstructions}
|
||||
>
|
||||
<ChevronDown className={`h-3 w-3 transition-transform ${showInstructions ? 'rotate-180' : ''}`} />
|
||||
Så importerar du filen i din bank
|
||||
</button>
|
||||
|
||||
{showInstructions && (
|
||||
<div className="space-y-2 rounded-md border bg-muted/30 p-3 text-xs">
|
||||
{BANK_INSTRUCTIONS[format].map(b => (
|
||||
<div key={b.bank}>
|
||||
<strong className="text-foreground">{b.bank}.</strong>{' '}
|
||||
<span className="text-muted-foreground">{b.steps}</span>
|
||||
</div>
|
||||
))}
|
||||
<p className="pt-1 text-muted-foreground border-t mt-2">
|
||||
Filen innehåller både lön till anställda och beloppen är förberedda — bankens BankID-signering är det enda steget kvar.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-start gap-2 rounded-md border border-dashed bg-muted/20 p-3 text-xs text-muted-foreground">
|
||||
<Info className="h-3.5 w-3.5 mt-0.5 shrink-0" />
|
||||
<span>
|
||||
<strong className="text-foreground">Open Payments / direktbetalning</strong> 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.
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@@ -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<Status | null>(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 (
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-1.5">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
Kontrollerar skattetabeller…
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<p className="text-xs text-muted-foreground flex items-center gap-1.5">
|
||||
<Globe className="h-3 w-3" />
|
||||
{label}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 rounded-md border bg-muted/30 px-3 py-2">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Icon className={`h-4 w-4 ${iconColor}`} />
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={check} disabled={loading} aria-label="Kontrollera igen">
|
||||
{loading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCcw className="h-3.5 w-3.5" />}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 && (
|
||||
<p className="text-xs text-muted-foreground">Kan inte ändras efter att kontot skapats</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="trade_name">Handelsnamn</Label>
|
||||
<Input
|
||||
id="trade_name"
|
||||
name="trade_name"
|
||||
defaultValue={settings.trade_name || ''}
|
||||
placeholder="Visas på fakturor istället för företagsnamnet"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
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).
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -95,6 +95,17 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps)
|
||||
onCheckedChange={(v) => saveToggle('invoice_show_plusgiro', v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Visa logga</Label>
|
||||
<p className="text-xs text-muted-foreground">Visa uppladdad logga i fakturahuvudet</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.invoice_show_logo ?? true}
|
||||
onCheckedChange={(v) => saveToggle('invoice_show_logo', v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 pt-2">
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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;">
|
||||
|
||||
@@ -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,
|
||||
|
||||
+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 {
|
||||
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+3
-4
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user