Files
accounted/lib/webshop-orders/ingest.ts
T
Jakob Wennberg f266c386f3 chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers (#2150)
* chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers

Remove 33 dead files, ~270 unreferenced exports/types, 13 dead i18n
namespaces and 4 unused dependencies; fold byte-identical helper copies
into one canonical home each (lib/utils chunk/sleep/utcDateStamp,
lib/dates/iso, lib/invariants/uuid, lib/xml/escape, lib/reports/sru/format,
lib/pdf/number-text, lib/browser/panel-request, lib/api/v1/body +
v1ValidationError rolled out to ~55 v1 routes, booking-template schemas).

No behaviour change: v1 bodies and status codes, MCP tool schemas, DB
writes and money math are untouched. Naive ore rounding was deliberately
not swapped for roundOre; see DECISIONS.md 2026-09-02 for the full list
of things left alone on purpose.

tsc, lint, 19588 unit tests and check:guards green; antipattern baseline
ratcheted (naive-ore-round 622 -> 620, hand-rolled-invariant 115 -> 113).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(transactions): import RawTransaction from @/types after the ingest re-export removal

CI's type ratchet (check:types, full tsconfig) caught the one test file
that still imported the type through lib/transactions/ingest.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 11:51:16 +02:00

566 lines
21 KiB
TypeScript

import { chunk } from '@/lib/utils'
import type { SupabaseClient } from '@supabase/supabase-js'
import { fetchExchangeRate } from '@/lib/currency/riksbanken'
import { roundOre as round } from '@/lib/money'
import { createLogger } from '@/lib/logger'
import type { Currency, WebshopOrder } from '@/types'
import type {
WebshopOrderRemovalResult,
WebshopOrderUpsert,
WebshopOrderUpsertResult,
} from './types'
const log = createLogger('webshop-orders/ingest')
/**
* Upsert service for webshop order rows: the single write path the platform
* syncs (woocommerce/shopify extensions) use, mirroring how the transactions
* feed goes through ingestTransactions().
*
* Unlike the append-only transactions feed, order rows are living mirrors:
* status changes, date_paid arriving later, growing refund totals and billing
* corrections all land as updates on (company_id, external_id). The boundary
* is the financial freeze: once a row is booked (journal_entry_id) or
* invoiced (invoice_id), financial fields are immutable (DB trigger). This
* service respects that application-side: a frozen row whose incoming
* financials differ gets remote_changed_after_freeze = true and only its
* safe fields updated, so the sync never trips the trigger and divergence is
* surfaced instead of silently dropped.
*
* Also owns:
* - FX enrichment: non-SEK rows get exchange_rate/total_sek via Riksbanken
* (rate date = paid date, falling back to order date). Unresolved rates
* leave total_sek null; booking is blocked until a later sync resolves it.
* - Legacy cross-marking: rows whose external_id already exists in the
* transactions feed (imported before the Orders switch-over) get
* legacy_transaction_id set, and the booking route refuses to double-book.
* - Refund parenting: refund rows resolve parent_external_id to
* parent_order_id (parent in the same batch or already in the table).
*/
/** Batch size for selects/inserts; keeps PostgREST URLs and payloads sane. */
const CHUNK_SIZE = 200
/** Financial fields the freeze protects; compared to detect remote drift. */
const FINANCIAL_FIELDS = [
'total',
'total_tax',
'currency',
'order_date',
'paid_date',
'is_paid',
'payment_method',
] as const
type ExistingRow = Pick<
WebshopOrder,
| 'id'
| 'external_id'
| 'journal_entry_id'
| 'invoice_id'
| 'manually_booked_at'
| 'legacy_transaction_id'
| 'remote_changed_after_freeze'
| 'total'
| 'total_tax'
| 'total_sek'
| 'exchange_rate'
| 'currency'
| 'order_date'
| 'paid_date'
| 'is_paid'
| 'payment_method'
| 'payment_method_title'
| 'gateway_reference'
| 'order_number'
| 'status'
| 'refunded_total'
| 'store_label'
| 'connection_id'
| 'customer_name'
| 'customer_company'
| 'customer_email'
| 'customer_orgnr'
| 'customer_country'
| 'vat_breakdown'
| 'line_items'
>
/**
* Rows whose financials must not be silently refreshed. Booked/invoiced rows
* are frozen by the DB trigger; manually marked rows (#1879) are treated the
* same APPLICATION-side: the user asserted "this row is covered by verifikat
* X", so a remote financial delta must surface as remote_changed_after_freeze
* (the same badge booked rows get) instead of mutating the row under that
* assertion and hiding the incremental business event forever.
*/
function isFrozen(
row: Pick<ExistingRow, 'journal_entry_id' | 'invoice_id' | 'manually_booked_at'>,
): boolean {
return (
row.journal_entry_id !== null ||
row.invoice_id !== null ||
row.manually_booked_at !== null
)
}
/**
* Field-wise jsonb array comparison. NEVER JSON.stringify: Postgres jsonb
* does not preserve object key order, so a stringify of what PostgREST
* returns differs from a stringify of what was inserted even when the
* values are identical (that bug falsely flagged every booked row as
* remote-changed on the first re-poll). Array ORDER is preserved by jsonb,
* so element-by-element field comparison is exact.
*/
function sameVatBreakdown(
a: ExistingRow['vat_breakdown'],
b: WebshopOrderUpsert['vat_breakdown'],
): boolean {
if (a.length !== b.length) return false
return a.every(
(bucket, i) =>
bucket.rate === b[i].rate && bucket.net === b[i].net && bucket.tax === b[i].tax,
)
}
function sameLineItems(
a: ExistingRow['line_items'],
b: WebshopOrderUpsert['line_items'],
): boolean {
if (a.length !== b.length) return false
return a.every(
(item, i) =>
item.name === b[i].name &&
item.quantity === b[i].quantity &&
item.total === b[i].total &&
item.total_tax === b[i].total_tax &&
(item.vat_rate ?? null) === (b[i].vat_rate ?? null),
)
}
function financialsDiffer(existing: ExistingRow, incoming: WebshopOrderUpsert): boolean {
for (const field of FINANCIAL_FIELDS) {
if ((existing[field] ?? null) !== (incoming[field] ?? null)) return true
}
return !sameVatBreakdown(existing.vat_breakdown, incoming.vat_breakdown)
}
/** Non-financial fields that keep syncing on every row, frozen or not. */
function safeFields(incoming: WebshopOrderUpsert) {
return {
status: incoming.status,
refunded_total: incoming.refunded_total,
store_label: incoming.store_label,
connection_id: incoming.connection_id,
}
}
interface FxResolution {
total_sek: number | null
exchange_rate: number | null
}
/**
* Resolve SEK amount for one row, with a per-run (currency, date) cache.
* Unknown currencies and Riksbanken failures resolve to nulls: booking stays
* blocked, never a fake 1:1 rate.
*/
async function resolveFx(
supabase: SupabaseClient,
row: WebshopOrderUpsert,
cache: Map<string, number | null>,
): Promise<FxResolution> {
const currency = row.currency.toUpperCase()
if (currency === 'SEK') return { total_sek: round(row.total), exchange_rate: 1 }
const rateDate = row.paid_date ?? row.order_date
const cacheKey = `${currency}:${rateDate}`
let rate = cache.get(cacheKey)
if (rate === undefined) {
try {
const result = await fetchExchangeRate(
currency as Currency,
new Date(`${rateDate}T00:00:00Z`),
supabase,
)
rate = result?.rate ?? null
} catch (err) {
log.warn('exchange rate fetch failed; row stays unbookable until resolved', {
currency,
rateDate,
message: err instanceof Error ? err.message : String(err),
})
rate = null
}
cache.set(cacheKey, rate)
}
if (rate === null) return { total_sek: null, exchange_rate: null }
return { total_sek: round(row.total * rate), exchange_rate: rate }
}
export async function upsertWebshopOrders(
supabase: SupabaseClient,
companyId: string,
userId: string,
rows: WebshopOrderUpsert[],
): Promise<WebshopOrderUpsertResult> {
const result: WebshopOrderUpsertResult = {
inserted: 0,
updated: 0,
unchanged: 0,
frozenFlagged: 0,
crossMarked: 0,
errors: 0,
}
if (rows.length === 0) return result
const recordError = (err: unknown) => {
result.errors += 1
if (!result.firstError) {
const anyErr = err as { message?: string; code?: string | null }
result.firstError = {
message: anyErr?.message ?? String(err),
code: anyErr?.code ?? null,
}
}
}
// Two phases: ALL order rows first, then refund rows. A refund's parent is
// resolved from knownIds, which is only populated once the parent's insert
// has actually executed; mixing both row types in one batch would leave
// same-batch refunds unparented.
const orderRows = rows.filter((r) => r.row_type === 'order')
const refundRows = rows.filter((r) => r.row_type === 'refund')
const fxCache = new Map<string, number | null>()
// external_id -> row id, for refund parenting across chunks.
const knownIds = new Map<string, string>()
for (const batch of [...chunk(orderRows, CHUNK_SIZE), ...chunk(refundRows, CHUNK_SIZE)]) {
const externalIds = batch.map((r) => r.external_id)
const parentIds = batch
.map((r) => r.parent_external_id)
.filter((id): id is string => id !== null && !knownIds.has(id))
const lookupIds = Array.from(new Set([...externalIds, ...parentIds]))
const { data: existingData, error: existingError } = await supabase
.from('webshop_orders')
.select(
'id, external_id, journal_entry_id, invoice_id, manually_booked_at, legacy_transaction_id, remote_changed_after_freeze, total, total_tax, total_sek, exchange_rate, currency, order_date, paid_date, is_paid, payment_method, payment_method_title, gateway_reference, order_number, status, refunded_total, store_label, connection_id, customer_name, customer_company, customer_email, customer_orgnr, customer_country, vat_breakdown, line_items',
)
.eq('company_id', companyId)
.in('external_id', lookupIds)
if (existingError) {
recordError(existingError)
continue
}
const existingByExternalId = new Map<string, ExistingRow>()
for (const row of (existingData ?? []) as ExistingRow[]) {
existingByExternalId.set(row.external_id, row)
knownIds.set(row.external_id, row.id)
}
// Legacy feed overlap for this batch, one query.
const { data: legacyData, error: legacyError } = await supabase
.from('transactions')
.select('id, external_id')
.eq('company_id', companyId)
.in('external_id', externalIds)
if (legacyError) {
recordError(legacyError)
continue
}
const legacyByExternalId = new Map<string, string>()
for (const row of (legacyData ?? []) as Array<{ id: string; external_id: string }>) {
legacyByExternalId.set(row.external_id, row.id)
}
const inserts: Array<Record<string, unknown>> = []
for (const incoming of batch) {
const existing = existingByExternalId.get(incoming.external_id)
const legacyTransactionId = legacyByExternalId.get(incoming.external_id) ?? null
const parentOrderId = incoming.parent_external_id
? (knownIds.get(incoming.parent_external_id) ?? null)
: null
if (!existing) {
const fx = await resolveFx(supabase, incoming, fxCache)
inserts.push({
company_id: companyId,
user_id: userId,
platform: incoming.platform,
store_scope: incoming.store_scope,
store_label: incoming.store_label,
connection_id: incoming.connection_id,
row_type: incoming.row_type,
parent_order_id: parentOrderId,
external_id: incoming.external_id,
platform_order_id: incoming.platform_order_id,
order_number: incoming.order_number,
status: incoming.status,
is_paid: incoming.is_paid,
order_date: incoming.order_date,
paid_date: incoming.paid_date,
currency: incoming.currency.toUpperCase(),
total: incoming.total,
total_tax: incoming.total_tax,
total_sek: fx.total_sek,
exchange_rate: fx.exchange_rate,
vat_breakdown: incoming.vat_breakdown,
line_items: incoming.line_items,
customer_name: incoming.customer_name,
customer_company: incoming.customer_company,
customer_email: incoming.customer_email,
customer_orgnr: incoming.customer_orgnr,
customer_country: incoming.customer_country,
payment_method: incoming.payment_method,
payment_method_title: incoming.payment_method_title,
gateway_reference: incoming.gateway_reference,
refunded_total: incoming.refunded_total,
legacy_transaction_id: legacyTransactionId,
})
if (legacyTransactionId) result.crossMarked += 1
continue
}
if (isFrozen(existing)) {
const drifted = financialsDiffer(existing, incoming)
const update: Record<string, unknown> = { ...safeFields(incoming) }
if (drifted && !existing.remote_changed_after_freeze) {
update.remote_changed_after_freeze = true
}
const changedSafe =
existing.status !== incoming.status ||
existing.refunded_total !== incoming.refunded_total ||
existing.store_label !== incoming.store_label ||
existing.connection_id !== incoming.connection_id ||
update.remote_changed_after_freeze === true
if (!changedSafe) {
result.unchanged += 1
continue
}
const { error } = await supabase
.from('webshop_orders')
.update(update)
.eq('id', existing.id)
.eq('company_id', companyId)
if (error) {
recordError(error)
} else {
result.updated += 1
if (drifted) result.frozenFlagged += 1
}
continue
}
// Unfrozen existing row: full refresh. Recompute FX only when the
// money or dates moved; otherwise keep the stored resolution (or
// retry a previously unresolved rate).
const moneyMoved = financialsDiffer(existing, incoming)
const needsFx = moneyMoved || existing.total_sek === null
const fx = needsFx
? await resolveFx(supabase, incoming, fxCache)
: { total_sek: existing.total_sek, exchange_rate: existing.exchange_rate }
const update: Record<string, unknown> = {
...safeFields(incoming),
order_number: incoming.order_number,
is_paid: incoming.is_paid,
order_date: incoming.order_date,
paid_date: incoming.paid_date,
currency: incoming.currency.toUpperCase(),
total: incoming.total,
total_tax: incoming.total_tax,
total_sek: fx.total_sek,
exchange_rate: fx.exchange_rate,
vat_breakdown: incoming.vat_breakdown,
line_items: incoming.line_items,
customer_name: incoming.customer_name,
customer_company: incoming.customer_company,
customer_email: incoming.customer_email,
customer_orgnr: incoming.customer_orgnr,
customer_country: incoming.customer_country,
payment_method: incoming.payment_method,
payment_method_title: incoming.payment_method_title,
gateway_reference: incoming.gateway_reference,
}
// Never null-out an already-resolved parent link (the parent may just
// be absent from this batch's lookup).
if (parentOrderId !== null) update.parent_order_id = parentOrderId
const newLegacyLink =
existing.legacy_transaction_id === null && legacyTransactionId !== null
if (newLegacyLink) {
update.legacy_transaction_id = legacyTransactionId
result.crossMarked += 1
}
// Every field the update payload writes must be compared here: a field
// written but not compared makes its corrections silently drop as
// "unchanged" (review finding: billing corrections).
const unchanged =
!moneyMoved &&
!needsFx &&
!newLegacyLink &&
existing.status === incoming.status &&
existing.refunded_total === incoming.refunded_total &&
existing.store_label === incoming.store_label &&
existing.connection_id === incoming.connection_id &&
existing.order_number === incoming.order_number &&
(existing.payment_method_title ?? null) === (incoming.payment_method_title ?? null) &&
(existing.gateway_reference ?? null) === (incoming.gateway_reference ?? null) &&
(existing.customer_name ?? null) === (incoming.customer_name ?? null) &&
(existing.customer_company ?? null) === (incoming.customer_company ?? null) &&
(existing.customer_email ?? null) === (incoming.customer_email ?? null) &&
(existing.customer_orgnr ?? null) === (incoming.customer_orgnr ?? null) &&
(existing.customer_country ?? null) === (incoming.customer_country ?? null) &&
sameLineItems(existing.line_items, incoming.line_items)
if (unchanged) {
result.unchanged += 1
continue
}
const { error } = await supabase
.from('webshop_orders')
.update(update)
.eq('id', existing.id)
.eq('company_id', companyId)
if (error) recordError(error)
else result.updated += 1
}
if (inserts.length > 0) {
const { data: insertedRows, error: insertError } = await supabase
.from('webshop_orders')
.insert(inserts)
.select('id, external_id')
if (insertError) {
recordError(insertError)
log.warn('webshop order insert batch failed', {
companyId,
batchSize: inserts.length,
code: (insertError as { code?: string }).code,
})
} else {
result.inserted += insertedRows?.length ?? 0
for (const row of (insertedRows ?? []) as Array<{ id: string; external_id: string }>) {
knownIds.set(row.external_id, row.id)
}
}
}
}
return result
}
/**
* Delete rows the store no longer considers money events (WooCommerce
* 'failed' payment attempts: user report, failed checkouts sat forever as
* unbookable "Ej betald" rows). The freeze boundary applies to deletes as it
* does to updates, application-side here because the table has no
* delete-protection trigger:
*
* - a frozen row (booked / invoiced / manually marked) is never deleted;
* - a paid row is never deleted: money moved at some point, and a paid row
* is also the only kind that can have refund children (parent_order_id
* cascades, so deleting one could take a booked refund row with it);
* - a cross-marked row (legacy_transaction_id) is never deleted: the order
* may be booked via the retired transactions feed without any freeze
* column set on this row;
* - every guard is repeated ON THE DELETE STATEMENT itself, not only on the
* candidate select (skeptic finding): a row booked between the select and
* the delete must survive, exactly like the conditional booking claim in
* book-order.ts. The frozen-child veto select below is defense in depth
* on top of the is_paid guard.
*/
export async function removeWebshopOrders(
supabase: SupabaseClient,
companyId: string,
externalIds: string[],
): Promise<WebshopOrderRemovalResult> {
const result: WebshopOrderRemovalResult = { removed: 0, errors: 0 }
if (externalIds.length === 0) return result
const recordError = (err: unknown) => {
result.errors += 1
if (!result.firstError) {
const anyErr = err as { message?: string; code?: string | null }
result.firstError = {
message: anyErr?.message ?? String(err),
code: anyErr?.code ?? null,
}
}
}
for (const batch of chunk(externalIds, CHUNK_SIZE)) {
const { data: candidates, error: candidateError } = await supabase
.from('webshop_orders')
.select('id')
.eq('company_id', companyId)
.in('external_id', batch)
.eq('is_paid', false)
.is('journal_entry_id', null)
.is('invoice_id', null)
.is('manually_booked_at', null)
.is('legacy_transaction_id', null)
if (candidateError) {
recordError(candidateError)
continue
}
const candidateIds = ((candidates ?? []) as Array<{ id: string }>).map((r) => r.id)
if (candidateIds.length === 0) continue
const { data: frozenChildren, error: childError } = await supabase
.from('webshop_orders')
.select('parent_order_id')
.eq('company_id', companyId)
.in('parent_order_id', candidateIds)
.or('journal_entry_id.not.is.null,invoice_id.not.is.null,manually_booked_at.not.is.null')
if (childError) {
recordError(childError)
continue
}
const vetoedParents = new Set(
((frozenChildren ?? []) as Array<{ parent_order_id: string }>).map(
(r) => r.parent_order_id,
),
)
const deletableIds = candidateIds.filter((id) => !vetoedParents.has(id))
if (deletableIds.length === 0) continue
const { data: deletedRows, error: deleteError } = await supabase
.from('webshop_orders')
.delete()
.eq('company_id', companyId)
.in('id', deletableIds)
.eq('is_paid', false)
.is('journal_entry_id', null)
.is('invoice_id', null)
.is('manually_booked_at', null)
.is('legacy_transaction_id', null)
.select('id')
if (deleteError) {
recordError(deleteError)
log.warn('webshop order removal batch failed', {
companyId,
batchSize: deletableIds.length,
code: (deleteError as { code?: string }).code,
})
} else {
const deletedIds = ((deletedRows ?? []) as Array<{ id: string }>).map((r) => r.id)
result.removed += deletedIds.length
if (deletedIds.length > 0) {
// Hard delete of financial staging rows: the log is the only record
// of what was removed and by which process (compliance finding, ISO
// A.8.10), since pre-bokföring rows carry no behandlingshistorik.
log.info('webshop order rows removed (store reports order failed)', {
companyId,
deletedIds,
requestedExternalIds: batch,
})
}
}
}
return result
}