From 5ac560ce41ceb240d9f5204ab6d7a89b81f4310a Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:55:41 +0200 Subject: [PATCH] fix: generate tax deadlines for the installed base + correct 2893 label carryover (#1029) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) * 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) * 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) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- DECISIONS.md | 2 + app/(dashboard)/deadlines/page.tsx | 76 ++++++-- app/api/settings/__tests__/route.test.ts | 52 ++++++ app/api/settings/route.ts | 25 ++- .../bookkeeping/CorrectionEntryDialog.tsx | 21 ++- .../correction-line-description.test.ts | 50 ++++++ .../correction-line-description.ts | 32 ++++ lib/tax/__tests__/deadline-generator.test.ts | 19 +- lib/tax/deadline-generator.ts | 47 +++-- messages/en.json | 10 +- messages/sv.json | 10 +- scripts/backfill-tax-deadlines.ts | 169 ++++++++++++++++++ 12 files changed, 482 insertions(+), 31 deletions(-) create mode 100644 components/bookkeeping/__tests__/correction-line-description.test.ts create mode 100644 components/bookkeeping/correction-line-description.ts create mode 100644 scripts/backfill-tax-deadlines.ts diff --git a/DECISIONS.md b/DECISIONS.md index 6033f7e9..c15cafd8 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -164,3 +164,5 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-07-15] Superseded the hard deletion part of the 2026-07-14 credit note draft decision: numbered credit note drafts are now retained as cancelled rows and reopened on retry so the KR series remains complete. [2026-07-15] Customer personnummer uses application field encryption with masked API and UI output rather than a database-only cipher: the existing key custody and AES-256-GCM implementation can protect values before they reach Postgres, while ordinary reads never expose the full identifier. [2026-07-15] Credit note creation uses a completion marker plus unique company guards instead of a large creation RPC: incomplete parents are never returned, concurrent requests converge, and all journal writes remain in the bookkeeping engine. + +[2026-07-16] Two bugs from one customer report (an AB). Bug 1 (acct 2893 showed 2393's "langfristig del" memo after an andringsverifikation): root cause = CorrectionEntryDialog never re-derived line_description on account change (JournalEntryForm does). Fixed forward via a pure helper (correction-line-description.ts) that refreshes the memo only when it is empty or still equals the prev account's name (preserves hand-typed memos). Chose NO prod data repair: the wrong memo sits on a POSTED verifikat (immutable per migration-017 trigger); it is cosmetic (account number + amounts correct, all reports key off the number); ~26 posted lines across 11 cos share this stale-echo pattern, all fix-forward only. Deferred the twin entry-level header fix (#1031). Bug 2 (auto tax-deadlines never appeared): root cause = generation only fired on a settings save where a TAX field CHANGED value (didTaxFieldsChange); settings are filled once at onboarding so re-saving generated nothing -> only 5/776 real cos had system deadlines. Chose count-based self-heal (regenerate when the company has 0 system deadlines) over always-regenerate, because generateTaxDeadlinesForUser deletes+reinserts and would reset is_completed/status on every unrelated save. Also wired the /deadlines empty-state to the existing (dead) /api/tax-deadlines/generate route, and fixed a 1000-row PostgREST cap in the annual cron. Backfilled 771 real cos with zero system deadlines via scripts/backfill-tax-deadlines.ts. Deferred moms_period=yearly config (#1030, 295 filers, largest VAT cohort): helarsmoms deadline (SFL 26 kap. 33-33b) depends on EU-trade status (no flag in CompanySettingsForDeadlines) and, for AB, the income-tax-return date. diff --git a/app/(dashboard)/deadlines/page.tsx b/app/(dashboard)/deadlines/page.tsx index d759805c..2cc48f84 100644 --- a/app/(dashboard)/deadlines/page.tsx +++ b/app/(dashboard)/deadlines/page.tsx @@ -8,8 +8,9 @@ import { useToast } from '@/components/ui/use-toast' import { ToastAction } from '@/components/ui/toast' import { DeadlineList } from '@/components/deadlines/DeadlineList' import { PageHeader } from '@/components/ui/page-header' +import { Button } from '@/components/ui/button' import { Skeleton } from '@/components/ui/skeleton' -import { AlertTriangle, ArrowRight, CalendarClock } from 'lucide-react' +import { AlertTriangle, ArrowRight, CalendarClock, Loader2 } from 'lucide-react' import { useCompany } from '@/contexts/CompanyContext' import { formatCurrency } from '@/lib/utils' import type { Deadline } from '@/types' @@ -23,6 +24,7 @@ export default function DeadlinesPage() { const [customers, setCustomers] = useState<{ id: string; name: string }[]>([]) const [overdueInvoices, setOverdueInvoices] = useState<{ count: number; total: number }>({ count: 0, total: 0 }) const [isLoading, setIsLoading] = useState(true) + const [isGenerating, setIsGenerating] = useState(false) const { toast } = useToast() const fetchData = useCallback(async () => { @@ -66,6 +68,42 @@ export default function DeadlinesPage() { fetchData() }, [fetchData]) + const handleGenerateSystemDeadlines = async () => { + setIsGenerating(true) + try { + const response = await fetch('/api/tax-deadlines/generate', { method: 'POST' }) + const result = await response.json() + + if (!response.ok) { + throw new Error(result.error || t('generate_failed_description')) + } + + if ((result.created ?? 0) === 0) { + // Nothing was generated: the tax settings are genuinely incomplete. + // Point the user to fill them in (the banner's settings link stays visible). + toast({ + title: t('generate_none_title'), + description: t('generate_none_description'), + }) + return + } + + toast({ + title: t('generate_success_title'), + description: t('generate_success_description', { count: result.created }), + }) + fetchData() + } catch (error) { + toast({ + title: t('generate_failed_title'), + description: error instanceof Error ? error.message : t('retry'), + variant: 'destructive', + }) + } finally { + setIsGenerating(false) + } + } + const handleDeadlineCreate = async ( data: Omit ) => { @@ -236,20 +274,30 @@ export default function DeadlinesPage() { {!hasSystemDeadlines && ( - -
-
- -

- {t('no_system_deadlines_title')} - - {t('no_system_deadlines_description')} - -

-
- +
+
+ +

+ {t('no_system_deadlines_title')} + + {t('no_system_deadlines_description')} + +

- +
+ + + {t('generate_open_settings')} + + +
+
)} {/* Overdue invoices alert */} diff --git a/app/api/settings/__tests__/route.test.ts b/app/api/settings/__tests__/route.test.ts index 4c7f0efb..ca73232a 100644 --- a/app/api/settings/__tests__/route.test.ts +++ b/app/api/settings/__tests__/route.test.ts @@ -22,9 +22,13 @@ vi.mock('@/lib/auth/require-write', () => ({ vi.mock('@/lib/tax/deadline-generator', () => ({ didTaxFieldsChange: vi.fn().mockReturnValue(false), regenerateTaxDeadlinesForUser: vi.fn().mockResolvedValue(undefined), + shouldRegenerateTaxDeadlines: vi.fn( + (changed: boolean, count: number) => changed || count === 0, + ), })) import { PUT } from '../route' +import { regenerateTaxDeadlinesForUser } from '@/lib/tax/deadline-generator' describe('PUT /api/settings', () => { beforeEach(() => { @@ -71,6 +75,7 @@ describe('PUT /api/settings', () => { enqueueMany([ { data: { entity_type: 'enskild_firma', onboarding_complete: false } }, // fetch oldSettings { data: { id: 's1', company_name: 'New Name' } }, // update ... returning + { data: null, count: 5 }, // deadlines count (has some -> no regen) ]) const request = createMockRequest('/api/settings', { @@ -103,6 +108,7 @@ describe('PUT /api/settings', () => { reminder_days_level_3: 35, }, }, + { data: null, count: 5 }, // deadlines count (has some -> no regen) ]) const request = createMockRequest('/api/settings', { @@ -126,6 +132,52 @@ describe('PUT /api/settings', () => { }) }) + it('regenerates tax deadlines when the company has none yet (self-heal)', async () => { + enqueueMany([ + { data: { entity_type: 'aktiebolag', onboarding_complete: true } }, // oldSettings + { + data: { + id: 's1', + entity_type: 'aktiebolag', + moms_period: 'quarterly', + f_skatt: true, + vat_registered: true, + pays_salaries: true, + fiscal_year_start_month: 1, + }, + }, // update + { data: null, count: 0 }, // no system deadlines -> self-heal generation + ]) + + const request = createMockRequest('/api/settings', { + method: 'PUT', + body: { f_skatt: true }, + }) + const response = await PUT(request, { params: Promise.resolve({}) }) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(200) + expect(vi.mocked(regenerateTaxDeadlinesForUser)).toHaveBeenCalledOnce() + }) + + it('does not regenerate tax deadlines when the company already has some', async () => { + enqueueMany([ + { data: { entity_type: 'aktiebolag', onboarding_complete: true } }, // oldSettings + { data: { id: 's1', entity_type: 'aktiebolag' } }, // update + { data: null, count: 12 }, // already has deadlines + ]) + + const request = createMockRequest('/api/settings', { + method: 'PUT', + body: { company_name: 'Unchanged Tax' }, + }) + const response = await PUT(request, { params: Promise.resolve({}) }) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(200) + expect(vi.mocked(regenerateTaxDeadlinesForUser)).not.toHaveBeenCalled() + }) + it('returns 400 when reminder thresholds are not increasing', async () => { enqueue({ data: { diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts index 40864825..0b8972f2 100644 --- a/app/api/settings/route.ts +++ b/app/api/settings/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from 'next/server' import { withRouteContext } from '@/lib/api/with-route-context' -import { didTaxFieldsChange, regenerateTaxDeadlinesForUser } from '@/lib/tax/deadline-generator' +import { didTaxFieldsChange, regenerateTaxDeadlinesForUser, shouldRegenerateTaxDeadlines } from '@/lib/tax/deadline-generator' import { validateBody } from '@/lib/api/validate' import { UpdateSettingsSchema } from '@/lib/api/schemas' @@ -139,8 +139,27 @@ export const PUT = withRouteContext( return NextResponse.json({ error: error.message }, { status: 500 }) } - // Check if tax-relevant fields changed and regenerate deadlines - if (oldSettings && didTaxFieldsChange(oldSettings, data)) { + // 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, diff --git a/components/bookkeeping/CorrectionEntryDialog.tsx b/components/bookkeeping/CorrectionEntryDialog.tsx index f14d574a..adb55d97 100644 --- a/components/bookkeeping/CorrectionEntryDialog.tsx +++ b/components/bookkeeping/CorrectionEntryDialog.tsx @@ -13,6 +13,7 @@ import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import AccountCombobox from '@/components/bookkeeping/AccountCombobox' import CorrectionPreview from '@/components/bookkeeping/CorrectionPreview' +import { nextLineDescriptionForAccountChange } from '@/components/bookkeeping/correction-line-description' import { useToast } from '@/components/ui/use-toast' import { getErrorMessage } from '@/lib/errors/get-error-message' import { Plus, Trash2 } from 'lucide-react' @@ -71,7 +72,25 @@ export default function CorrectionEntryDialog({ entry, open, onOpenChange, onCor } const updateLine = (index: number, field: keyof CorrectionLine, value: string) => { - setLines((prev) => prev.map((l, i) => (i === index ? { ...l, [field]: value } : l))) + setLines((prev) => + prev.map((l, i) => { + if (i !== index) return l + const next = { ...l, [field]: value } + // When the account changes, refresh the auto-filled description to the + // new account's name. Without this, a description carried over from the + // original entry (e.g. 2393 "Lån från närstående personer, långfristig + // del") stays stale on the newly chosen account (e.g. 2893, kortfristig). + if (field === 'account_number' && value) { + next.line_description = nextLineDescriptionForAccountChange( + l.line_description, + l.account_number, + value, + accounts, + ) + } + return next + }) + ) } const addLine = () => { diff --git a/components/bookkeeping/__tests__/correction-line-description.test.ts b/components/bookkeeping/__tests__/correction-line-description.test.ts new file mode 100644 index 00000000..44899d39 --- /dev/null +++ b/components/bookkeeping/__tests__/correction-line-description.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from 'vitest' +import { nextLineDescriptionForAccountChange } from '@/components/bookkeeping/correction-line-description' + +const ACCOUNTS = [ + { account_number: '2393', account_name: 'Lån från närstående personer, långfristig del' }, + { account_number: '2893', account_name: 'Skulder till närstående personer, kortfristig del' }, + { account_number: '1930', account_name: 'Företagskonto' }, +] + +describe('nextLineDescriptionForAccountChange', () => { + it('refreshes the carried-over description when switching 2393 -> 2893 (the reported bug)', () => { + // Original correction line pre-filled from 2393 with 2393's name. + const result = nextLineDescriptionForAccountChange( + 'Lån från närstående personer, långfristig del', + '2393', + '2893', + ACCOUNTS, + ) + expect(result).toBe('Skulder till närstående personer, kortfristig del') + }) + + it('fills the description when the line had no description yet', () => { + expect(nextLineDescriptionForAccountChange('', '', '2893', ACCOUNTS)).toBe( + 'Skulder till närstående personer, kortfristig del', + ) + }) + + it('preserves a memo the user typed themselves', () => { + expect( + nextLineDescriptionForAccountChange('Återbetalning till Anna', '1930', '2893', ACCOUNTS), + ).toBe('Återbetalning till Anna') + }) + + it('leaves the description untouched when the new account is unknown', () => { + expect( + nextLineDescriptionForAccountChange('Lån från närstående personer, långfristig del', '2393', '9999', ACCOUNTS), + ).toBe('Lån från närstående personer, långfristig del') + }) + + it('returns the current description when the account is cleared', () => { + expect(nextLineDescriptionForAccountChange('Företagskonto', '1930', '', ACCOUNTS)).toBe('Företagskonto') + }) + + it('treats a description equal to the new account name as already correct', () => { + // Switching to an account whose name already matches: no visible change. + expect( + nextLineDescriptionForAccountChange('Skulder till närstående personer, kortfristig del', '2393', '2893', ACCOUNTS), + ).toBe('Skulder till närstående personer, kortfristig del') + }) +}) diff --git a/components/bookkeeping/correction-line-description.ts b/components/bookkeeping/correction-line-description.ts new file mode 100644 index 00000000..994607fa --- /dev/null +++ b/components/bookkeeping/correction-line-description.ts @@ -0,0 +1,32 @@ +/** + * Decide the line description to show when a correction line's account changes. + * + * The correction dialog pre-fills each line's description from the original + * entry. If the user then switches the account (e.g. from 2393 "Lån från + * närstående personer, långfristig del" to 2893, the kortfristig account), the + * carried-over description would otherwise stay stale on the new account. We + * refresh it to the newly chosen account's name, but only when the current + * description is empty or still equals the previously selected account's name: + * a memo the user typed themselves is preserved. + */ +export interface AccountNameLookup { + account_number: string + account_name: string +} + +export function nextLineDescriptionForAccountChange( + currentDescription: string, + previousAccountNumber: string, + newAccountNumber: string, + accounts: AccountNameLookup[], +): string { + if (!newAccountNumber) return currentDescription + + const newAccount = accounts.find((a) => a.account_number === newAccountNumber) + if (!newAccount) return currentDescription + + const previousAccount = accounts.find((a) => a.account_number === previousAccountNumber) + const isAutoFilled = !currentDescription || currentDescription === previousAccount?.account_name + + return isAutoFilled ? newAccount.account_name : currentDescription +} diff --git a/lib/tax/__tests__/deadline-generator.test.ts b/lib/tax/__tests__/deadline-generator.test.ts index b9cb4dc2..1723b4f3 100644 --- a/lib/tax/__tests__/deadline-generator.test.ts +++ b/lib/tax/__tests__/deadline-generator.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import type { SupabaseClient } from '@supabase/supabase-js' -import { generateTaxDeadlinesForUser } from '../deadline-generator' +import { generateTaxDeadlinesForUser, shouldRegenerateTaxDeadlines } from '../deadline-generator' import type { CompanySettingsForDeadlines } from '../deadline-config' const SETTINGS: CompanySettingsForDeadlines = { @@ -112,3 +112,20 @@ describe('generateTaxDeadlinesForUser', () => { expect(calls).not.toContain('delete') }) }) + +describe('shouldRegenerateTaxDeadlines', () => { + it('regenerates when a tax-relevant field changed', () => { + expect(shouldRegenerateTaxDeadlines(true, 42)).toBe(true) + }) + + it('regenerates when the company has no system deadlines yet, even with no field change', () => { + // The reported bug: settings were filled at onboarding, so a later save with + // no tax-field change never generated deadlines and the page stayed empty. + expect(shouldRegenerateTaxDeadlines(false, 0)).toBe(true) + }) + + it('does not regenerate when nothing changed and deadlines already exist', () => { + // Avoid clobbering existing status/progress on unrelated settings saves. + expect(shouldRegenerateTaxDeadlines(false, 12)).toBe(false) + }) +}) diff --git a/lib/tax/deadline-generator.ts b/lib/tax/deadline-generator.ts index b4207fe6..665d537c 100644 --- a/lib/tax/deadline-generator.ts +++ b/lib/tax/deadline-generator.ts @@ -4,6 +4,7 @@ import { SupabaseClient } from '@supabase/supabase-js' import { createLogger } from '@/lib/logger' +import { fetchAllRows } from '@/lib/supabase/fetch-all' import type { TaxDeadlineType, DeadlineStatus } from '@/types' const log = createLogger('deadline-generator') @@ -41,6 +42,23 @@ export function didTaxFieldsChange( return false } +/** + * Decide whether a settings save should (re)generate tax deadlines. + * + * Regenerate when a tax-relevant field changed OR when the company has no + * system-generated deadlines yet. The second case is the common one: tax + * settings are filled at onboarding, so a later save with no tax-field change + * used to skip generation entirely and the deadlines page stayed empty even + * though the settings were "filled in". Backfilling an empty set is safe: there + * is no existing status/progress to clobber. + */ +export function shouldRegenerateTaxDeadlines( + taxFieldsChanged: boolean, + existingSystemDeadlineCount: number +): boolean { + return taxFieldsChanged || existingSystemDeadlineCount === 0 +} + /** * Format date to YYYY-MM-DD */ @@ -240,20 +258,29 @@ export async function generateNewYearDeadlines( ): Promise<{ usersProcessed: number; totalCreated: number }> { const newYear = new Date().getFullYear() - // Fetch all companies with company settings - const { data: allSettings, error } = await supabase - .from('company_settings') - .select('company_id, entity_type, moms_period, f_skatt, vat_registered, pays_salaries, fiscal_year_start_month') - - if (error) { - log.error('Error fetching company settings:', error) - throw error - } + // Fetch all companies with company settings. Paginate: PostgREST silently + // caps a plain .select() at 1000 rows, which would leave companies beyond the + // cap without next-year deadlines every January. + const allSettings = await fetchAllRows<{ + company_id: string + entity_type: CompanySettingsForDeadlines['entity_type'] + moms_period: CompanySettingsForDeadlines['moms_period'] + f_skatt: boolean + vat_registered: boolean + pays_salaries: boolean | null + fiscal_year_start_month: number + }>(({ from, to }) => + supabase + .from('company_settings') + .select('company_id, entity_type, moms_period, f_skatt, vat_registered, pays_salaries, fiscal_year_start_month') + .order('company_id', { ascending: true }) + .range(from, to) + ) let usersProcessed = 0 let totalCreated = 0 - for (const settings of allSettings || []) { + for (const settings of allSettings) { try { const result = await generateTaxDeadlinesForUser( supabase, diff --git a/messages/en.json b/messages/en.json index 1b8b2f29..41806c73 100644 --- a/messages/en.json +++ b/messages/en.json @@ -511,7 +511,15 @@ "retry": "Please try again.", "overdue_invoices": "{count} overdue invoices", "no_system_deadlines_title": "Automatic tax deadlines missing.", - "no_system_deadlines_description": "Deadlines for VAT, employer declarations and F-tax are generated from your company's tax settings — make sure they are filled in." + "no_system_deadlines_description": "Deadlines for VAT, employer declarations and F-tax are generated from your company's tax settings — make sure they are filled in.", + "generate_action": "Generate now", + "generate_open_settings": "Open tax settings", + "generate_success_title": "Tax deadlines created", + "generate_success_description": "{count} deadlines were created from your tax settings.", + "generate_none_title": "No deadlines created", + "generate_none_description": "No new deadlines to create. Check that your tax settings are filled in if you expected more.", + "generate_failed_title": "Could not create deadlines", + "generate_failed_description": "Tax deadlines could not be created right now." }, "bureau": { "title": "Bureau", diff --git a/messages/sv.json b/messages/sv.json index 7a56ada0..7bb778c2 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -511,7 +511,15 @@ "retry": "Försök igen.", "overdue_invoices": "{count} förfallna fakturor", "no_system_deadlines_title": "Automatiska skattedeadlines saknas.", - "no_system_deadlines_description": "Deadlines för moms, arbetsgivardeklaration och F-skatt skapas från företagets skatteinställningar — kontrollera att de är ifyllda." + "no_system_deadlines_description": "Deadlines för moms, arbetsgivardeklaration och F-skatt skapas från företagets skatteinställningar — kontrollera att de är ifyllda.", + "generate_action": "Generera nu", + "generate_open_settings": "Öppna skatteinställningar", + "generate_success_title": "Skattedeadlines skapade", + "generate_success_description": "{count} deadlines skapades från dina skatteinställningar.", + "generate_none_title": "Inga deadlines skapades", + "generate_none_description": "Inga nya deadlines att skapa. Kontrollera att skatteinställningarna är ifyllda om du väntade dig fler.", + "generate_failed_title": "Kunde inte skapa deadlines", + "generate_failed_description": "Det gick inte att skapa skattedeadlines just nu." }, "bureau": { "title": "Byrå", diff --git a/scripts/backfill-tax-deadlines.ts b/scripts/backfill-tax-deadlines.ts new file mode 100644 index 00000000..55885ea3 --- /dev/null +++ b/scripts/backfill-tax-deadlines.ts @@ -0,0 +1,169 @@ +#!/usr/bin/env npx tsx +/** + * Backfill automatic tax deadlines for the installed base. + * + * Root cause (bug): tax deadlines only ever regenerated when a tax-relevant + * settings field CHANGED value (app/api/settings/route.ts, gated on + * didTaxFieldsChange). Companies fill these fields once at onboarding, so a + * later save changed nothing and generated nothing. The annual cron + * (generateNewYearDeadlines) is the only unconditional trigger and runs Jan 2, + * so the installed base sat empty. Result before this backfill: only ~5 of ~776 + * real companies had system-generated deadlines. + * + * This script runs the REAL generator (generateTaxDeadlinesForUser) so the + * backfilled rows are byte-identical to what the app produces. It targets ONLY + * non-sandbox companies that currently have ZERO source='system' deadlines, so + * it can never reset is_completed/status on a company that already has progress. + * + * Known gap (intentionally NOT covered here): moms_period='yearly' has no + * deadline config yet, so ~295 annual VAT filers will not get a momsdeklaration + * deadline until moms_yearly ships. Every other applicable deadline is created. + * + * Usage: + * npx tsx scripts/backfill-tax-deadlines.ts --dry-run # report only (default-safe) + * npx tsx scripts/backfill-tax-deadlines.ts --apply # write to the database + */ + +import { config } from 'dotenv' +config({ path: '.env.local' }) +import { createClient } from '@supabase/supabase-js' +import { generateTaxDeadlinesForUser } from '../lib/tax/deadline-generator' +import type { CompanySettingsForDeadlines } from '../lib/tax/deadline-config' + +const APPLY = process.argv.includes('--apply') +const DRY_RUN = !APPLY + +const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL +const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY + +if (!supabaseUrl || !serviceRoleKey) { + console.error('Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY in .env.local') + process.exit(1) +} + +const supabase = createClient(supabaseUrl, serviceRoleKey) + +const PAGE = 1000 + +interface SettingsRow { + company_id: string + entity_type: CompanySettingsForDeadlines['entity_type'] + moms_period: CompanySettingsForDeadlines['moms_period'] + f_skatt: boolean + vat_registered: boolean + pays_salaries: boolean | null + fiscal_year_start_month: number + is_sandbox: boolean | null +} + +async function fetchAllSettings(): Promise { + const rows: SettingsRow[] = [] + let from = 0 + for (;;) { + const { data, error } = await supabase + .from('company_settings') + .select('company_id, entity_type, moms_period, f_skatt, vat_registered, pays_salaries, fiscal_year_start_month, is_sandbox') + .order('company_id', { ascending: true }) + .range(from, from + PAGE - 1) + if (error) { + console.error('Failed to fetch company_settings', error) + process.exit(1) + } + if (!data || data.length === 0) break + rows.push(...(data as SettingsRow[])) + if (data.length < PAGE) break + from += PAGE + } + return rows +} + +async function fetchCompaniesWithSystemDeadlines(): Promise> { + const ids = new Set() + let from = 0 + for (;;) { + const { data, error } = await supabase + .from('deadlines') + .select('company_id') + .eq('source', 'system') + .order('company_id', { ascending: true }) + .range(from, from + PAGE - 1) + if (error) { + console.error('Failed to fetch companies with system deadlines', error) + process.exit(1) + } + if (!data || data.length === 0) break + for (const row of data as Array<{ company_id: string }>) ids.add(row.company_id) + if (data.length < PAGE) break + from += PAGE + } + return ids +} + +async function main() { + const all = await fetchAllSettings() + const realCompanies = all.filter((s) => !s.is_sandbox) + const withDeadlines = await fetchCompaniesWithSystemDeadlines() + + let scanned = 0 + let alreadyHad = 0 + let missingEntityType = 0 + let generatedCompanies = 0 + let generatedRows = 0 + const errors: Array<{ company_id: string; error: string }> = [] + + for (const s of realCompanies) { + scanned++ + + if (!s.entity_type) { + // Generator gates every config on entity_type; nothing to create. + missingEntityType++ + continue + } + + if (withDeadlines.has(s.company_id)) { + alreadyHad++ + continue + } + + const settings: CompanySettingsForDeadlines = { + entity_type: s.entity_type, + moms_period: s.moms_period, + f_skatt: s.f_skatt, + vat_registered: s.vat_registered, + pays_salaries: s.pays_salaries ?? false, + fiscal_year_start_month: s.fiscal_year_start_month, + } + + if (DRY_RUN) { + // In dry-run we cannot cheaply know the row count without inserting, so we + // just report the company as a target. + generatedCompanies++ + continue + } + + try { + const result = await generateTaxDeadlinesForUser(supabase, s.company_id, settings) + if (result.created > 0) { + generatedCompanies++ + generatedRows += result.created + } + } catch (err) { + errors.push({ company_id: s.company_id, error: err instanceof Error ? err.message : String(err) }) + } + } + + console.log(JSON.stringify({ + mode: DRY_RUN ? 'dry-run' : 'apply', + real_companies_scanned: scanned, + already_had_system_deadlines: alreadyHad, + skipped_missing_entity_type: missingEntityType, + companies_generated: generatedCompanies, + rows_generated: DRY_RUN ? null : generatedRows, + errors, + }, null, 2)) +} + +main().catch((err) => { + console.error(err) + process.exit(1) +})