From aa405b9a74b3ff47d44701fe3eb79a9e74419f7a Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Fri, 10 Apr 2026 11:02:28 +0200 Subject: [PATCH] Fix/company creation bug (#212) * feat: enhance JournalEntryForm with currency selection and exchange rate fetching - Added currency selection to JournalEntryForm, allowing users to choose from multiple currencies (SEK, EUR, USD, GBP, NOK, DKK). - Implemented fetching of exchange rates from Riksbanken API based on selected currency and entry date. - Updated calculations for foreign amounts and SEK equivalents based on user input and fetched exchange rates. - Improved form handling to reset currency-related fields when switching back to SEK. feat: refactor WelcomeOnboarding to streamline company creation process - Replaced direct company switching with a new server action to create a company from onboarding data. - Added validation for fiscal period during onboarding steps, allowing for mid-month starts for the first fiscal period. - Enhanced error handling and rollback mechanisms to ensure data integrity during company creation. fix: update Step3TaxRegistration to allow flexible first-year start dates - Modified date selection to include day, month, and year for the first-year start date. - Updated validation messages to reflect changes in fiscal year start date handling. test: expand validate-period-duration tests for fiscal period validation - Added tests to validate that mid-month starts are allowed for the first fiscal period. - Ensured that subsequent periods must start on the 1st of the month and enforced maximum duration constraints. feat: implement currency rate API endpoint - Created a new API route to fetch exchange rates for specified currencies, ensuring user authentication. - Validated currency input and handled errors for invalid requests. chore: update database constraints for fiscal periods - Modified database constraints to allow custom start dates for the first fiscal period while enforcing day-1 starts for subsequent periods. * fix: implement computeFiscalPeriod function for onboarding and refactor JournalEntryForm * Fixed date issue * Added migration --- app/(dashboard)/bookkeeping/[id]/page.tsx | 25 ++ .../bookkeeping/fiscal-periods/[id]/route.ts | 18 +- app/api/bookkeeping/fiscal-periods/route.ts | 15 +- app/api/currency/rate/route.ts | 31 ++ app/companies/new/page.tsx | 301 +++------------- components/bookkeeping/JournalEntryForm.tsx | 162 ++++++++- components/dashboard/WelcomeOnboarding.tsx | 324 +++--------------- .../onboarding/Step3TaxRegistration.tsx | 66 +++- .../validate-period-duration.test.ts | 32 +- lib/bookkeeping/validate-period-duration.ts | 14 +- lib/company/actions.ts | 123 +++++++ lib/company/compute-fiscal-period.ts | 68 ++++ lib/core/bookkeeping/period-service.ts | 4 +- ...allow_custom_first_fiscal_period_start.sql | 36 ++ 14 files changed, 664 insertions(+), 555 deletions(-) create mode 100644 app/api/currency/rate/route.ts create mode 100644 lib/company/compute-fiscal-period.ts create mode 100644 supabase/migrations/20260409165300_allow_custom_first_fiscal_period_start.sql diff --git a/app/(dashboard)/bookkeeping/[id]/page.tsx b/app/(dashboard)/bookkeeping/[id]/page.tsx index bdaa9a16..61a190f1 100644 --- a/app/(dashboard)/bookkeeping/[id]/page.tsx +++ b/app/(dashboard)/bookkeeping/[id]/page.tsx @@ -80,6 +80,13 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i const totalDebit = lines.reduce((sum, l) => sum + (Number(l.debit_amount) || 0), 0) const totalCredit = lines.reduce((sum, l) => sum + (Number(l.credit_amount) || 0), 0) + const foreignLines = lines.filter(l => l.currency && l.currency !== 'SEK' && l.amount_in_currency != null) + const hasForeignCurrency = foreignLines.length > 0 + // For the summary: use the first foreign line's data (the settlement line) + const foreignCurrency = hasForeignCurrency ? foreignLines[0].currency! : null + const foreignTotal = hasForeignCurrency ? Math.abs(Number(foreignLines[0].amount_in_currency) || 0) : 0 + const foreignExchangeRate = hasForeignCurrency ? (Number(foreignLines[0].exchange_rate) || null) : null + const canCorrect = entry.status === 'posted' && entry.source_type !== 'storno' && @@ -168,6 +175,24 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i Antal rader {lines.length} + {hasForeignCurrency && ( + <> +
+ {computedForeignAmount.toLocaleString('sv-SE', { minimumFractionDigits: 2 })} {entryCurrency} × {rate.toLocaleString('sv-SE', { minimumFractionDigits: 4 })} = {computedSekAmount.toLocaleString('sv-SE', { minimumFractionDigits: 2 })} SEK +
+ )} + > + )} +- Månaden verksamheten startade. Räkenskapsåret börjar alltid den 1:a. + Datumet företaget registrerades. Första räkenskapsåret kan börja valfri dag.
{errors.first_year_start && ({errors.first_year_start.message}
diff --git a/lib/bookkeeping/__tests__/validate-period-duration.test.ts b/lib/bookkeeping/__tests__/validate-period-duration.test.ts index 4a7ddb1d..10e64fd2 100644 --- a/lib/bookkeeping/__tests__/validate-period-duration.test.ts +++ b/lib/bookkeeping/__tests__/validate-period-duration.test.ts @@ -48,12 +48,42 @@ describe('validatePeriodDuration', () => { ) }) - it('returns error when start is not 1st of month', () => { + it('returns error when start is not 1st of month (default)', () => { expect(validatePeriodDuration('2025-01-15', '2025-12-31')).toBe( 'Period start must be the 1st of a month' ) }) + it('returns error when start is not 1st of month (isFirstPeriod: false)', () => { + expect(validatePeriodDuration('2025-03-25', '2025-12-31', { isFirstPeriod: false })).toBe( + 'Period start must be the 1st of a month' + ) + }) + + it('allows mid-month start for first fiscal period', () => { + expect(validatePeriodDuration('2025-03-25', '2025-12-31', { isFirstPeriod: true })).toBeNull() + }) + + it('allows mid-month start for first period (October)', () => { + expect(validatePeriodDuration('2025-10-15', '2025-12-31', { isFirstPeriod: true })).toBeNull() + }) + + it('still allows day-1 start for first period', () => { + expect(validatePeriodDuration('2025-10-01', '2025-12-31', { isFirstPeriod: true })).toBeNull() + }) + + it('enforces end-of-month even for first period', () => { + expect(validatePeriodDuration('2025-03-25', '2025-12-15', { isFirstPeriod: true })).toBe( + 'Period end must be the last day of a month' + ) + }) + + it('enforces 18-month max for first period with mid-month start', () => { + const result = validatePeriodDuration('2025-01-15', '2026-12-31', { isFirstPeriod: true }) + expect(result).toContain('months') + expect(result).toContain('18 months') + }) + it('returns error when end is not last day of month', () => { expect(validatePeriodDuration('2025-01-01', '2025-12-15')).toBe( 'Period end must be the last day of a month' diff --git a/lib/bookkeeping/validate-period-duration.ts b/lib/bookkeeping/validate-period-duration.ts index 73b38329..e8a4f5fc 100644 --- a/lib/bookkeeping/validate-period-duration.ts +++ b/lib/bookkeeping/validate-period-duration.ts @@ -17,7 +17,8 @@ export function parseDateParts(dateStr: string): { year: number; month: number; /** * Calculate the number of months between two dates (inclusive of partial months). - * Assumes start is 1st of month and end is last of month. + * Uses year/month arithmetic only — a mid-month start counts the start month fully, + * which is conservative for the 18-month cap check. */ export function monthsBetween(start: string, end: string): number { const s = parseDateParts(start) @@ -25,11 +26,16 @@ export function monthsBetween(start: string, end: string): number { return (e.year - s.year) * 12 + (e.month - s.month) + 1 } +export interface ValidatePeriodOptions { + /** Allow any start day (not just 1st of month) for the first fiscal period per BFL 3 kap. */ + isFirstPeriod?: boolean +} + /** * Validate a fiscal period's duration and date constraints. * Returns null if valid, or an error message string if invalid. */ -export function validatePeriodDuration(start: string, end: string): string | null { +export function validatePeriodDuration(start: string, end: string, options?: ValidatePeriodOptions): string | null { const startParts = parseDateParts(start) const endParts = parseDateParts(end) @@ -38,8 +44,8 @@ export function validatePeriodDuration(start: string, end: string): string | nul return 'Period end must be after period start' } - // start must be 1st of month - if (startParts.day !== 1) { + // start must be 1st of month — unless this is the first fiscal period (BFL 3 kap.) + if (startParts.day !== 1 && !options?.isFirstPeriod) { return 'Period start must be the 1st of a month' } diff --git a/lib/company/actions.ts b/lib/company/actions.ts index ed1351fa..5685a2b7 100644 --- a/lib/company/actions.ts +++ b/lib/company/actions.ts @@ -20,3 +20,126 @@ export async function switchCompany(companyId: string): Promise<{ error?: string return { error: 'Du har inte tillgång till detta företag.' } } } + +/** + * Create a company from onboarding wizard data. + * + * This runs on the server so that if the Next.js server is unavailable when + * the user clicks the final "Fortsätt" button, the action never reaches + * Supabase and no ghost company is created. All operations (company, + * membership, chart of accounts, settings, fiscal period, active company) + * happen sequentially; if any step after company creation fails the company + * is rolled back to avoid partial state. + */ +export async function createCompanyFromOnboarding(params: { + teamId: string + settings: Record