Files
accounted/app/api/extensions/arcim-migration/complete-invoice-lines/cron/route.ts
T
e80ea74e76 fix(providers): map Fortnox VAT-inclusive rows net of VAT, and refuse migrated rows that contradict their header (#2302)
The first production run of the row-completion pass (#2291) wrote 345
Profilio invoices whose rows summed to the invoice GROSS with 25 % VAT
computed on top, beside a header (Net / TotalVAT) that was right. Fortnox
prices an invoice either excluding or including VAT and says which with
the invoice-level VATIncluded flag; the mapper had always read the row
Total and Price as net. Every such row set is 1.25 x its header net, to
the öre, across all 345.

- lib/providers/fortnox/mapper.ts: netOfVat() divides row Total and Price
  by (1 + rate) when VATIncluded is true; TotalExcludingVAT and
  PriceExcludingVAT are preferred when the payload carries them. A row
  without a rate cannot be split and keeps its amount.
- complete-invoice-lines.ts: rows whose net or VAT disagree with the header
  the same payload established by more than 1 kr are reported as
  rowsMismatch and left untouched. Öresavrundning stays inside the
  tolerance; VAT-inside rows, header-level freight and discounts do not.
  Rows that contradict their own header are worse than no rows.
- Cron summary carries rowsMismatch.

None of the 345 is open or booked; a separate repair removes today's rows
for them so the fixed pass refills them.


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

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

176 lines
6.5 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,
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, newest
* first, and for each company completes as many of its row-less invoices as
* its share of the run allows. A company with nothing left costs one query
* and no provider call (the pass checks our side before it touches the
* consent), so walking every live consent is cheap and no company waits
* behind a fixed page of newer ones. 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 remaining companies 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
}
/** 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)
.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 consents = scanned.filter((consent) => consentIsUsable(consent, now))
const deadline = now + RUN_BUDGET_MS
let skippedForBudget = 0
const totals = {
companies: 0,
candidates: 0,
completed: 0,
headersUpdated: 0,
remaining: 0,
notHydrated: 0,
totalMismatch: 0,
rowsMismatch: 0,
failed: 0,
}
const summary = await ctx.forEach('consent', consents, async (consent, itemCtx) => {
const budgetMs = Math.min(PER_COMPANY_BUDGET_MS, deadline - Date.now())
if (budgetMs < MIN_COMPANY_BUDGET_MS) {
skippedForBudget++
return
}
const result: CompleteInvoiceLinesResult = await completeMigratedInvoiceLines({
supabase,
companyId: consent.company_id,
consentId: consent.id,
budgetMs,
})
if (result.candidates > 0) {
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
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,
hydration: result.hydration,
})
}
})
ctx.log.info('complete-invoice-lines run finished', {
...totals, skippedForBudget, consents: summary.total, consentsStale: scanned.length - consents.length,
})
return NextResponse.json({
data: {
consents: summary.total,
consentsStale: scanned.length - consents.length,
consentsFailed: summary.failed,
skippedForBudget,
...totals,
},
})
})