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>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
0087b7be3f
commit
32d9978f1b
+186
-16
@@ -26,6 +26,28 @@ const timeString = z.string().regex(/^\d{2}:\d{2}(:\d{2})?$/, 'Expected HH:MM or
|
||||
|
||||
export const EntityTypeSchema = z.enum(['enskild_firma', 'aktiebolag'])
|
||||
|
||||
export const AccountingFrameworkSchema = z.enum(['k2', 'k3'])
|
||||
|
||||
/**
|
||||
* Single K3 component (BFNAR 2012:1 ch.17.4 — komponentavskrivning).
|
||||
*
|
||||
* Used inside AssetCreateSchema / AssetUpdateSchema's `k3_components` array.
|
||||
* The cross-component invariant (sum of `cost` equals asset `acquisition_cost`)
|
||||
* lives in `validateComponents` from `lib/bokslut/assets/k3-components.ts`
|
||||
* and is called by the route-layer refinement — it cannot be expressed in
|
||||
* a single-object schema. Component-level checks (cost > 0, salvage ≤ cost,
|
||||
* positive useful life) are reinforced by `validateComponents` too so any
|
||||
* future caller that uses just the validator gets the same guarantees.
|
||||
*
|
||||
* `salvage_value` is optional; the engine treats omission as 0.
|
||||
*/
|
||||
export const K3ComponentSchema = z.object({
|
||||
name: z.string().min(1, 'Komponentens namn krävs.'),
|
||||
cost: z.number().positive('Anskaffningsvärdet måste vara större än 0.'),
|
||||
useful_life_months: z.number().int().positive('Nyttjandeperioden måste vara ett positivt heltal månader.'),
|
||||
salvage_value: z.number().nonnegative().optional(),
|
||||
})
|
||||
|
||||
export const CustomerTypeSchema = z.enum([
|
||||
'individual',
|
||||
'swedish_business',
|
||||
@@ -103,6 +125,7 @@ export const JournalEntrySourceTypeSchema = z.enum([
|
||||
'supplier_invoice_privately_paid',
|
||||
'supplier_credit_note',
|
||||
'currency_revaluation',
|
||||
'reminder_fee',
|
||||
])
|
||||
|
||||
export const AccountTypeSchema = z.enum([
|
||||
@@ -156,6 +179,14 @@ export const CreateInvoiceItemSchema = z.object({
|
||||
unit: z.string().min(1, 'Unit is required'),
|
||||
unit_price: z.number(),
|
||||
vat_rate: z.number().min(0).max(100).optional(),
|
||||
// ROT/RUT-avdrag fields. `deduction_amount` is intentionally omitted from
|
||||
// the client schema — the API computes it from rot-rut-rules.ts so a
|
||||
// tampered client can't expand the 1513 receivable beyond the line total.
|
||||
deduction_type: z.enum(['rot', 'rut']).nullable().optional(),
|
||||
labor_hours: z.number().nonnegative().nullable().optional(),
|
||||
work_type: z.string().max(64).nullable().optional(),
|
||||
housing_designation: z.string().max(128).nullable().optional(),
|
||||
apartment_number: z.string().max(32).nullable().optional(),
|
||||
})
|
||||
|
||||
const optionalIsoDate = isoDate.or(z.literal('')).transform(v => v || undefined).optional()
|
||||
@@ -170,6 +201,13 @@ export const CreateInvoiceSchema = z.object({
|
||||
your_reference: z.string().optional(),
|
||||
our_reference: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
// ROT/RUT claim info. The personnummer is plaintext on the wire and gets
|
||||
// encrypted server-side before it ever hits the DB (see encryptPersonnummer
|
||||
// in lib/salary/personnummer.ts). `deduction_housing_designation` is the
|
||||
// fastighetsbeteckning at invoice level — required when any ROT item is
|
||||
// present (enforced via rot-rut-rules.validateInvoice in the API).
|
||||
deduction_personnummer: z.string().max(20).optional(),
|
||||
deduction_housing_designation: z.string().max(128).optional(),
|
||||
items: z.array(CreateInvoiceItemSchema).min(1, 'At least one item is required'),
|
||||
})
|
||||
|
||||
@@ -515,6 +553,16 @@ export const UpdateSettingsSchema = z.object({
|
||||
auto_lock_period_days: z.number().int().positive().nullable().optional(),
|
||||
// Voucher series
|
||||
default_voucher_series: z.string().regex(/^[A-Z]$/, 'Verifikationsserie måste vara en bokstav A–Z').optional(),
|
||||
// Per-source-type voucher series map. Keys are journal_entries.source_type
|
||||
// values; values are single uppercase letters A–Z. Read by the engine
|
||||
// (`createDraftEntry`) when no explicit voucher_series is passed, with a
|
||||
// fallback to 'A' for unknown keys.
|
||||
default_voucher_series_per_source_type: z
|
||||
.record(
|
||||
JournalEntrySourceTypeSchema,
|
||||
z.string().regex(/^[A-Z]$/, 'Verifikationsserie måste vara en bokstav A–Z'),
|
||||
)
|
||||
.optional(),
|
||||
// Invoice PDF settings
|
||||
ore_rounding: z.boolean().optional(),
|
||||
invoice_show_ocr: z.boolean().optional(),
|
||||
@@ -526,8 +574,36 @@ export const UpdateSettingsSchema = z.object({
|
||||
invoice_company_name_position: z.enum(['header', 'footer']).optional(),
|
||||
invoice_late_fee_text: z.string().nullable().optional(),
|
||||
invoice_credit_terms_text: z.string().nullable().optional(),
|
||||
// Invoice branding — colors enforced as #RRGGBB at the DB level too
|
||||
// (see migration 20260526120200_invoice_branding.sql). The dedicated
|
||||
// /api/settings/invoicing/branding route is the primary path; these
|
||||
// entries let the generic PUT /api/settings also accept the same fields.
|
||||
invoice_primary_color: z
|
||||
.string()
|
||||
.regex(/^#[0-9A-Fa-f]{6}$/, 'Ange en giltig hex-färg (#RRGGBB)')
|
||||
.optional(),
|
||||
invoice_accent_color: z
|
||||
.string()
|
||||
.regex(/^#[0-9A-Fa-f]{6}$/, 'Ange en giltig hex-färg (#RRGGBB)')
|
||||
.optional(),
|
||||
invoice_font_family: z.enum(['Helvetica', 'Times-Roman', 'Courier']).optional(),
|
||||
invoice_header_text: z.string().max(200).nullable().optional(),
|
||||
invoice_footer_text: z.string().max(500).nullable().optional(),
|
||||
// Automation
|
||||
send_invoice_reminders: z.boolean().optional(),
|
||||
// Reminder surcharges (dröjsmålsränta + lagstadgad påminnelseavgift)
|
||||
reminder_fee_enabled: z.boolean().optional(),
|
||||
reminder_fee_amount: z
|
||||
.number()
|
||||
.min(0, 'Påminnelseavgift kan inte vara negativ')
|
||||
.max(60, 'Lagstadgad maxgräns för påminnelseavgift är 60 kr (Lag 1981:739)')
|
||||
.optional(),
|
||||
reminder_interest_rate_override: z
|
||||
.number()
|
||||
.min(0, 'Räntesats kan inte vara negativ')
|
||||
.max(0.9999, 'Ange räntesatsen som en decimal mindre än 1 (t.ex. 0.115 för 11,5%)')
|
||||
.nullable()
|
||||
.optional(),
|
||||
// AI agent flow
|
||||
ai_flow_enabled: z.boolean().optional(),
|
||||
// Salary payment file
|
||||
@@ -830,11 +906,14 @@ export const VacationRuleSchema = z.enum(['procentregeln', 'sammaloneregeln', 'n
|
||||
export const SalaryRunStatusSchema = z.enum(['draft', 'review', 'approved', 'paid', 'booked', 'corrected'])
|
||||
|
||||
export const SalaryLineItemTypeSchema = z.enum([
|
||||
'monthly_salary', 'hourly_salary', 'overtime', 'bonus', 'commission',
|
||||
'monthly_salary', 'hourly_salary',
|
||||
'overtime', 'overtime_50', 'overtime_100',
|
||||
'ob_weekday_evening', 'ob_weekend', 'ob_night', 'ob_holiday',
|
||||
'bonus', 'commission',
|
||||
'gross_deduction_pension', 'gross_deduction_other',
|
||||
'benefit_car', 'benefit_housing', 'benefit_meals', 'benefit_wellness', 'benefit_bike', 'benefit_other',
|
||||
'sick_karens', 'sick_day2_14', 'sick_day15_plus',
|
||||
'vab', 'parental_leave', 'vacation',
|
||||
'vab', 'parental_leave', 'vacation', 'semesterersattning',
|
||||
'traktamente_taxfree', 'traktamente_taxable',
|
||||
'mileage_taxfree', 'mileage_taxable',
|
||||
'net_deduction_advance', 'net_deduction_union', 'net_deduction_benefit_payment',
|
||||
@@ -1117,12 +1196,25 @@ export const AbsenceRangeQuerySchema = z.object({
|
||||
// same calendar UX, half-day mixing with absence enforced by the 24h cap
|
||||
// trigger. The calculator sums these per pay period at calculate time.
|
||||
|
||||
export const UpsertWorkedDaySchema = z.object({
|
||||
work_date: isoDate,
|
||||
hours: z.number().positive().max(24).default(8),
|
||||
notes: z.string().max(2000).optional(),
|
||||
salary_run_employee_id: uuid.optional(),
|
||||
})
|
||||
export const UpsertWorkedDaySchema = z
|
||||
.object({
|
||||
work_date: isoDate,
|
||||
hours: z.number().positive().max(24).default(8),
|
||||
notes: z.string().max(2000).optional(),
|
||||
salary_run_employee_id: uuid.optional(),
|
||||
// Optional shift window. Feeds the shift-premium engine — without explicit
|
||||
// times, the engine assumes a default 08:00–17:00 day shift. Either both
|
||||
// fields are provided or neither.
|
||||
start_time: timeString.optional(),
|
||||
end_time: timeString.optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) => (data.start_time == null && data.end_time == null) || (data.start_time != null && data.end_time != null),
|
||||
{
|
||||
message: 'Ange både start- och sluttid eller låt båda vara tomma',
|
||||
path: ['start_time'],
|
||||
},
|
||||
)
|
||||
|
||||
export const WorkedHoursRangeQuerySchema = z.object({
|
||||
from: isoDate,
|
||||
@@ -1132,14 +1224,26 @@ export const WorkedHoursRangeQuerySchema = z.object({
|
||||
path: ['from'],
|
||||
})
|
||||
|
||||
export const BatchUpsertWorkedDaysSchema = z.object({
|
||||
// 100-row sanity cap: typical use is one pay period (~22 weekdays). A larger
|
||||
// value usually indicates the caller is iterating wrong.
|
||||
dates: z.array(isoDate).min(1).max(100),
|
||||
hours: z.number().positive().max(24).default(8),
|
||||
notes: z.string().max(2000).optional(),
|
||||
salary_run_employee_id: uuid.optional(),
|
||||
})
|
||||
export const BatchUpsertWorkedDaysSchema = z
|
||||
.object({
|
||||
// 100-row sanity cap: typical use is one pay period (~22 weekdays). A larger
|
||||
// value usually indicates the caller is iterating wrong.
|
||||
dates: z.array(isoDate).min(1).max(100),
|
||||
hours: z.number().positive().max(24).default(8),
|
||||
notes: z.string().max(2000).optional(),
|
||||
salary_run_employee_id: uuid.optional(),
|
||||
// Optional shift window applied to every date in the batch. Pair both or
|
||||
// neither; same fallback behaviour as the single-row endpoint.
|
||||
start_time: timeString.optional(),
|
||||
end_time: timeString.optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) => (data.start_time == null && data.end_time == null) || (data.start_time != null && data.end_time != null),
|
||||
{
|
||||
message: 'Ange både start- och sluttid eller låt båda vara tomma',
|
||||
path: ['start_time'],
|
||||
},
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
// AI agent flow schemas
|
||||
@@ -1228,3 +1332,69 @@ export const ListProposalsQuerySchema = z.object({
|
||||
export const AttachDocumentSchema = z.object({
|
||||
document_id: uuid,
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// Shift-premium rules (OB-tillägg och övertid)
|
||||
// ============================================================
|
||||
|
||||
export const ShiftPremiumItemTypeSchema = z.enum([
|
||||
'overtime_50',
|
||||
'overtime_100',
|
||||
'ob_weekday_evening',
|
||||
'ob_weekend',
|
||||
'ob_night',
|
||||
'ob_holiday',
|
||||
])
|
||||
|
||||
const dayOfWeekArray = z
|
||||
.array(z.number().int().min(1).max(7))
|
||||
.min(1, 'Välj minst en veckodag')
|
||||
.max(7, 'Högst sju veckodagar tillåtna')
|
||||
|
||||
export const CreateShiftPremiumRuleSchema = z
|
||||
.object({
|
||||
name: z.string().min(1).max(120),
|
||||
applies_to_all_employees: z.boolean().default(true),
|
||||
applies_to_employee_ids: z.array(uuid).default([]),
|
||||
day_of_week: dayOfWeekArray,
|
||||
start_time: timeString,
|
||||
end_time: timeString,
|
||||
premium_percent: z.number().min(0).max(500),
|
||||
item_type: ShiftPremiumItemTypeSchema,
|
||||
priority: z.number().int().min(0).max(1000).default(0),
|
||||
is_active: z.boolean().default(true),
|
||||
})
|
||||
.refine(
|
||||
(data) => data.applies_to_all_employees || data.applies_to_employee_ids.length > 0,
|
||||
{
|
||||
message: 'Välj minst en anställd när regeln inte gäller alla',
|
||||
path: ['applies_to_employee_ids'],
|
||||
},
|
||||
)
|
||||
|
||||
export const UpdateShiftPremiumRuleSchema = z
|
||||
.object({
|
||||
name: z.string().min(1).max(120).optional(),
|
||||
applies_to_all_employees: z.boolean().optional(),
|
||||
applies_to_employee_ids: z.array(uuid).optional(),
|
||||
day_of_week: dayOfWeekArray.optional(),
|
||||
start_time: timeString.optional(),
|
||||
end_time: timeString.optional(),
|
||||
premium_percent: z.number().min(0).max(500).optional(),
|
||||
item_type: ShiftPremiumItemTypeSchema.optional(),
|
||||
priority: z.number().int().min(0).max(1000).optional(),
|
||||
is_active: z.boolean().optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.applies_to_all_employees === false && data.applies_to_employee_ids !== undefined) {
|
||||
return data.applies_to_employee_ids.length > 0
|
||||
}
|
||||
return true
|
||||
},
|
||||
{
|
||||
message: 'Välj minst en anställd när regeln inte gäller alla',
|
||||
path: ['applies_to_employee_ids'],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user