Files
accounted/lib/bookkeeping/reminder-fee-entries.ts
T
MattssonandClaude Opus 4.7 32d9978f1b Fix/chrome pdf preview csp (#572)
* feat: add option to exclude year-end closing entries in SIE export and related reports

* delete docs

* fix: allow Chrome's PDF viewer in verifikat document preview

The /api/documents/:id/inline route shipped with
`object-src 'none'` in its CSP, which blocked Chrome's built-in PDF
viewer (it renders inline PDFs via an internal <embed>). Users on
Chrome saw "Det här innehållet har blockerats" when expanding a PDF
attachment in the bookkeeping view; Firefox (PDF.js) and Edge (own
viewer) were unaffected, and JPGs worked because <img> isn't subject
to object-src.

Drops the CSP for this route to the minimum needed for embeddability:
`frame-ancestors 'self'`. X-Content-Type-Options: nosniff plus the
fixed Content-Type from the handler already block MIME confusion;
X-Frame-Options: SAMEORIGIN + frame-ancestors still block clickjacking.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(auth): add webmail deep link to email confirmation screens

Mirrors Stripe's signup UX: after asking the user to verify their email,
detect their webmail provider from the domain and show a button that
opens the inbox in a new tab. Gmail gets a from:<sender> search
pre-populated; Outlook/Yahoo/iCloud/Proton open the inbox directly.
Unknown / custom domains fall back to the existing copy.

Sender address is configurable via NEXT_PUBLIC_BRANDING_AUTH_EMAIL_FROM
(default noreply@gnubok.se) so white-label installs can match their
Supabase Auth SMTP config.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(auth): unblock first-time password set for BankID users with MFA

Supabase rejects updateUser({password}) and mfa.unenroll with "AAL2 session
is required" whenever a TOTP factor is enrolled. BankID magic-link logins
produce AAL1, and middleware skips MFA enforcement for bankid_linked users,
so they had no path to AAL2 — leaving them unable to set a backup password
or disable MFA without going through the email-recovery escape hatch.

- /api/account/password: branch on app_metadata.has_password. First-time set
  writes via service.auth.admin.updateUserById (no existing credential to
  protect, AAL2 guard does not apply). Change-password keeps the user-session
  updateUser so AAL2 still fires for credential rotation.
- /mfa/verify: accept a safeReturnTo query param and route there after
  successful verify, so step-up flows can land back where they came from.
- SecuritySettings: detect the AAL2 error from both change-password and
  mfa.unenroll and redirect through /mfa/verify?returnTo=/settings/account
  instead of toasting a dead-end error.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Add tests and rounding utility for öre precision in bokslut calculations

- Implemented `roundOre` function for rounding SEK amounts to two decimal places, ensuring consistent monetary calculations.
- Introduced `ORE_TOLERANCE` constant for comparing rounded amounts, facilitating invariant checks in financial entries.
- Created comprehensive tests for `roundOre`, covering typical cases, edge cases, and idempotency.
- Added year-end invariants tests to verify database-level guarantees for closing entries, ensuring they balance to the öre and reject discrepancies.
- Developed end-to-end tests for the dispositions chain, validating the correctness of calculations across various scenarios.

* fix: update PDF rendering to remove Swish QR code generation and set default to disable Swish visibility

* fix: enhance security by rejecting data URIs in safeReturnTo function tests

* fix: improve rounding logic in roundOre function and add customer_type migration

* fix: add customer_type column to customers and enforce CHECK constraint

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 22:29:41 +02:00

116 lines
4.0 KiB
TypeScript

/**
* Journal entry generator for lagstadgad påminnelseavgift (statutory
* reminder fee, default 60 kr per Lag 1981:739).
*
* Booking convention:
* Debit 1510 Kundfordringar (the customer now owes the fee)
* Credit 3990 Övriga ersättningar, bidrag och intäkter
*
* Account choice rationale:
* - 1510 is the existing AR account already debited when the invoice was
* issued. Adding the fee on the same account keeps the customer's
* open balance accurate and matches Skatteverket / Kronofogden practice
* (one accumulated claim per customer).
* - 3990 (Övriga ersättningar, bidrag och intäkter) is the BAS 2026
* "miscellaneous operating revenue" bucket. Skatteverket guidance:
* reminder fees are not interest income (8313) but administrative
* compensation — so they sit in the 39xx group, not 83xx.
*
* Notes:
* - We deliberately do NOT book the dröjsmålsränta (late-payment interest)
* on reminder send. Interest is recognised when the customer pays it
* (revenue should not be recognised on a contingent claim).
* - Source type is 'reminder_fee' (see migration
* 20260526120300_drojsmalsranta_paminnelseavgift.sql which adds it
* to the journal_entries.source_type CHECK constraint).
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import { createJournalEntry, findFiscalPeriod } from './engine'
import { createLogger } from '@/lib/logger'
import type { CreateJournalEntryInput, JournalEntry } from '@/types'
const log = createLogger('bookkeeping.reminder-fee')
export interface CreateReminderFeeEntryInput {
/** Invoice the reminder relates to. Used for description and source_id linkage. */
invoiceId: string
/** Invoice number for the description (e.g. "F2026001"). */
invoiceNumber: string
/** Company that owns the invoice. */
companyId: string
/** User initiating the booking (for journal_entries.user_id audit trail). */
userId: string
/** Fee amount in SEK (≥ 0). Default per Lag 1981:739 is 60 kr. */
feeAmount: number
/** Date used as entry_date (typically the reminder send date). */
asOfDate: string
}
export interface CreateReminderFeeEntryResult {
journal_entry_id: string
}
/**
* Book the statutory påminnelseavgift as a journal entry.
*
* Returns the new journal_entry_id on success. Returns `null` if no
* open fiscal period exists for `asOfDate` (the caller should treat
* this as "skip booking, log a warning, continue sending the email").
*
* Throws on hard failures (account missing from chart, period locked,
* balance trigger rejection). Callers wrap in try/catch so a single
* failed posting doesn't abort the cron batch.
*/
export async function createReminderFeeEntry(
supabase: SupabaseClient,
input: CreateReminderFeeEntryInput,
): Promise<CreateReminderFeeEntryResult | null> {
const { invoiceId, invoiceNumber, companyId, userId, feeAmount, asOfDate } = input
if (feeAmount <= 0) {
log.info('skipping reminder fee booking — feeAmount is zero', {
invoiceId,
companyId,
})
return null
}
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId, asOfDate)
if (!fiscalPeriodId) {
log.warn('no open fiscal period for reminder fee', {
invoiceId,
companyId,
asOfDate,
})
return null
}
const rounded = Math.round(feeAmount * 100) / 100
const description = `Påminnelseavgift faktura ${invoiceNumber}`
const entryInput: CreateJournalEntryInput = {
fiscal_period_id: fiscalPeriodId,
entry_date: asOfDate,
description,
source_type: 'reminder_fee',
source_id: invoiceId,
lines: [
{
account_number: '1510',
debit_amount: rounded,
credit_amount: 0,
line_description: description,
},
{
account_number: '3990',
debit_amount: 0,
credit_amount: rounded,
line_description: description,
},
],
}
const entry: JournalEntry = await createJournalEntry(supabase, companyId, userId, entryInput)
return { journal_entry_id: entry.id }
}