Files
accounted/app/api/extensions/arcim-migration/complete-invoice-lines/cron/route.ts
T
6906bc4aa2 feat(compliance): InvoiceRowsCompleted behandlingshistorik event for migrated invoice rows (#2312) (#2357)
Every migrated sales invoice whose rows complete_invoice_rows writes, from
the migration wizard or the hourly row-completion pass, now leaves one
InvoiceRowsCompleted row in processing_history on a new Invoice aggregate:
the writer, the provider, the consent, the row count, and the header VAT
split before and after when the pass rewrote it (BFL 5 kap 11 §, BFNAR
2013:2 p. 9.16). One run shares one correlation id.

lib/invoices/complete-invoice-rows.ts is the one TypeScript call site for
the RPC and the one emitter: it appends only on wrote = true, records
nothing for already_filled or failed, and keeps the append best-effort
(logged, eventId null) like every other processing_history writer. The
wizard runs on the user's session client, so MigrationOptions takes a lazy
createHistoryClient for the service role. Invoice numbers stay out of the
payload (the personnummer guard would drop ten-digit ones).

Migration 20260906210100 widens the aggregate_type CHECK with Invoice and
registers the event type; pg test covers the catalog row, the aggregate,
and that the CHECK still refuses unknown aggregates.


Claude-Session: https://claude.ai/code/session_01LvMaHcTnwAfxzgYD1fGYX1

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 21:04:53 +02:00

225 lines
9.0 KiB
TypeScript

import { NextResponse } from 'next/server'
import { loadExtensions } from '@/lib/extensions/loader'
import { extensionRegistry } from '@/lib/extensions/registry'
import { withCronContext } from '@/lib/api/with-cron-context'
import { createServiceClientNoCookies } from '@/lib/auth/api-keys'
import {
completeMigratedInvoiceLines,
countRowlessInvoices,
type CompleteInvoiceLinesResult,
} from '@/extensions/general/arcim-migration/lib/complete-invoice-lines'
/**
* GET /api/extensions/arcim-migration/complete-invoice-lines/cron: fetch the
* rows (and the VAT split) for migrated sales invoices that were imported
* without them.
*
* The migration hydrates the provider's detail form inside a fixed budget
* and reports the shortfall; this is what picks the shortfall up. Every run
* walks the accepted consents whose credentials can still be used, in two
* phases. First it sizes each register on our side: one indexed count of the
* invoices still without rows per consent, no provider call. Then it hands
* the registers with anything left to the pass smallest first, each within
* its share of the run. Shortest job first: a register that fits its share
* is finished this run whatever was accepted after it, and a register that
* needs several runs takes what is left of each instead of pushing every
* older company back by an hour per run (on 2026-09-05 a 1 125-invoice
* register on the newest consent used two runs in a row while a 384-invoice
* register three consents older, about 100 s of work, was skipped for budget
* both times). Nothing is stored between runs: the counts are taken fresh
* every hour, so a register the wizard or an earlier run finished simply
* stops appearing.
*
* What a run does not reach is, by construction, its largest registers. They
* are reported as deferred with their counts in the run summary and in the
* response, so a register that is deferred hour after hour (possible only
* while smaller registers keep arriving faster than the run clears them) is
* visible rather than silent. Scheduled hourly in vercel.json (and the
* Docker crontabs); a company the size of Clearstoq (1 125 invoices) is done
* after two or three runs.
*
* "Can still be used" is read off the token row, not the consent's age:
* Fortnox issues a new refresh token on every refresh and each one lives 45
* days, so a pair whose access token expired more than 45 days ago has not
* been refreshed since and its refresh token is dead. Trying such a consent
* every hour would only log the same PROVIDER_AUTH_EXPIRED; when the company
* reconnects through the wizard, the token row is renewed and the next run
* finds it here. A token without an expiry (Bokio's private tokens) is
* always eligible.
*/
export const maxDuration = 300
/** Leave the function a margin for the DB writes after the last fetch. */
const RUN_BUDGET_MS = 240_000
/** One company's share of provider detail fetches per run. */
const PER_COMPANY_BUDGET_MS = 120_000
/** Below this the registers still in line (the largest ones) wait for the next run. */
const MIN_COMPANY_BUDGET_MS = 20_000
/**
* A token pair not refreshed for this long cannot be refreshed any more
* (Fortnox: refresh tokens live 45 days and rotate on every refresh).
*/
const TOKEN_STALE_DAYS = 45
/** Hard safety on the consent scan; prod holds ~120 accepted consents in total. */
const MAX_CONSENTS_SCANNED = 500
interface ConsentRow {
id: string
company_id: string
provider: string | null
created_at: string
provider_consent_tokens: { token_expires_at: string | null } | { token_expires_at: string | null }[] | null
}
/** A usable consent whose company still has invoices without rows, sized by that count. */
interface Register {
consent: ConsentRow
candidates: number
}
/** The consent's token row, whichever cardinality PostgREST rendered it with. */
function tokenOf(consent: ConsentRow): { token_expires_at: string | null } | null {
const tokens = consent.provider_consent_tokens
if (!tokens) return null
return Array.isArray(tokens) ? (tokens[0] ?? null) : tokens
}
/** Does this consent still hold credentials a run can use? */
export function consentIsUsable(consent: ConsentRow, now: number): boolean {
const token = tokenOf(consent)
if (!token) return false
if (token.token_expires_at === null) return true
const expiredAt = Date.parse(token.token_expires_at)
if (Number.isNaN(expiredAt)) return false
return now - expiredAt <= TOKEN_STALE_DAYS * 24 * 60 * 60 * 1000
}
export const GET = withCronContext('cron.arcim_migration_complete_invoice_lines', async (_request, ctx) => {
loadExtensions()
// Physical routes under app/api/extensions/<id>/ compile into every build;
// the registry (generated from extensions.config.json) is what switches an
// extension on. A scheduled-but-disabled cron must fail visibly.
if (!extensionRegistry.get('arcim-migration')) {
ctx.log.warn('arcim-migration extension is not enabled; cron refused')
return NextResponse.json(
{ error: 'Migration extension is not enabled', code: 'EXTENSION_DISABLED' },
{ status: 503 },
)
}
const supabase = createServiceClientNoCookies()
const { data, error } = await supabase
.from('provider_consents')
.select('id, company_id, provider, created_at, provider_consent_tokens(token_expires_at)')
.eq('status', 1)
.not('provider', 'is', null)
// A stable scan order under the cap, and the tiebreak between registers
// of equal size (the sort below is stable): the newer consent goes first.
.order('created_at', { ascending: false })
.limit(MAX_CONSENTS_SCANNED)
if (error) {
throw new Error(`provider_consents lookup failed: ${error.message}`)
}
const now = Date.now()
const scanned = (data ?? []) as ConsentRow[]
const usable = scanned.filter((consent) => consentIsUsable(consent, now))
const deadline = now + RUN_BUDGET_MS
// Phase 1: size every register on our side. No consent is touched here, so
// a company with nothing left costs one count and no token refresh.
const registers: Register[] = []
const sizing = await ctx.forEach('consent', usable, async (consent) => {
const candidates = await countRowlessInvoices(supabase, consent.company_id)
if (candidates > 0) registers.push({ consent, candidates })
})
registers.sort((a, b) => a.candidates - b.candidates)
if (registers.length > 0) {
ctx.log.info('registers with row-less invoices, smallest first', {
registers: registers.map((r) => ({
companyId: r.consent.company_id, provider: r.consent.provider, candidates: r.candidates,
})),
})
}
// Phase 2: complete them smallest first, each within its share of the run.
const deferred: { companyId: string; candidates: number }[] = []
const totals = {
companies: 0,
candidates: 0,
completed: 0,
headersUpdated: 0,
remaining: 0,
notHydrated: 0,
totalMismatch: 0,
rowsMismatch: 0,
failed: 0,
historyAppended: 0,
}
const completing = await ctx.forEach('register', registers, async ({ consent, candidates }, itemCtx) => {
const budgetMs = Math.min(PER_COMPANY_BUDGET_MS, deadline - Date.now())
if (budgetMs < MIN_COMPANY_BUDGET_MS) {
deferred.push({ companyId: consent.company_id, candidates })
return
}
const result: CompleteInvoiceLinesResult = await completeMigratedInvoiceLines({
supabase,
companyId: consent.company_id,
consentId: consent.id,
budgetMs,
})
// The pass re-reads our side before it touches the consent; a register
// the wizard finished between the count and now is neither worked on nor
// counted as a company.
if (result.candidates === 0) return
totals.companies++
totals.candidates += result.candidates
totals.completed += result.completed
totals.headersUpdated += result.headersUpdated
totals.remaining += result.remaining
totals.notHydrated += result.notHydrated
totals.totalMismatch += result.totalMismatch
totals.rowsMismatch += result.rowsMismatch
totals.failed += result.failed
totals.historyAppended += result.historyAppended
itemCtx.log.info('migrated invoice rows completed for company', {
companyId: consent.company_id,
provider: consent.provider,
candidates: result.candidates,
matched: result.matched,
completed: result.completed,
remaining: result.remaining,
notHydrated: result.notHydrated,
totalMismatch: result.totalMismatch,
rowsMismatch: result.rowsMismatch,
failed: result.failed,
historyAppended: result.historyAppended,
hydration: result.hydration,
})
})
if (deferred.length > 0) {
ctx.log.warn('run deadline reached; the largest registers wait for the next run', { deferred })
}
const summary = {
consents: usable.length,
consentsStale: scanned.length - usable.length,
consentsFailed: sizing.failed + completing.failed,
skippedForBudget: deferred.length,
deferred,
...totals,
}
ctx.log.info('complete-invoice-lines run finished', summary)
return NextResponse.json({ data: summary })
})