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) +})