* feat(salary): one-click AGI submission with filing state machine and success feedback The AGI panel required users to know that "Ladda ner AGI-fil" was the generate step, then click submit, signing link, and kvittens manually. A nollkorning filing stalled on "AGI-XML saknas" pointing at a UI path that does not exist. - New primary button "Lamna in till Skatteverket" chains the existing endpoints client-side: generate XML if missing, POST underlag, poll kontrollresultat, create signing link, open Mina Sidor in a tab opened synchronously at click (popup-blocker safe). Inline stepper shows each step; the four old buttons become collapsed advanced/recovery actions, auto-expanded in stale-draft and rejected states. XML download stays visible and free for manual filing. - deriveAgiFilingState() + useAgiSubmission() lift the per-period submission record to the run page: the progress rail and salary hero now render the real state machine (generated, underlag inskickat, vantar pa BankID-signatur, inlamnad med kvittensnummer) instead of telling users to "lamna in" an already-submitted declaration. - Success card with kvittensnummer and signature metadata once signed, plus a toast when a poll flips the state while the page is open. - AGI kvittens cron every 15 min instead of every 2 h so filings signed on another device get stamped and emailed promptly. - Advanced submit also auto-generates, and the stale "Lon -> AGI -> Generera" error text now points at the real buttons. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(enable-banking): instant OAuth callback feedback and dead-attempt cleanup The bank redirect landed on a blank page for the several seconds the callback spent exchanging the PSD2 session and mirroring accounts, and every failed connect attempt left a status='error' row that rendered forever as an "Atgard kravs" card next to a successful retry, showing duplicate connections to the same bank. - Stream a branded "Slutfor bankanslutningen" progress page from the callback: the shell flushes before the session exchange starts and a script/meta redirect follows when the work completes, with a 30s slow-work escape hatch. Fast outcomes (denial, bad params, unknown state) keep their plain redirects. - Delete never-activated connection rows (no session_id, no accounts_data) on denial or exchange failure, and sweep leftovers for the same bank on the next connect. Established connections keep their "Atgard krävs" card via the accounts_data guard; FKs are ON DELETE SET NULL so deletion has no dependents. - Show "Banken ar ansluten: hamtar dina konton" while the settings panel loads after the callback instead of an anonymous spinner. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(invoices): reject re-send of issued invoices and gate bookkeeping on the sent flip A direct POST to /api/invoices/[id]/send against an already-issued invoice re-emailed the customer and posted a second revenue verifikat (createInvoiceJournalEntry has no dedup), overwriting journal_entry_id and orphaning the first entry. Only the UI hid the button; the v1 route and the MCP commit executor already rejected non-drafts. - Non-draft invoices now return 409 INVOICE_ALREADY_SENT. - The draft to sent status flip is an optimistic lock (status guard plus row-count check); journal entry, accrual schedules, PDF archival and the invoice.sent event only run for the request that won the flip. - On a flip failure the journal entry is deferred: the row stays draft and a retry re-runs the pipeline, ending with exactly one verifikat. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(invoices): payment links, failure visibility and sandbox guard for recurring auto-send - sendInvoiceFromSchedule now auto-creates an online payment link via applyPaymentLinkToInvoice before rendering and passes the payment link QR to the PDF: parity with the dashboard and v1 send routes, which recurring invoices silently lacked. - The recurring cron persists last_run_warning both when a claimed run throws (hourly retries stay visible on the schedule) and when a stale schedule is rolled forward, so a deterministic failure can no longer skip a month silently. - Auto-send is blocked for sandbox companies at the email chokepoint (freeze-and-retain: the invoice is still generated as a draft), covering both the cron and the run-now route with one guard. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(salary): close the Fortnox payroll API gaps (phases 1-4) Payroll now runs end-to-end through the open API, including onboarding a client from another payroll system, with every write staged for approval. - v1: per-employee payslips (list/detail/PDF), payslip line writes, run roster attach/remove, absence ranges (per-day storage), jamkning fields, cutover opening balances (single + atomic bulk PUT), vacation balance + vacation-year-close. PUT added to the wrapper's idempotency/ test-key set (test keys could otherwise write through PUT). - MCP: 10 new tools (get_employee/get_payslip/list_absence/ get_vacation_balance reads + staged update_payslip_line, register_absence, create_employee, update_employee, set_employee_opening_balances, close_vacation_year), executors, risk tiers, op-type CHECK expansions. create_employee encrypts personnummer at staging: pending_operations never holds plaintext. - Scope-map audit retrofit: 11 formerly unmapped tools now scoped; BREAKING for keys that relied on the 4 default-allow writes. - Cutover: employee_opening_balances (derived lock trigger, self-unlocks on run correction), engine YTD/karens/liability integration, Ingaende saldon section in the employee editor. - Arbetsschema-lite: employees.hours_per_week/workdays_per_week drive the hourly/daily divisors; legacy 173/21 preserved exactly at defaults so existing pay math is byte-identical. - Vacation ledger + semesterberedning/arsavslut: recomputed per-year day balances (synced on book/correct, non-fatal), year-close with the min-20 floor, 5-year sparade-dagar expiry to forced payout, and a 2920/2940 drift adjustment via the bookkeeping engine; Semester dashboard card with preview-then-confirm dialog. - Fix: Zod 4 defaults leak through .partial(), which made every sparse employee PATCH fail validation and reset defaulted columns. Migrations 20260713100000/101000/110000/121000/122000 (applied to staging with version rows; prod via merge). vacation_ledger renamed from 20260713120000 to avoid colliding with vat_declaration_totals_rpc. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * perf: cut dashboard page-load latency (region, round trips, caching, VAT RPC) The dominant cost was infrastructure: Vercel functions ran in iad1 (Washington D.C.) while Supabase (DB + auth) lives in eu-north-1 (Stockholm), so every request paid 4-5 transatlantic round trips of auth + company resolution before doing any real work (measured 530-1900ms for single-query GETs in prod logs). Pin functions to arn1 and cut the redundant work on top: - vercel.json: functions to arn1, same city as the database - getActiveCompanyId: preference + first-membership queries run in parallel; the fallback result doubles as validation in the common single-company case (one round trip instead of two sequential) - withRouteContext: Server-Timing header and authMs/companyMs/handlerMs in the op-completed log, so latency is attributable per phase - dashboard layout: nav badge counts off the critical path; DashboardNav loads them client-side via the new use-worklist-badges SWR hook with debounced realtime revalidation - swr (new dependency, approved): global provider; useCompanySettings shares one cache entry across consumers and renders from cache on back-navigation instead of re-showing skeletons - /pending: realtime refetch debounced; bulk operations previously fired 4 requests per row-change event - VAT declaration: new get_vat_declaration_totals RPC returns per-account totals, settlement-shape detection (#984) and source_type counts in ONE round trip instead of paging every entry+line through PostgREST. Account lists stay TS-side parameters so ACCOUNT_RUTA remains the single source of truth. Shape-exclusion coverage moved to tests/pg/vat-declaration-totals-rpc.pg.test.ts; DDL already applied to staging. - bundle: CommandPalette lazy-mounts on first Ctrl/Cmd+K, AgentChat dynamic-imports the markdown parser, @vercel/speed-insights (new dependency, approved) added for real-user timings The /salary fetch-waterfall fix from the same effort already landed inside 2084a756. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(invoices): settle öre-rounded payments from the mark-paid flow An invoice with öresavrundning shows a rounded "Att betala" on the PDF; the customer pays that amount (up to 50 öre off the stored öre total) and the invoice-page mark-paid flow rejected it with MATCH_AMOUNT_EXCEEDS_REMAINING: a dead end, while the bank-transaction match flow already absorbed the residual to 3740. - PaymentBookingDialog now proposes the rounded bank leg plus the 3740 residual line (credit when rounded up, debit when rounded down), resolved via getDisplayTotal from the per-invoice override and company_settings.ore_rounding. - settleInvoicePayment and the v1 mark-paid route absorb the sub-krona residual, gated by planInvoicePaymentForLines: absorption applies ONLY when the caller lines carry the exact residual on 3740; otherwise the strict plan applies (sub-krona partials stay partial, no-3740 overshoots keep the 400), so the GL can never diverge from the AR sub-ledger. - planInvoicePayment absorb-band boundary tightened to >= 1 kr: an exactly-1-kr overshoot used to slip past both the guard and the absorb branch and silently over-record paid_amount (pre-existing on the bank-match path). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(security): resolve all 7 PR compliance findings - ASVS V3.3: per-request CSP nonce on the enable-banking finalize page (mirrors the mcp-oauth consent page); inline scripts are nonce-bound - ASVS V16: decouple callback finalize work from the response stream (eager promise + next/server after()) so a client disconnect cannot drop session persistence or the consent_granted audit emit - ISO 27001 A.8.15: failed audit-event emits log through the structured logger with a stable message for log-based alerting - ASVS V2.3: recurring-invoice cron and run-now routes resolve isSandboxCompany themselves and pass an explicit suppressAutoSend flag (defence in depth around the email chokepoint, freeze-and-retain kept) - ISO 27001 A.8.11: stagePendingOperation rejects plaintext personnummer-bearing keys in params/preview_data (key-based guard; EF org numbers make value-matching unsafe) - ASVS V4.5: employee PATCH body is truly sparse; cleared number fields are omitted instead of resetting DB values to hardcoded fallbacks - ASVS V8.2.1: route-level tests pin the v1 cross-company deny (404 by convention, not 403) on the payslip PDF endpoint Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: implement vacation-year basis change validation and error handling - Added tests to block vacation-year basis changes when open balances exist. - Implemented error handling for open-balances guard query failures in the settings route. - Enhanced absence route to reject reversed date ranges with a validation error. - Updated absence handling to use atomic upserts instead of delete+insert for better performance and reliability. - Refactored salary calculation logic to correctly handle age-based avgifter rates according to Skatteverket's rules. - Improved error messaging for vacation year closure adjustments. - Adjusted employee opening balances handling to preserve audit information during upserts. * feat(settings): add validation to block vacation-year basis change with open balances feat(absence): reject reversed date ranges in absence queries fix(absence): update absence handling to use atomic upserts instead of delete+insert fix(employee): improve validation for jamkning dates in employee updates fix(opening-balances): ensure created_by field is preserved during upserts test(absence): enhance tests for absence range and date validations test(calculation): add tests for age-based avgifter rates and edge cases test(semesterberedning): validate vacation year closure adjustments and error handling test(employee-opening-balances): update tests to reflect changes in salary_run_employees schema * fix(migrations): implement NOT VALID constraints for pending_operations and add validation migration --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
576 lines
20 KiB
TypeScript
576 lines
20 KiB
TypeScript
/**
|
|
* Recurring invoice schedule service.
|
|
*
|
|
* Two public functions:
|
|
* - executeRecurringSchedule: spawn one invoice from a schedule, optionally
|
|
* sending it. Used by the daily cron and by a manual "run now" admin
|
|
* action.
|
|
* - computeNextRunDate: pure date helper. Given today + day_of_month, return
|
|
* the next date the schedule should run. Day-of-month values >28 are
|
|
* clamped to the last day of shorter months; the schedule keeps its
|
|
* original day_of_month so it jumps back in months that have it.
|
|
*/
|
|
|
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import { eventBus } from '@/lib/events'
|
|
import { getVatRules, getAvailableVatRates } from '@/lib/invoices/vat-rules'
|
|
import { fetchExchangeRate, convertToSEK } from '@/lib/currency/riksbanken'
|
|
import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
|
|
import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
|
|
import { renderToBuffer } from '@react-pdf/renderer'
|
|
import { InvoicePDF } from '@/lib/invoices/pdf-template'
|
|
import {
|
|
prepareInvoicePdfRender,
|
|
buildSwishQrDataUrl,
|
|
buildPaymentLinkQrDataUrl,
|
|
} from '@/lib/invoices/pdf-render-helpers'
|
|
import { applyPaymentLinkToInvoice } from '@/lib/extensions/payment-links'
|
|
import { getEmailService } from '@/lib/email/service'
|
|
import { hasCapability } from '@/lib/entitlements/has-capability'
|
|
import { CAPABILITY } from '@/lib/entitlements/keys'
|
|
import { isSandboxCompany } from '@/lib/sandbox/guard'
|
|
import {
|
|
generateInvoiceEmailHtml,
|
|
generateInvoiceEmailText,
|
|
generateInvoiceEmailSubject,
|
|
} from '@/lib/email/invoice-templates'
|
|
import { uploadDocument } from '@/lib/core/documents/document-service'
|
|
import { createLogger } from '@/lib/logger'
|
|
import type {
|
|
Invoice,
|
|
InvoiceItem,
|
|
Customer,
|
|
CompanySettings,
|
|
RecurringInvoiceSchedule,
|
|
RecurringInvoiceScheduleItem,
|
|
} from '@/types'
|
|
|
|
const log = createLogger('invoices/recurring-schedule-service')
|
|
|
|
export interface ExecuteResult {
|
|
invoiceId: string
|
|
invoiceNumber: string | null
|
|
autoSent: boolean
|
|
warning: string | null
|
|
}
|
|
|
|
/**
|
|
* Last day of the month for the given year/month (1-indexed month).
|
|
* Used to clamp day_of_month values >28 in shorter months.
|
|
*/
|
|
function lastDayOfMonth(year: number, monthIndex0: number): number {
|
|
// Day 0 of next month = last day of this month.
|
|
return new Date(Date.UTC(year, monthIndex0 + 1, 0)).getUTCDate()
|
|
}
|
|
|
|
/**
|
|
* Compute the next run date for a schedule given a reference date and the
|
|
* stored day_of_month. The reference is always interpreted in UTC to avoid
|
|
* timezone surprises around the day boundary in Vercel cron.
|
|
*
|
|
* Rules:
|
|
* - If reference is the same as a valid day_of_month occurrence, returns
|
|
* NEXT month's occurrence (callers compute the FIRST run via
|
|
* computeInitialRunDate).
|
|
* - Day 29-31 in shorter months clamps to that month's last day.
|
|
* - The schedule's stored day_of_month is unchanged: caller passes it in.
|
|
*/
|
|
export function computeNextRunDate(reference: Date, dayOfMonth: number): string {
|
|
if (dayOfMonth < 1 || dayOfMonth > 31) {
|
|
throw new Error(`invalid day_of_month: ${dayOfMonth}`)
|
|
}
|
|
const refY = reference.getUTCFullYear()
|
|
const refM = reference.getUTCMonth()
|
|
// Advance to the next month.
|
|
const nextM = refM + 1
|
|
const nextYear = refY + Math.floor(nextM / 12)
|
|
const nextMonth = ((nextM % 12) + 12) % 12
|
|
const clamped = Math.min(dayOfMonth, lastDayOfMonth(nextYear, nextMonth))
|
|
const yyyy = nextYear.toString().padStart(4, '0')
|
|
const mm = (nextMonth + 1).toString().padStart(2, '0')
|
|
const dd = clamped.toString().padStart(2, '0')
|
|
return `${yyyy}-${mm}-${dd}`
|
|
}
|
|
|
|
/**
|
|
* Compute the initial next_run_date when a schedule is created.
|
|
* - If start_date is given, use it.
|
|
* - Else, if today's day-of-month <= schedule day_of_month (clamped to this
|
|
* month's last day), pick this month's occurrence.
|
|
* - Otherwise pick next month's occurrence.
|
|
*/
|
|
export function computeInitialRunDate(
|
|
today: Date,
|
|
dayOfMonth: number,
|
|
startDate?: string,
|
|
): string {
|
|
if (startDate) return startDate
|
|
if (dayOfMonth < 1 || dayOfMonth > 31) {
|
|
throw new Error(`invalid day_of_month: ${dayOfMonth}`)
|
|
}
|
|
const y = today.getUTCFullYear()
|
|
const m = today.getUTCMonth()
|
|
const todayDay = today.getUTCDate()
|
|
const thisMonthDay = Math.min(dayOfMonth, lastDayOfMonth(y, m))
|
|
if (todayDay <= thisMonthDay) {
|
|
const yyyy = y.toString().padStart(4, '0')
|
|
const mm = (m + 1).toString().padStart(2, '0')
|
|
const dd = thisMonthDay.toString().padStart(2, '0')
|
|
return `${yyyy}-${mm}-${dd}`
|
|
}
|
|
return computeNextRunDate(today, dayOfMonth)
|
|
}
|
|
|
|
/**
|
|
* Resolve the calendar date (yyyy-mm-dd) and hour (0-23) in Europe/Stockholm
|
|
* for a given instant. The recurring cron runs in UTC on Vercel, but users
|
|
* pick a send time in Swedish local time, so we need "what day and hour is it
|
|
* in Sweden right now". Uses Intl (DST-aware, no extra dependency); en-CA +
|
|
* hourCycle 'h23' guarantees zero-padded ISO-shaped parts and a 0-23 hour.
|
|
*/
|
|
export function getStockholmDateHour(instant: Date): { date: string; hour: number } {
|
|
const parts = new Intl.DateTimeFormat('en-CA', {
|
|
timeZone: 'Europe/Stockholm',
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
hour: '2-digit',
|
|
hourCycle: 'h23',
|
|
}).formatToParts(instant)
|
|
const get = (type: string) => parts.find((p) => p.type === type)?.value ?? ''
|
|
return {
|
|
date: `${get('year')}-${get('month')}-${get('day')}`,
|
|
hour: Number(get('hour')),
|
|
}
|
|
}
|
|
|
|
export interface ExecuteScheduleOptions {
|
|
/**
|
|
* Defence-in-depth sandbox suppression (ASVS V2.3): callers that resolved
|
|
* `isSandboxCompany` at the route level pass true to skip the auto-send
|
|
* path outright, so the sandbox invariant does not hinge solely on the
|
|
* chokepoint inside sendInvoiceFromSchedule. Freeze-and-retain semantics
|
|
* are unchanged: the invoice is still created as a numbered draft.
|
|
*/
|
|
suppressAutoSend?: boolean
|
|
}
|
|
|
|
/**
|
|
* Spawn one invoice from a schedule. Always creates the invoice; auto_send
|
|
* additionally renders + emails + flips status + creates JE + archives PDF.
|
|
*
|
|
* Idempotency: caller must check schedule.last_run_at >= today before calling
|
|
* to prevent double-spawn on cron retries within the same UTC day.
|
|
*/
|
|
export async function executeRecurringSchedule(
|
|
supabase: SupabaseClient,
|
|
schedule: RecurringInvoiceSchedule & { items: RecurringInvoiceScheduleItem[] },
|
|
today: Date = new Date(),
|
|
options: ExecuteScheduleOptions = {},
|
|
): Promise<ExecuteResult> {
|
|
const opLog = log.child({ scheduleId: schedule.id, companyId: schedule.company_id })
|
|
|
|
// 1. Load customer to resolve VAT rules.
|
|
const { data: customer, error: customerErr } = await supabase
|
|
.from('customers')
|
|
.select('*')
|
|
.eq('id', schedule.customer_id)
|
|
.eq('company_id', schedule.company_id)
|
|
.single<Customer>()
|
|
|
|
if (customerErr || !customer) {
|
|
throw new Error(`customer not found for schedule ${schedule.id}`)
|
|
}
|
|
|
|
const vatRules = getVatRules(customer.customer_type, customer.vat_number_validated)
|
|
const availableRates = getAvailableVatRates(customer.customer_type, customer.vat_number_validated)
|
|
const allowedRates = new Set(availableRates.map((r) => r.rate))
|
|
|
|
// 2. Compute amounts (mirrors POST /api/invoices).
|
|
const items = (schedule.items || []).slice().sort((a, b) => a.sort_order - b.sort_order)
|
|
if (items.length === 0) {
|
|
throw new Error(`schedule ${schedule.id} has no items`)
|
|
}
|
|
|
|
const subtotal = items.reduce((sum, it) => sum + it.quantity * it.unit_price, 0)
|
|
let vatAmount = 0
|
|
for (const item of items) {
|
|
const itemRate = item.vat_rate != null ? item.vat_rate : vatRules.rate
|
|
if (!allowedRates.has(itemRate)) {
|
|
throw new Error(
|
|
`VAT rate ${itemRate}% not allowed for customer type ${customer.customer_type}`,
|
|
)
|
|
}
|
|
const lineTotal = item.quantity * item.unit_price
|
|
vatAmount += Math.round((lineTotal * itemRate) / 100 * 100) / 100
|
|
}
|
|
const total = subtotal + vatAmount
|
|
|
|
const uniqueRates = new Set(items.map((it) => (it.vat_rate != null ? it.vat_rate : vatRules.rate)))
|
|
const isMixedRate = uniqueRates.size > 1
|
|
|
|
// 3. Dates: invoice_date = today (UTC), due_date = +payment_terms_days.
|
|
const yyyy = today.getUTCFullYear().toString().padStart(4, '0')
|
|
const mm = (today.getUTCMonth() + 1).toString().padStart(2, '0')
|
|
const dd = today.getUTCDate().toString().padStart(2, '0')
|
|
const invoiceDate = `${yyyy}-${mm}-${dd}`
|
|
const due = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate()))
|
|
due.setUTCDate(due.getUTCDate() + schedule.payment_terms_days)
|
|
const dueDate = due.toISOString().slice(0, 10)
|
|
|
|
// 4. Foreign currency: fetch exchange rate.
|
|
let exchangeRate: number | null = null
|
|
let exchangeRateDate: string | null = null
|
|
let subtotalSek: number | null = null
|
|
let vatAmountSek: number | null = null
|
|
let totalSek: number | null = null
|
|
if (schedule.currency !== 'SEK') {
|
|
const rateData = await fetchExchangeRate(schedule.currency)
|
|
if (rateData) {
|
|
exchangeRate = rateData.rate
|
|
exchangeRateDate = rateData.date
|
|
subtotalSek = convertToSEK(subtotal, exchangeRate)
|
|
vatAmountSek = convertToSEK(vatAmount, exchangeRate)
|
|
totalSek = convertToSEK(total, exchangeRate)
|
|
}
|
|
}
|
|
|
|
// 5. Insert invoice header.
|
|
const { data: invoice, error: invoiceError } = await supabase
|
|
.from('invoices')
|
|
.insert({
|
|
user_id: schedule.user_id,
|
|
company_id: schedule.company_id,
|
|
customer_id: schedule.customer_id,
|
|
invoice_number: null,
|
|
invoice_date: invoiceDate,
|
|
due_date: dueDate,
|
|
delivery_date: null,
|
|
currency: schedule.currency,
|
|
exchange_rate: exchangeRate,
|
|
exchange_rate_date: exchangeRateDate,
|
|
subtotal,
|
|
subtotal_sek: subtotalSek,
|
|
vat_amount: vatAmount,
|
|
vat_amount_sek: vatAmountSek,
|
|
total,
|
|
total_sek: totalSek,
|
|
remaining_amount: total,
|
|
vat_treatment: vatRules.treatment,
|
|
vat_rate: isMixedRate ? null : (uniqueRates.values().next().value ?? vatRules.rate),
|
|
moms_ruta: vatRules.momsRuta,
|
|
reverse_charge_text: vatRules.reverseChargeText || null,
|
|
your_reference: schedule.your_reference,
|
|
our_reference: schedule.our_reference,
|
|
notes: schedule.notes,
|
|
document_type: 'invoice',
|
|
})
|
|
.select()
|
|
.single()
|
|
|
|
if (invoiceError || !invoice) {
|
|
throw new Error(`failed to insert invoice from schedule: ${invoiceError?.message ?? 'unknown'}`)
|
|
}
|
|
|
|
// 6. Insert items.
|
|
// NOTE (artikelregister Phase 2): recurring schedule template items have no
|
|
// article_id / revenue_account columns (see recurring_invoice_schedule_items),
|
|
// so generated invoices fall back to the VAT-treatment-derived revenue account.
|
|
// Wiring per-article overrides into recurring invoices needs a schema change
|
|
// and is deliberately out of the artikelregister MVP scope.
|
|
const itemRows = items.map((item, index) => {
|
|
const itemRate = item.vat_rate != null ? item.vat_rate : vatRules.rate
|
|
const lineTotal = item.quantity * item.unit_price
|
|
const itemVat = Math.round((lineTotal * itemRate) / 100 * 100) / 100
|
|
return {
|
|
invoice_id: invoice.id,
|
|
sort_order: index,
|
|
description: item.description,
|
|
quantity: item.quantity,
|
|
unit: item.unit,
|
|
unit_price: item.unit_price,
|
|
line_total: lineTotal,
|
|
vat_rate: itemRate,
|
|
vat_amount: itemVat,
|
|
}
|
|
})
|
|
const { error: itemsError } = await supabase.from('invoice_items').insert(itemRows)
|
|
if (itemsError) {
|
|
// Hard-delete is safe here only because step 5 inserted invoice_number: null,
|
|
// no F-series slot has been consumed yet (step 7 calls ensureInvoiceNumber).
|
|
// Once a number is assigned, the soft-cancel path in step 7 must be used to
|
|
// preserve the sequence per BFL 5 kap 6§ / ML 17 kap 24§.
|
|
await supabase.from('invoices').delete().eq('id', invoice.id)
|
|
throw new Error(`failed to insert invoice items: ${itemsError.message}`)
|
|
}
|
|
|
|
// 7. Allocate F-series number.
|
|
try {
|
|
await ensureInvoiceNumber(supabase, schedule.company_id, invoice as Invoice)
|
|
} catch (err) {
|
|
// Soft-cancel to preserve the F-series sequence (ML 17 kap 24§).
|
|
await supabase
|
|
.from('invoices')
|
|
.update({ status: 'cancelled' })
|
|
.eq('id', invoice.id)
|
|
.eq('company_id', schedule.company_id)
|
|
.eq('status', 'draft')
|
|
throw new Error(
|
|
`failed to assign invoice number: ${err instanceof Error ? err.message : String(err)}`,
|
|
)
|
|
}
|
|
|
|
// 8. Re-fetch with relations so downstream PDF/email/event have full data.
|
|
const { data: completeInvoice } = await supabase
|
|
.from('invoices')
|
|
.select('*, customer:customers(*), items:invoice_items(*)')
|
|
.eq('id', invoice.id)
|
|
.single()
|
|
|
|
if (!completeInvoice) {
|
|
throw new Error('failed to reload created invoice')
|
|
}
|
|
|
|
// Always emit invoice.created so existing consumers (event_log, etc.) see it.
|
|
await eventBus.emit({
|
|
type: 'invoice.created',
|
|
payload: {
|
|
invoice: completeInvoice as Invoice,
|
|
companyId: schedule.company_id,
|
|
userId: schedule.user_id,
|
|
},
|
|
})
|
|
|
|
let autoSent = false
|
|
let warning: string | null = null
|
|
|
|
// 9. Auto-send path. If anything below fails, we keep the invoice (now a
|
|
// numbered draft) and surface a Swedish warning on the schedule: the
|
|
// user can manually send from /invoices/[id].
|
|
if (schedule.auto_send && options.suppressAutoSend) {
|
|
// Route-level sandbox suppression: same outcome as the internal sandbox
|
|
// chokepoint below (no email, invoice retained as draft, manual-send
|
|
// warning), reached without entering the send path at all.
|
|
opLog.warn('auto-send suppressed by route-level sandbox guard', {
|
|
invoiceId: invoice.id,
|
|
})
|
|
warning = 'Auto-utskick misslyckades: fakturan finns som utkast och kan skickas manuellt.'
|
|
} else if (schedule.auto_send) {
|
|
try {
|
|
autoSent = await sendInvoiceFromSchedule(
|
|
supabase,
|
|
schedule.company_id,
|
|
schedule.user_id,
|
|
completeInvoice as Invoice & { customer: Customer; items: InvoiceItem[] },
|
|
)
|
|
if (!autoSent) {
|
|
warning = 'Auto-utskick misslyckades: fakturan finns som utkast och kan skickas manuellt.'
|
|
}
|
|
} catch (err) {
|
|
opLog.error('auto-send failed for recurring schedule', err as Error, {
|
|
invoiceId: invoice.id,
|
|
})
|
|
warning = `Auto-utskick misslyckades: ${err instanceof Error ? err.message : 'okänt fel'}`
|
|
}
|
|
}
|
|
|
|
await eventBus.emit({
|
|
type: 'recurring_invoice.executed',
|
|
payload: {
|
|
scheduleId: schedule.id,
|
|
invoice: completeInvoice as Invoice,
|
|
autoSent,
|
|
warning,
|
|
companyId: schedule.company_id,
|
|
userId: schedule.user_id,
|
|
},
|
|
})
|
|
|
|
return {
|
|
invoiceId: invoice.id,
|
|
invoiceNumber: (completeInvoice as Invoice).invoice_number,
|
|
autoSent,
|
|
warning,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Render PDF + send email + flip status + create JE + archive PDF.
|
|
* Mirrors /api/invoices/[id]/send/route.ts but inline so we don't depend on
|
|
* the route's auth chain. Returns true if email was sent successfully.
|
|
*/
|
|
async function sendInvoiceFromSchedule(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
userId: string,
|
|
invoice: Invoice & { customer: Customer; items: InvoiceItem[] },
|
|
): Promise<boolean> {
|
|
const emailService = getEmailService()
|
|
if (!emailService.isConfigured()) {
|
|
log.warn('email service not configured; recurring schedule cannot auto-send', {
|
|
invoiceId: invoice.id,
|
|
})
|
|
return false
|
|
}
|
|
// The sandbox must never deliver a real email to a real address. The
|
|
// interactive send routes enforce this with guardSandbox, but cron and
|
|
// run-now reach this function without any route-level guard, so the
|
|
// invariant is enforced here at the email chokepoint. Freeze-and-retain
|
|
// like the paywall path below: the invoice is still generated as a draft.
|
|
if (await isSandboxCompany(supabase, companyId)) {
|
|
log.warn('sandbox company; recurring schedule cannot auto-send', {
|
|
invoiceId: invoice.id,
|
|
companyId,
|
|
})
|
|
return false
|
|
}
|
|
// Paywall: email sending is a paid capability. The invoice itself is still
|
|
// created (bookkeeping stays free); it just isn't emailed, and the schedule
|
|
// surfaces the standard manual-send warning (freeze-and-retain).
|
|
if (!(await hasCapability(supabase, companyId, CAPABILITY.email_send))) {
|
|
log.warn('company lacks email_send capability; recurring schedule cannot auto-send', {
|
|
invoiceId: invoice.id,
|
|
companyId,
|
|
})
|
|
return false
|
|
}
|
|
if (!invoice.customer.email) {
|
|
log.warn('customer has no email; recurring schedule cannot auto-send', {
|
|
invoiceId: invoice.id,
|
|
customerId: invoice.customer.id,
|
|
})
|
|
return false
|
|
}
|
|
|
|
const { data: company } = await supabase
|
|
.from('company_settings')
|
|
.select('*')
|
|
.eq('company_id', companyId)
|
|
.single<CompanySettings>()
|
|
|
|
if (!company) {
|
|
throw new Error('company settings missing: cannot send invoice')
|
|
}
|
|
|
|
const items = (invoice.items || []).slice().sort((a, b) => a.sort_order - b.sort_order)
|
|
|
|
// Auto-create an online payment link (extension-provided, e.g. Stripe) so
|
|
// the email button and PDF QR carry it: parity with the manual and v1 send
|
|
// routes. Best-effort: the faktura is legally valid without a link, so a
|
|
// failure only logs and the send proceeds. On success the helper mirrors
|
|
// payment_link_url onto this invoice object, which the email template and
|
|
// QR builder below read.
|
|
const { failure: paymentLinkFailure } = await applyPaymentLinkToInvoice(
|
|
supabase,
|
|
companyId,
|
|
userId,
|
|
invoice,
|
|
log,
|
|
)
|
|
if (paymentLinkFailure) {
|
|
log.warn('payment link creation failed for recurring invoice; sending without it', {
|
|
invoiceId: invoice.id,
|
|
reason: paymentLinkFailure,
|
|
})
|
|
}
|
|
|
|
// Render PDF with status overridden to 'sent' so the customer doesn't
|
|
// receive a "UTKAST" stamp.
|
|
const renderableInvoice = { ...invoice, status: 'sent' as const }
|
|
const { branding, company: renderCompany } = await prepareInvoicePdfRender(company)
|
|
const swishQrDataUrl = await buildSwishQrDataUrl(company, renderableInvoice)
|
|
const paymentLinkQrDataUrl = await buildPaymentLinkQrDataUrl(renderableInvoice)
|
|
const pdfBuffer = await renderToBuffer(
|
|
InvoicePDF({
|
|
invoice: renderableInvoice,
|
|
customer: invoice.customer,
|
|
items,
|
|
company: renderCompany,
|
|
branding,
|
|
swishQrDataUrl,
|
|
paymentLinkQrDataUrl,
|
|
}),
|
|
)
|
|
|
|
const emailData = { invoice, customer: invoice.customer, company }
|
|
const filename = `faktura-${invoice.invoice_number}.pdf`
|
|
const ccAddress = company.email || undefined
|
|
|
|
const result = await emailService.sendEmail({
|
|
to: invoice.customer.email,
|
|
cc: ccAddress,
|
|
subject: generateInvoiceEmailSubject(emailData),
|
|
html: generateInvoiceEmailHtml(emailData),
|
|
text: generateInvoiceEmailText(emailData),
|
|
replyTo: company.email || undefined,
|
|
fromName: company.company_name ?? undefined,
|
|
attachments: [
|
|
{ filename, content: pdfBuffer, contentType: 'application/pdf' },
|
|
],
|
|
})
|
|
|
|
if (!result.success) {
|
|
log.error(
|
|
'email provider failed in recurring schedule auto-send',
|
|
new Error(result.error || 'unknown'),
|
|
{ invoiceId: invoice.id },
|
|
)
|
|
return false
|
|
}
|
|
|
|
// Email delivered: flip status, create JE, archive PDF. Treat downstream
|
|
// failures as warnings (don't unsend the email).
|
|
await supabase
|
|
.from('invoices')
|
|
.update({ status: 'sent' })
|
|
.eq('id', invoice.id)
|
|
.eq('company_id', companyId)
|
|
|
|
const accountingMethod = (company as { accounting_method?: string }).accounting_method
|
|
let journalEntryId: string | undefined
|
|
if (!accountingMethod || accountingMethod === 'accrual') {
|
|
try {
|
|
const journalEntry = await createInvoiceJournalEntry(
|
|
supabase,
|
|
companyId,
|
|
userId,
|
|
invoice,
|
|
company.entity_type,
|
|
)
|
|
if (journalEntry) {
|
|
journalEntryId = journalEntry.id
|
|
await supabase
|
|
.from('invoices')
|
|
.update({ journal_entry_id: journalEntry.id })
|
|
.eq('id', invoice.id)
|
|
}
|
|
} catch (err) {
|
|
log.error('failed to create journal entry for recurring invoice', err as Error, {
|
|
invoiceId: invoice.id,
|
|
})
|
|
}
|
|
}
|
|
|
|
try {
|
|
const pdfArrayBuffer = new Uint8Array(pdfBuffer).buffer as ArrayBuffer
|
|
await uploadDocument(
|
|
supabase,
|
|
userId,
|
|
companyId,
|
|
{ name: filename, buffer: pdfArrayBuffer, type: 'application/pdf' },
|
|
{ upload_source: 'system', journal_entry_id: journalEntryId },
|
|
)
|
|
} catch (err) {
|
|
log.error('failed to archive recurring invoice PDF', err as Error, {
|
|
invoiceId: invoice.id,
|
|
})
|
|
}
|
|
|
|
await eventBus.emit({
|
|
type: 'invoice.sent',
|
|
payload: { invoice, companyId, userId },
|
|
})
|
|
|
|
return true
|
|
}
|