5ac560ce41
* fix(bookkeeping): refresh correction line description on account change When editing an ändringsverifikation, CorrectionEntryDialog pre-filled each line's description from the original entry but never re-derived it when the user changed the account, so a description carried over from the old account (e.g. 2393 "Lån från närstående personer, långfristig del") stayed stale on the newly chosen account (e.g. 2893, the kortfristig account). The regular JournalEntryForm already auto-fills on account change; this mirrors it. The refresh is guarded: it only overwrites the description when it is empty or still equals the previously selected account's name, so a memo the user typed themselves is preserved. Logic is extracted into a pure, unit-tested helper. Note: the wrong text on an already-posted correction cannot be repaired (line descriptions of posted verifikat are immutable per BFL / migration 017); this prevents recurrence on future corrections. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(deadlines): generate tax deadlines for the installed base Automatic tax deadlines only regenerated when a tax-relevant settings field changed value (didTaxFieldsChange). Companies fill those fields once at onboarding, so a later save changed nothing and generated nothing; the annual cron was the only unconditional trigger. As a result only ~5 of ~776 real companies had any system deadlines, and the /deadlines empty state told users to "check the tax settings" that were already complete. - Settings save now also regenerates when the company has zero system deadlines yet (safe first-time backfill; cannot reset is_completed/status). Decision extracted into shouldRegenerateTaxDeadlines() with tests. - The empty-state banner gets a "Generera nu" action wired to the existing /api/tax-deadlines/generate route (previously it had no caller). New sv/en strings. - generateNewYearDeadlines (annual cron) paginates company_settings via fetchAllRows: a plain .select() silently caps at 1000 rows, leaving companies beyond the cap without next-year deadlines. - scripts/backfill-tax-deadlines.ts: one-off that reruns the real generator for non-sandbox companies with zero system deadlines. Known gap (follow-up): moms_period='yearly' has no deadline config, so annual VAT filers get no momsdeklaration deadline yet. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(deadlines): address review feedback + fix settings-route test - settings/route.ts: fail safe when the system-deadline count query errors. A null count on error was treated as 0, which would trigger a delete+regenerate and reset is_completed/status on a transient failure; now a count error keeps the self-heal off (CodeRabbit, Major). - Update app/api/settings/__tests__/route.test.ts (added on main via the withRouteContext refactor) for the extra deadline-count query and the new shouldRegenerateTaxDeadlines export; add self-heal / no-regen cases. - Soften the "no deadlines created" copy: zero generated rows can also mean no applicable obligations (or the moms_yearly gap), not just incomplete settings (CodeRabbit, Minor). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
183 lines
7.4 KiB
TypeScript
183 lines
7.4 KiB
TypeScript
import { NextResponse } from 'next/server'
|
|
import { withRouteContext } from '@/lib/api/with-route-context'
|
|
import { didTaxFieldsChange, regenerateTaxDeadlinesForUser, shouldRegenerateTaxDeadlines } from '@/lib/tax/deadline-generator'
|
|
import { validateBody } from '@/lib/api/validate'
|
|
import { UpdateSettingsSchema } from '@/lib/api/schemas'
|
|
|
|
export const GET = withRouteContext(
|
|
'settings.get',
|
|
async (_request, { supabase, companyId }) => {
|
|
const { data, error } = await supabase
|
|
.from('company_settings')
|
|
.select('*')
|
|
.eq('company_id', companyId)
|
|
.single()
|
|
|
|
if (error) {
|
|
return NextResponse.json({ error: error.message }, { status: 500 })
|
|
}
|
|
|
|
// Fall back to companies.entity_type if company_settings.entity_type is null
|
|
let responseData = data
|
|
if (data && !data.entity_type) {
|
|
const { data: company } = await supabase
|
|
.from('companies')
|
|
.select('entity_type')
|
|
.eq('id', companyId)
|
|
.single()
|
|
if (company?.entity_type) {
|
|
responseData = { ...data, entity_type: company.entity_type }
|
|
}
|
|
}
|
|
|
|
return NextResponse.json({ data: responseData })
|
|
},
|
|
)
|
|
|
|
export const PUT = withRouteContext(
|
|
'settings.update',
|
|
async (request, { supabase, companyId }) => {
|
|
// Fetch current settings to check for tax-relevant changes
|
|
const { data: oldSettings } = await supabase
|
|
.from('company_settings')
|
|
.select('entity_type, moms_period, f_skatt, vat_registered, vat_number, pays_salaries, fiscal_year_start_month, onboarding_complete, salary_vacation_year_basis, reminder_days_level_1, reminder_days_level_2, reminder_days_level_3')
|
|
.eq('company_id', companyId)
|
|
.single()
|
|
|
|
const validation = await validateBody(request, UpdateSettingsSchema)
|
|
if (!validation.success) return validation.response
|
|
const body = validation.data
|
|
|
|
const reminderDays = [
|
|
body.reminder_days_level_1 ?? oldSettings?.reminder_days_level_1 ?? 15,
|
|
body.reminder_days_level_2 ?? oldSettings?.reminder_days_level_2 ?? 30,
|
|
body.reminder_days_level_3 ?? oldSettings?.reminder_days_level_3 ?? 45,
|
|
]
|
|
if (!(reminderDays[0] < reminderDays[1] && reminderDays[1] < reminderDays[2])) {
|
|
return NextResponse.json(
|
|
{ error: 'Påminnelsedagarna måste ligga i stigande ordning.' },
|
|
{ status: 400 },
|
|
)
|
|
}
|
|
|
|
// Lock org_number after onboarding is complete (legal identifier: changing it
|
|
// would orphan vouchers, SIE history, and tax filings). company_name remains
|
|
// editable so users can update their display/brand name (e.g. särskilt företagsnamn).
|
|
if (oldSettings && (oldSettings as Record<string, unknown>).onboarding_complete === true) {
|
|
delete (body as Record<string, unknown>).org_number
|
|
}
|
|
|
|
// Validate: enskild firma must use calendar year (BFL 3 kap.)
|
|
const effectiveEntityType = body.entity_type || oldSettings?.entity_type
|
|
const effectiveFYStartMonth = body.fiscal_year_start_month ?? oldSettings?.fiscal_year_start_month
|
|
if (effectiveEntityType === 'enskild_firma' && effectiveFYStartMonth && effectiveFYStartMonth !== 1) {
|
|
return NextResponse.json(
|
|
{ error: 'Enskild firma måste använda kalenderår (BFL 3 kap.)' },
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
|
|
// Vacation year basis (payroll gap-closure 3.1): changing the boundary
|
|
// while OPEN vacation-ledger rows exist would orphan them (rows are keyed
|
|
// by vacation_year_start). Close the current year first.
|
|
if (
|
|
body.salary_vacation_year_basis !== undefined &&
|
|
body.salary_vacation_year_basis !==
|
|
(oldSettings as Record<string, unknown> | null)?.salary_vacation_year_basis
|
|
) {
|
|
const { count: openRows, error: openRowsError } = await supabase
|
|
.from('employee_vacation_balances')
|
|
.select('id', { count: 'exact', head: true })
|
|
.eq('company_id', companyId)
|
|
.eq('status', 'open')
|
|
// Fail closed: a failed check must not let the basis change through
|
|
// and orphan open vacation-ledger rows.
|
|
if (openRowsError) {
|
|
return NextResponse.json({ error: openRowsError.message }, { status: 500 })
|
|
}
|
|
if ((openRows ?? 0) > 0) {
|
|
return NextResponse.json(
|
|
{
|
|
error:
|
|
'Semesterårets basis kan inte ändras medan öppna semestersaldon finns. Stäng semesteråret först.',
|
|
},
|
|
{ status: 400 },
|
|
)
|
|
}
|
|
}
|
|
|
|
// Validate: VAT-registered must have VAT number (ML 11 kap. 8§) and moms period (SFL 26 kap.)
|
|
const effectiveVatRegistered = body.vat_registered ?? oldSettings?.vat_registered
|
|
if (effectiveVatRegistered === true) {
|
|
const effectiveVatNumber = body.vat_number ?? oldSettings?.vat_number
|
|
if (!effectiveVatNumber) {
|
|
return NextResponse.json(
|
|
{ error: 'Momsregistreringsnummer krävs när företaget är momsregistrerat (ML 11 kap. 8§)' },
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
const effectiveMomsPeriod = body.moms_period ?? oldSettings?.moms_period
|
|
if (!effectiveMomsPeriod) {
|
|
return NextResponse.json(
|
|
{ error: 'Momsperiod krävs när företaget är momsregistrerat (SFL 26 kap.)' },
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
}
|
|
|
|
const { data, error } = await supabase
|
|
.from('company_settings')
|
|
.update(body)
|
|
.eq('company_id', companyId)
|
|
.select()
|
|
.single()
|
|
|
|
if (error) {
|
|
if (error.code === 'PGRST116') {
|
|
return NextResponse.json({ error: 'Inställningarna hittades inte.' }, { status: 404 })
|
|
}
|
|
return NextResponse.json({ error: error.message }, { status: 500 })
|
|
}
|
|
|
|
// Regenerate tax deadlines when a tax-relevant field changed OR when the
|
|
// company has no system-generated deadlines yet. The latter is the common
|
|
// case: tax settings are filled at onboarding, so a later save with no
|
|
// tax-field change never triggered generation and the deadlines page stayed
|
|
// empty even though the settings were "filled in". Backfilling when the set
|
|
// is empty is safe: there is no existing progress/status to clobber.
|
|
const taxFieldsChanged = Boolean(oldSettings && didTaxFieldsChange(oldSettings, data))
|
|
let existingSystemDeadlineCount = 0
|
|
if (!taxFieldsChanged) {
|
|
const { count, error: countError } = await supabase
|
|
.from('deadlines')
|
|
.select('id', { count: 'exact', head: true })
|
|
.eq('company_id', companyId)
|
|
.eq('source', 'system')
|
|
// Fail safe: on a count error, assume deadlines already exist so we do
|
|
// NOT delete+regenerate on a transient failure (regeneration would reset
|
|
// is_completed/status). A non-zero placeholder keeps the self-heal off.
|
|
existingSystemDeadlineCount = countError ? 1 : (count ?? 0)
|
|
}
|
|
|
|
if (shouldRegenerateTaxDeadlines(taxFieldsChanged, existingSystemDeadlineCount)) {
|
|
try {
|
|
await regenerateTaxDeadlinesForUser(supabase, companyId, {
|
|
entity_type: data.entity_type,
|
|
moms_period: data.moms_period,
|
|
f_skatt: data.f_skatt,
|
|
vat_registered: data.vat_registered,
|
|
pays_salaries: data.pays_salaries ?? false,
|
|
fiscal_year_start_month: data.fiscal_year_start_month,
|
|
})
|
|
console.log('Tax deadlines regenerated after settings change')
|
|
} catch (err) {
|
|
console.error('Failed to regenerate tax deadlines:', err)
|
|
// Don't fail the settings update if deadline generation fails
|
|
}
|
|
}
|
|
|
|
return NextResponse.json({ data })
|
|
},
|
|
{ requireWrite: true },
|
|
)
|