* feat(webshop-orders): schema, types and error codes for the orders surface webshop_orders (order/refund rows, financial-freeze trigger, member select/update RLS, no DELETE) + webshop_store_settings (per-store payment method -> account map), source_type 'webshop_order', multi-store index drop, customer_country, and a one-time woo cursor reset so the switch-over backfills and cross-marks existing feed rows. Tables classified in the full-archive export; pg-real coverage for RLS, freeze and CHECK. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(webshop-orders): core service (ingest, booking lines) upsertWebshopOrders: two-phase order/refund upsert with FX enrichment, legacy-feed cross-marking, frozen-row protection and field-wise jsonb comparisons (Postgres does not preserve object key order). Booking-line builder: per-rate VAT split with SIGNED buckets (discounts book as revenue reductions), refund mirroring, 3740 residual, per-store account prefill, and advisory export/EU + OSS warnings. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(webshop-orders): API routes for list, booking, invoicing and mapping Booking is draft -> atomic claim -> commit (conditional link-back closes the concurrent double-book race; a lost claim cancels the voucher-free draft). Legacy-feed guard honors transactions.is_ignored on both the book and create-invoice paths. Invoice conversion reuses buildInvoiceWriteData for an unnumbered draft with dominant-rate fallback and drift-safe unit prices. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(webshop-orders): Orders page, booking/invoice dialogs and gated nav /orders lists per-store orders with status tabs (server-side filters), exception chips and one action per row. Booking dialog prefills from the per-store payment-method mapping with an opt-in remember; invoice dialog converts to a draft kundfaktura. The Order nav item renders only for companies with an active WooCommerce connection or existing order rows (Shopify deliberately excluded until its sync writes webshop_orders). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(woocommerce): switch the order sync to webshop_orders, multi-store The sync maps rich wc/v3 payloads (billing, line/shipping/fee taxes, refund allocations with parent-prorated VAT fallback) and upserts order rows instead of transactions-inbox rows; already-imported feed rows stay bookable and get cross-marked. Multi-store: several active connections per company, per-store panel cards with the account-mapping editor. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(webshop-orders): decision log entries and ratchet baseline Baseline moves DOWN only: naive-ore-round 638 -> 637 via roundOre adoption; hand-rolled invariants stay at 115 (ACCOUNT_NUMBER_RE imported, not inlined). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(webshop-orders): resolve PR #1525 review findings and CI failures Review batch (Superagent, CodeRabbit, Swedish compliance review): - Mutual-exclusion claims: booking guards invoice_id, invoice link-back guards journal_entry_id AND treats zero matched rows as the conflict it is (409 + rollback), closing both TOCTOU races. - Freeze v2 migration (20260812124858): the link columns themselves are protected: invoice links immutable, journal links clearable only while the entry is still a draft (the booking rollback path). - Scraped orgnr no longer auto-written to customers.org_number; rate fallback applies only on single-VAT-bucket orders; refunds get their own WEBSHOP_ORDER_REFUND_NOT_CONVERTIBLE code; VAT advisories outrank the invoice-mode hint in the booking dialog. - Ingest compares every synced field (billing corrections no longer drop as unchanged); sync guards absent refunds arrays; /sync aggregates per-store results; panel disables all cards while a request runs; orders page separates load failure from empty; account field explains itself. CI: regenerated skills/accounted-api; pg tests restructured for transaction-abort/rollback semantics + freeze-link coverage; unresolvable- expression ceiling 375 -> 378 with documented reason (partial-update payloads in ingest, shapes covered by unit tests). Declined: CodeRabbit docstring-coverage advisory (house style: comments only where the code cannot say it). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
440 lines
16 KiB
TypeScript
440 lines
16 KiB
TypeScript
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 { 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'
|
|
| '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'
|
|
>
|
|
|
|
function chunk<T>(items: T[], size: number): T[][] {
|
|
const out: T[][] = []
|
|
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size))
|
|
return out
|
|
}
|
|
|
|
function isFrozen(row: Pick<ExistingRow, 'journal_entry_id' | 'invoice_id'>): boolean {
|
|
return row.journal_entry_id !== null || row.invoice_id !== 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, 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
|
|
}
|