32d9978f1b
* 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>
178 lines
6.0 KiB
TypeScript
178 lines
6.0 KiB
TypeScript
/**
|
||
* Parses Swedish (and a few mixed Swedish/English) date-range patterns out
|
||
* of free-text invoice descriptions / line items so the periodisering wizard
|
||
* can auto-detect supplier invoices and customer invoices whose service
|
||
* window crosses the fiscal-period boundary.
|
||
*
|
||
* The function is intentionally conservative — it only returns a parse when
|
||
* BOTH a recognizable start and end date are present. Single dates, "från
|
||
* 2026-01-01" with no end, and malformed strings all return null. The auto-
|
||
* detect step later sets `confidence: 'low'` when the parser only caught a
|
||
* partial pattern (currently always "high" since partials return null —
|
||
* kept as a knob so the wizard UI can downgrade future heuristics without
|
||
* touching this file).
|
||
*
|
||
* Patterns supported (case-insensitive):
|
||
* 1. ISO : "period: 2026-01-01 till 2027-12-31"
|
||
* 2. ISO : "perioden 2026-01-01 - 2026-12-31"
|
||
* 3. Swede : "period: 1 jan 2026 - 31 dec 2026"
|
||
* 4. Swede : "jan 2026 till dec 2026" (whole-month range, expanded to 1st / last)
|
||
* 5. yyyy-mm: "2026-01 till 2027-12" (expanded to 1st of start / last of end)
|
||
* 6. Free : "giltig från 2026-01-01 till 2027-12-31"
|
||
*
|
||
* All return ISO `yyyy-mm-dd`. The function does no fiscal logic — it just
|
||
* reports the parsed window. The caller decides whether endDate > period_end.
|
||
*/
|
||
|
||
const SWEDISH_MONTHS: Record<string, number> = {
|
||
jan: 0,
|
||
januari: 0,
|
||
feb: 1,
|
||
februari: 1,
|
||
mar: 2,
|
||
mars: 2,
|
||
apr: 3,
|
||
april: 3,
|
||
maj: 4,
|
||
jun: 5,
|
||
juni: 5,
|
||
jul: 6,
|
||
juli: 6,
|
||
aug: 7,
|
||
augusti: 7,
|
||
sep: 8,
|
||
september: 8,
|
||
okt: 9,
|
||
oktober: 9,
|
||
nov: 10,
|
||
november: 10,
|
||
dec: 11,
|
||
december: 11,
|
||
}
|
||
|
||
const MONTH_NAMES_PATTERN = Object.keys(SWEDISH_MONTHS).join('|')
|
||
|
||
/** Separator tokens between the two dates: " till ", " - ", "–", "—". */
|
||
const SEP = '(?:\\s*[-–—]\\s*|\\s+till\\s+|\\s+t\\.?\\s*o\\.?\\s*m\\.?\\s+)'
|
||
|
||
/** ISO date `yyyy-mm-dd`. */
|
||
const ISO = '(\\d{4}-\\d{2}-\\d{2})'
|
||
|
||
/** yyyy-mm without day. */
|
||
const YM = '(\\d{4}-\\d{2})'
|
||
|
||
/** Swedish long form "1 jan 2026" — day optional. */
|
||
const SWE_LONG = `(?:(\\d{1,2})\\s+)?(${MONTH_NAMES_PATTERN})\\s+(\\d{4})`
|
||
|
||
function pad2(n: number): string {
|
||
return n < 10 ? `0${n}` : String(n)
|
||
}
|
||
|
||
/** Number of days in a (1-based) month/year, honoring leap years. */
|
||
function daysInMonth(year: number, monthZeroBased: number): number {
|
||
return new Date(Date.UTC(year, monthZeroBased + 1, 0)).getUTCDate()
|
||
}
|
||
|
||
function isoFromYMD(year: number, monthZeroBased: number, day: number): string {
|
||
return `${year}-${pad2(monthZeroBased + 1)}-${pad2(day)}`
|
||
}
|
||
|
||
/** "2026-01" → "2026-01-01"; "2026-02" with `lastDay=true` → "2026-02-28". */
|
||
function expandYearMonth(ym: string, lastDay: boolean): string | null {
|
||
const m = /^(\d{4})-(\d{2})$/.exec(ym)
|
||
if (!m) return null
|
||
const year = parseInt(m[1], 10)
|
||
const monthZero = parseInt(m[2], 10) - 1
|
||
if (monthZero < 0 || monthZero > 11) return null
|
||
const day = lastDay ? daysInMonth(year, monthZero) : 1
|
||
return isoFromYMD(year, monthZero, day)
|
||
}
|
||
|
||
/** Swedish long form ("1 jan 2026" or "jan 2026") → ISO. When the day is
|
||
* missing, anchor to the 1st (start side) or the last day of the month
|
||
* (end side). */
|
||
function expandSwedishLong(
|
||
day: string | undefined,
|
||
monthName: string,
|
||
year: string,
|
||
endSide: boolean,
|
||
): string | null {
|
||
const monthZero = SWEDISH_MONTHS[monthName.toLowerCase()]
|
||
if (monthZero === undefined) return null
|
||
const y = parseInt(year, 10)
|
||
if (Number.isNaN(y)) return null
|
||
if (day) {
|
||
const d = parseInt(day, 10)
|
||
if (d < 1 || d > daysInMonth(y, monthZero)) return null
|
||
return isoFromYMD(y, monthZero, d)
|
||
}
|
||
return isoFromYMD(y, monthZero, endSide ? daysInMonth(y, monthZero) : 1)
|
||
}
|
||
|
||
function validateRange(startDate: string, endDate: string): boolean {
|
||
// endDate must be strictly after startDate. A single date repeated isn't
|
||
// a range, just a point — the caller should treat that as "no range".
|
||
return endDate > startDate
|
||
}
|
||
|
||
export interface ParsedDateRange {
|
||
startDate: string // ISO yyyy-mm-dd
|
||
endDate: string // ISO yyyy-mm-dd
|
||
}
|
||
|
||
/**
|
||
* Attempt to extract a date range from a free-text description. Returns
|
||
* null if nothing recognizable is found.
|
||
*/
|
||
export function parseInvoiceDateRange(description: string | null | undefined): ParsedDateRange | null {
|
||
if (!description) return null
|
||
const text = description.toLowerCase()
|
||
|
||
// 1. ISO–ISO: "2026-01-01 till 2027-12-31", "2026-01-01 - 2027-12-31"
|
||
const isoRe = new RegExp(`${ISO}${SEP}${ISO}`, 'i')
|
||
const isoMatch = isoRe.exec(text)
|
||
if (isoMatch) {
|
||
const start = isoMatch[1]
|
||
const end = isoMatch[2]
|
||
if (isValidIso(start) && isValidIso(end) && validateRange(start, end)) {
|
||
return { startDate: start, endDate: end }
|
||
}
|
||
}
|
||
|
||
// 2. Swedish long form on both sides: "1 jan 2026 - 31 dec 2026"
|
||
const sweRe = new RegExp(`${SWE_LONG}${SEP}${SWE_LONG}`, 'i')
|
||
const sweMatch = sweRe.exec(text)
|
||
if (sweMatch) {
|
||
const startDate = expandSwedishLong(sweMatch[1], sweMatch[2], sweMatch[3], false)
|
||
const endDate = expandSwedishLong(sweMatch[4], sweMatch[5], sweMatch[6], true)
|
||
if (startDate && endDate && validateRange(startDate, endDate)) {
|
||
return { startDate, endDate }
|
||
}
|
||
}
|
||
|
||
// 3. yyyy-mm on both sides: "2026-01 till 2027-12"
|
||
// Guarded: don't allow a full ISO date here — anchor to space / start.
|
||
const ymRe = new RegExp(`(?:^|[^\\d-])${YM}${SEP}${YM}(?![\\d-])`, 'i')
|
||
const ymMatch = ymRe.exec(text)
|
||
if (ymMatch) {
|
||
const startDate = expandYearMonth(ymMatch[1], false)
|
||
const endDate = expandYearMonth(ymMatch[2], true)
|
||
if (startDate && endDate && validateRange(startDate, endDate)) {
|
||
return { startDate, endDate }
|
||
}
|
||
}
|
||
|
||
return null
|
||
}
|
||
|
||
function isValidIso(s: string): boolean {
|
||
if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return false
|
||
const [yStr, mStr, dStr] = s.split('-')
|
||
const year = parseInt(yStr, 10)
|
||
const monthZero = parseInt(mStr, 10) - 1
|
||
const day = parseInt(dStr, 10)
|
||
if (monthZero < 0 || monthZero > 11) return false
|
||
if (day < 1 || day > daysInMonth(year, monthZero)) return false
|
||
return true
|
||
}
|