* feat(salary): validate employee clearing/kontonummer at entry Bank details on the "Anställda" form had no structural validation, so a typo in clearing/kontonummer was saved silently and only surfaced at Bankgirot LB generation (or never, on the SEPA path). Adds a shared validator (lib/salary/payment/bank-account.ts) wired into the create dialog, edit page, CreateEmployeeSchema, and the PATCH route: 4-digit clearing or 5-digit Swedbank (8xxxx), 5-11 digit account, both-or-neither. Mirrors encodeReceiverAccount so entry-time validation matches what the payout layer can encode. Update validates only when a bank field actually changes, so legacy free-text data stays editable. Includes a conservative clearing to bank-name hint (null for unknown ranges). Per-bank mod10/mod11 checksum deferred to a soft-warning follow-up. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(chart-of-accounts): styled delete warnings and bulk select-all Replace the native window.confirm() on single-account delete with the styled DestructiveConfirmDialog, and add to the prune dialog a master 'select all unused accounts' checkbox plus an explicit confirmation step before bulk deletion. New sv/en strings for the confirm titles and actions. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(salary): encrypt personnummer on v1 employee create; tolerate legacy plaintext on read The v1 REST create route stored personnummer unencrypted, which then threw ERR_CRYPTO_INVALID_AUTH_TAG on every decrypt-on-read path and 500'd the employees roster. Encrypt on write in v1 create, decrypt on read in the v1 list/detail/patch responses, and make decryptPersonnummer pass a raw 12-digit value through with a warn so a legacy plaintext row can't take the roster down. Encrypt seeded personnummer. Add a gated, idempotent backfill for existing rows. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(bookkeeping): save a manual entry as a reusable template Add a "Spara som mall" action to the manual journal-entry form next to the existing "Anvand mall" picker, so users can capture a booking pattern the moment they work it out. Opens the shared TemplateForm (create mode) pre-seeded from the current lines via deriveTemplateLinesFromBooking, and saves through the existing POST /api/settings/booking-templates. Rendered in both the mobile and desktop layouts and in create + edit modes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(pending): label all staged operation types The Granskning list rendered the raw snake_case operation_type (e.g. create_supplier_invoice_from_inbox) for any type missing from the label map, which hogs the meta row and wraps awkwardly on mobile. Add short sv/en labels for all operation types in OPERATION_RISK_TIERS, plus a humanized fallback for future ones, and simplify the label map to a plain operation_type -> i18n-key record (the icon/variant fields were dead). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(reports): let users file moms without a Skatteverket connection The momsdeklaration was never gated on the Skatteverket connection (it renders from the bookkeeping), but the not-connected "Anslut med BankID" card read as a wall. Make manual filing a first-class path: - Add a "Lämna in din momsdeklaration" card under the report with a PDF download (SKV 4700 layout, hela kronor) and a skatteverket.se link. - Add a momsdeklaration PDF route + template; buildManualFilingRows() rounds each ruta to whole kronor and recomputes ruta 49 per the SKV 4700 formula so it ties out. The PDF is a read/record copy, not a submission file (moms has no upload channel). - Offer PDF alongside Excel in the report's export menu. - Reframe the not-connected SkatteverketPanel to "Skicka direkt till Skatteverket (valfritt)". Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(salary): compact new-employee dialog and warn on bad account check digit Redesign NewEmployeeDialog into a compact layout: borderless sections split by hairline dividers (no per-section cards), a fixed header + scrolling body + solid footer (fixes content showing through the old sticky bar), and denser grids. EmployeeTaxCard gains a `flat` variant so the dialog can host it without card chrome; the edit page keeps the boxed version. Add non-blocking Swedish account check-digit validation (lib/bankgiro/account-number.ts): mod10 (reuses luhn) + mod11, with a clearing->method table from the Bankgirot "Bankernas kontonummeruppbyggnad" spec, cross-checked against jop-io/kontonummer.js and verified against a real account (Forex 9420/4172385). Surfaced as a soft warning in both employee forms; unrecognised clearings return 'unknown' so we never warn on a valid but unmapped account. Never blocks saving. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(invoices): configurable send time + editing for recurring invoices Re-register the accidentally-removed recurring cron (now hourly) and add a per-schedule send hour (Europe/Stockholm, DST-aware). The cron never sends for a past date, and the enabling migration pauses every existing schedule on deploy so nothing auto-sends behind a user's back; users reactivate consciously (with a confirm) or click "Skapa faktura nu" to send this month on demand. Automatic sending now requires a customer email. Adds a full edit flow (row click opens the prefilled form, PATCH), fixing the row-click 404. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(invoices): configure självfaktura via the invoice API Add an optional is_self_billed flag (plus external_invoice_number, self_billing_agreement_ref, received_date) to the public invoice-create endpoint so callers can register a received self-billing invoice (mottagen självfaktura, ML 17 kap 15§) via the API. It was previously only reachable from the internal dashboard route, so it was missing from the API docs. Extract the booking into a shared service (lib/invoices/self-billed-sale.ts) and refactor the internal /api/invoices/self-billed route to a thin wrapper over it, so the dashboard and the API cannot drift. Books as a sale (Debit 1510 / Credit 30xx+26xx) with the counterparty's number; no own number is consumed. Fields are plain optionals (no schema refine) so UpdateInvoiceSchema.omit() keeps working; required-when-self-billed is enforced in the route. Documented in the endpoint registry. No migration (columns already exist). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(settings): allow a partial voucher-series-per-source-type map In Zod 4 an enum-keyed z.record is exhaustive (every source_type required), so saving a default_voucher_series_per_source_type map that omits a source type (e.g. the newly added result_appropriation) failed with "expected string, received undefined". Use partialRecord so the map can be sparse; the engine falls back to series 'A' for any unmapped key. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(salary): resolve employer name via getCompanyDisplayName Payslip PDFs, the payslip email, AGI, KU10, and the BG/LB + SEPA payment files now resolve the employer name through getCompanyDisplayName (company_settings.company_name, falling back to companies.name), matching how invoices already display it. Read-side coalesce, so no migration or backfill: companies.name is write-once at onboarding and not authoritative for these surfaces. The sidebar company switcher uses the same coalesce for the non-active companies in the list. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * perf(kontoplan): index-only account usage counts + lighter reference load Add a covering index on journal_entry_lines (journal_entry_id, account_number) so get_account_usage_counts becomes an index-only scan (prod worst case ~440ms). Slim /api/bookkeeping/accounts/reference to return only the company's activation rows and merge against the client-bundled BAS_REFERENCE instead of re-sending the full ~1,300-account catalog every load, and defer the BAS catalog + usage counts off the first-paint critical path in ChartOfAccountsManager. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * i18n(salary): add bank-account checksum warning string sv/en strings for the employee bank-account (clearing/kontonummer) soft checksum warning shown by the create/edit forms. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: update decision log Append the 2026-07-06/07 decision entries (salary employer-name coalesce, sidebar switcher, employees API personnummer fix, kontoplan load optimization, momsdeklaration manual filing, recurring invoices resend + reactivation + editing, "spara som mall", voucher-series partial map, and självfaktura via the invoice API). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: address compliance-review findings on recurring invoices + moms filing - recurring cron: close the double-send window with an atomic compare-and-set claim on last_run_at (release-on-failure) so two overlapping hourly runs can't both spawn from the same stale batch row - recurring edit dialog: force auto_send=false whenever the effective customer has no email, so a disabled-but-checked box can't PATCH auto_send=true after the async customer load - momsdeklaration manual-filing: truncate rutor to whole kronor (öretal faller bort per SFL 22 kap 1 §) instead of round-to-nearest, matching the SRU path Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
238 lines
8.6 KiB
TypeScript
238 lines
8.6 KiB
TypeScript
import { NextResponse } from 'next/server'
|
|
import { ensureInitialized } from '@/lib/init'
|
|
import { withCronContext } from '@/lib/api/with-cron-context'
|
|
import { createServiceClient } from '@/lib/supabase/server'
|
|
import {
|
|
executeRecurringSchedule,
|
|
computeNextRunDate,
|
|
computeInitialRunDate,
|
|
getStockholmDateHour,
|
|
} from '@/lib/invoices/recurring-schedule-service'
|
|
import type {
|
|
RecurringInvoiceSchedule,
|
|
RecurringInvoiceScheduleItem,
|
|
} from '@/types'
|
|
|
|
ensureInitialized()
|
|
|
|
type DueSchedule = RecurringInvoiceSchedule & { items: RecurringInvoiceScheduleItem[] }
|
|
|
|
/**
|
|
* GET /api/invoices/recurring/cron: hourly (top of every hour, UTC).
|
|
*
|
|
* Users pick a send hour in Swedish local time (send_hour, 0-23,
|
|
* Europe/Stockholm). This cron runs every hour and, for each active schedule
|
|
* due today, sends only once the chosen Stockholm hour has arrived.
|
|
*
|
|
* Safety rules (see DECISIONS.md):
|
|
* - Never send for a date in the past. A schedule whose next_run_date is
|
|
* before today (a missed prior day, e.g. after an outage or on a schedule
|
|
* the user just reactivated) is rolled forward to its next future
|
|
* occurrence WITHOUT generating anything.
|
|
* - Paused schedules are ignored (status filter). Existing schedules were
|
|
* paused on deploy so nothing resumes sending behind the user's back.
|
|
*
|
|
* Each schedule runs in isolated try/catch so a failure on one doesn't block
|
|
* the rest. On a successful send: bump next_run_date to next month, set
|
|
* last_run_at/last_invoice_id/generated_count. On failure: leave next_run_date
|
|
* alone so a later run retries.
|
|
*/
|
|
export const GET = withCronContext('cron.recurring_invoices', async (_request, ctx) => {
|
|
const supabase = createServiceClient()
|
|
|
|
const now = new Date()
|
|
// "Today" and the current hour in Swedish local time. Date math for rolling
|
|
// next_run_date uses a UTC-midnight Date of the Stockholm calendar day so it
|
|
// stays consistent with the Stockholm day even across the UTC boundary.
|
|
const { date: todayStockholm, hour: currentHour } = getStockholmDateHour(now)
|
|
const stockholmToday = new Date(`${todayStockholm}T00:00:00Z`)
|
|
|
|
const { data: due, error } = await supabase
|
|
.from('recurring_invoice_schedules')
|
|
.select('*, items:recurring_invoice_schedule_items(*)')
|
|
.eq('status', 'active')
|
|
.lte('next_run_date', todayStockholm)
|
|
|
|
if (error) {
|
|
ctx.log.error('failed to load due recurring schedules', error)
|
|
return NextResponse.json(
|
|
{ success: false, error: error.message },
|
|
{ status: 500 },
|
|
)
|
|
}
|
|
|
|
const schedules = (due ?? []) as DueSchedule[]
|
|
|
|
ctx.log.info('recurring invoice cron starting', {
|
|
dueCount: schedules.length,
|
|
todayStockholm,
|
|
currentHour,
|
|
})
|
|
|
|
type RunResult = {
|
|
scheduleId: string
|
|
invoiceId?: string
|
|
invoiceNumber?: string | null
|
|
autoSent?: boolean
|
|
warning?: string | null
|
|
skipped?: boolean
|
|
skipReason?: string
|
|
error?: string
|
|
}
|
|
const results: RunResult[] = []
|
|
|
|
const summary = await ctx.forEach('schedule', schedules, async (schedule, itemCtx) => {
|
|
// 1. Missed a prior day: never send for the past. Roll forward to the next
|
|
// future occurrence (this month if the day hasn't passed, else next
|
|
// month) without generating an invoice. This also protects the
|
|
// reactivation path: turning a long-paused schedule back on rolls it to
|
|
// its next date rather than firing a stale one immediately.
|
|
if (schedule.next_run_date < todayStockholm) {
|
|
const rolledNext = computeInitialRunDate(stockholmToday, schedule.day_of_month)
|
|
const { error: rollError } = await supabase
|
|
.from('recurring_invoice_schedules')
|
|
.update({ next_run_date: rolledNext })
|
|
.eq('id', schedule.id)
|
|
.eq('company_id', schedule.company_id)
|
|
if (rollError) {
|
|
throw new Error(`failed to roll stale schedule forward: ${rollError.message}`)
|
|
}
|
|
itemCtx.log.info('stale schedule rolled forward without sending', {
|
|
from: schedule.next_run_date,
|
|
to: rolledNext,
|
|
})
|
|
results.push({
|
|
scheduleId: schedule.id,
|
|
skipped: true,
|
|
skipReason: 'stale_rolled_forward',
|
|
})
|
|
return
|
|
}
|
|
|
|
// 2. Due today but the chosen Stockholm hour hasn't arrived yet. A later
|
|
// run this same day will pick it up (send_hour <= currentHour).
|
|
if (currentHour < schedule.send_hour) {
|
|
results.push({
|
|
scheduleId: schedule.id,
|
|
skipped: true,
|
|
skipReason: 'hour_not_reached',
|
|
})
|
|
return
|
|
}
|
|
|
|
// 3. Idempotency fast-path: skip if the row we loaded already shows a run
|
|
// today (cheap check against the batch, no write).
|
|
if (schedule.last_run_at) {
|
|
const lastRunDay = getStockholmDateHour(new Date(schedule.last_run_at)).date
|
|
if (lastRunDay >= todayStockholm) {
|
|
itemCtx.log.info('schedule already ran today; skipping')
|
|
results.push({
|
|
scheduleId: schedule.id,
|
|
skipped: true,
|
|
skipReason: 'already_ran_today',
|
|
})
|
|
return
|
|
}
|
|
}
|
|
|
|
// 4. Atomic claim. Two hourly cron invocations can overlap (an hour-boundary
|
|
// retry, or a manual re-trigger) and both read the same stale
|
|
// last_run_at from the batch above, so the read-only check in step 3
|
|
// can't by itself stop a double-send. Compare-and-set last_run_at from
|
|
// the exact value we read to `now`: Postgres row-locking serialises the
|
|
// two writers, so only the one whose WHERE still matches the old value
|
|
// flips the row and gets it back; the loser matches zero rows and skips.
|
|
// Cheaper than a Postgres advisory lock, and it closes the window for the
|
|
// whole batch execution, not just a single row.
|
|
const claimTs = now.toISOString()
|
|
const claimBase = supabase
|
|
.from('recurring_invoice_schedules')
|
|
.update({ last_run_at: claimTs })
|
|
.eq('id', schedule.id)
|
|
.eq('company_id', schedule.company_id)
|
|
const claimGated = schedule.last_run_at
|
|
? claimBase.eq('last_run_at', schedule.last_run_at)
|
|
: claimBase.is('last_run_at', null)
|
|
const { data: claimed, error: claimError } = await claimGated.select('id')
|
|
if (claimError) {
|
|
throw new Error(`failed to claim schedule for today: ${claimError.message}`)
|
|
}
|
|
if (!claimed || (claimed as unknown[]).length === 0) {
|
|
itemCtx.log.info('schedule claimed by a concurrent cron run; skipping')
|
|
results.push({
|
|
scheduleId: schedule.id,
|
|
skipped: true,
|
|
skipReason: 'claimed_by_concurrent_run',
|
|
})
|
|
return
|
|
}
|
|
|
|
// 5. Spawn the invoice. If it throws after we claimed, release the claim
|
|
// (restore the prior last_run_at) so a later cron retries today rather
|
|
// than treating the row as already run.
|
|
let result: Awaited<ReturnType<typeof executeRecurringSchedule>>
|
|
try {
|
|
result = await executeRecurringSchedule(supabase, schedule, now)
|
|
} catch (err) {
|
|
await supabase
|
|
.from('recurring_invoice_schedules')
|
|
.update({ last_run_at: schedule.last_run_at })
|
|
.eq('id', schedule.id)
|
|
.eq('company_id', schedule.company_id)
|
|
.eq('last_run_at', claimTs)
|
|
throw err
|
|
}
|
|
|
|
const nextRunDate = computeNextRunDate(stockholmToday, schedule.day_of_month)
|
|
const { error: updateError } = await supabase
|
|
.from('recurring_invoice_schedules')
|
|
.update({
|
|
next_run_date: nextRunDate,
|
|
last_run_at: now.toISOString(),
|
|
last_invoice_id: result.invoiceId,
|
|
last_run_warning: result.warning,
|
|
generated_count: schedule.generated_count + 1,
|
|
})
|
|
.eq('id', schedule.id)
|
|
.eq('company_id', schedule.company_id)
|
|
|
|
if (updateError) {
|
|
// The invoice exists. If we don't mark the schedule as ran, tomorrow's
|
|
// cron would spawn a duplicate. Surface this loudly.
|
|
itemCtx.log.error(
|
|
'invoice created but failed to update schedule: manual cleanup may be needed',
|
|
updateError,
|
|
{ scheduleId: schedule.id, invoiceId: result.invoiceId },
|
|
)
|
|
throw new Error(
|
|
`schedule update failed after invoice ${result.invoiceId} created: ${updateError.message}`,
|
|
)
|
|
}
|
|
|
|
results.push({
|
|
scheduleId: schedule.id,
|
|
invoiceId: result.invoiceId,
|
|
invoiceNumber: result.invoiceNumber,
|
|
autoSent: result.autoSent,
|
|
warning: result.warning,
|
|
})
|
|
})
|
|
|
|
ctx.log.info('recurring invoice cron summary', {
|
|
total: summary.total,
|
|
succeeded: summary.succeeded,
|
|
failed: summary.failed,
|
|
})
|
|
|
|
return NextResponse.json({
|
|
success: true,
|
|
total: summary.total,
|
|
succeeded: summary.succeeded,
|
|
failed: summary.failed,
|
|
failures: summary.failures,
|
|
results,
|
|
})
|
|
})
|
|
|
|
export const POST = GET
|