feat(webshop-orders): Orders page with per-store, per-payment-method booking (#1525)
* 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>
This commit is contained in:
co-authored by
Claude Fable 5
parent
89cfa82f2f
commit
c35b2547fb
@@ -274,6 +274,7 @@ export const JournalEntrySourceTypeSchema = z.enum([
|
||||
'rot_rut_payout',
|
||||
'vat_settlement',
|
||||
'stripe_payout',
|
||||
'webshop_order',
|
||||
])
|
||||
|
||||
/** Query params for GET /api/bookkeeping/voucher-sequences/next. */
|
||||
@@ -1411,6 +1412,51 @@ export const BookTransactionSchema = z
|
||||
path: ['expected_duplicate_journal_entry_id'],
|
||||
})
|
||||
|
||||
// ── Webshop orders (Orders page) ──────────────────────────────
|
||||
|
||||
export const WebshopPlatformSchema = z.enum(['woocommerce', 'shopify'])
|
||||
|
||||
export const WebshopOrdersListQuerySchema = z.object({
|
||||
platform: WebshopPlatformSchema.optional(),
|
||||
store_scope: z.string().max(255).optional(),
|
||||
status: z.string().max(64).optional(),
|
||||
row_type: z.enum(['order', 'refund']).optional(),
|
||||
paid: z.enum(['paid', 'unpaid']).optional(),
|
||||
booked: z.enum(['booked', 'unbooked']).optional(),
|
||||
limit: z.coerce.number().int().min(1).max(200).optional(),
|
||||
offset: z.coerce.number().int().min(0).optional(),
|
||||
})
|
||||
|
||||
export const BookWebshopOrderSchema = z.object({
|
||||
fiscal_period_id: uuid,
|
||||
entry_date: isoDate,
|
||||
description: z.string().min(1, 'Description is required').max(500),
|
||||
lines: z.array(CreateJournalEntryLineSchema).min(2, 'At least two lines are required'),
|
||||
voucher_series: z
|
||||
.string()
|
||||
.regex(/^[A-Z]$/, 'Verifikationsserie måste vara en bokstav A-Z')
|
||||
.optional(),
|
||||
notes: z.string().max(2000).optional(),
|
||||
})
|
||||
|
||||
export const CreateInvoiceFromWebshopOrderSchema = z.object({
|
||||
/** Omitted: match by email/orgnr within the company, else create. */
|
||||
customer_id: uuid.optional(),
|
||||
})
|
||||
|
||||
/** {"<payment_method>": {mode:'book', account:'1930'} | {mode:'invoice'}} */
|
||||
export const WebshopStoreSettingsUpdateSchema = z.object({
|
||||
platform: WebshopPlatformSchema,
|
||||
store_scope: z.string().min(1).max(255),
|
||||
payment_method_account_map: z.record(
|
||||
z.string().min(1).max(64),
|
||||
z.discriminatedUnion('mode', [
|
||||
z.object({ mode: z.literal('book'), account: accountNumber }),
|
||||
z.object({ mode: z.literal('invoice') }),
|
||||
]),
|
||||
),
|
||||
})
|
||||
|
||||
/**
|
||||
* Edit a bank transaction's title (description). Only the working label:
|
||||
* gated server-side to unbooked, unmatched rows. Trimmed; whitespace-only is
|
||||
|
||||
@@ -3343,6 +3343,80 @@ const NETWORK_TRANSIENT_ENTRY: StructuredErrorEntry = {
|
||||
retryable: true,
|
||||
}
|
||||
|
||||
const WEBSHOP_ORDERS: Record<string, StructuredErrorEntry> = {
|
||||
WEBSHOP_ORDER_NOT_FOUND: {
|
||||
httpStatus: 404,
|
||||
message_sv: 'Ordern hittades inte.',
|
||||
message_en: 'The order was not found.',
|
||||
},
|
||||
WEBSHOP_ORDER_ALREADY_BOOKED: {
|
||||
httpStatus: 409,
|
||||
message_sv: 'Ordern är redan bokförd.',
|
||||
message_en: 'The order is already booked.',
|
||||
},
|
||||
WEBSHOP_ORDER_ALREADY_INVOICED: {
|
||||
httpStatus: 409,
|
||||
message_sv:
|
||||
'Ordern är kopplad till en kundfaktura. Bokföringen sker via fakturaflödet, inte direkt från ordern.',
|
||||
message_en:
|
||||
'The order is linked to a customer invoice. Bookkeeping happens through the invoice flow, not directly from the order.',
|
||||
},
|
||||
WEBSHOP_ORDER_NOT_PAID: {
|
||||
httpStatus: 409,
|
||||
message_sv:
|
||||
'Ordern är inte betald ännu. Obetalda ordrar bokförs när betalningen kommer, eller faktureras via Skapa faktura.',
|
||||
message_en:
|
||||
'The order is not paid yet. Unpaid orders are booked when payment arrives, or invoiced via Create invoice.',
|
||||
},
|
||||
WEBSHOP_ORDER_LEGACY_TRANSACTION_OPEN: {
|
||||
httpStatus: 409,
|
||||
message_sv:
|
||||
'Samma order ligger redan som en obokförd transaktion under Transaktioner (importerad av det tidigare orderflödet). Bokför eller ignorera den transaktionen först, så att samma affärshändelse inte bokförs två gånger.',
|
||||
message_en:
|
||||
'The same order already exists as an unbooked transaction under Transactions (imported by the previous order feed). Book or ignore that transaction first so the same business event is not booked twice.',
|
||||
},
|
||||
WEBSHOP_ORDER_LEGACY_TRANSACTION_BOOKED: {
|
||||
httpStatus: 409,
|
||||
message_sv:
|
||||
'Ordern är redan bokförd via en transaktion under Transaktioner (importerad av det tidigare orderflödet).',
|
||||
message_en:
|
||||
'The order is already booked via a transaction under Transactions (imported by the previous order feed).',
|
||||
},
|
||||
WEBSHOP_ORDER_FX_UNRESOLVED: {
|
||||
httpStatus: 422,
|
||||
message_sv:
|
||||
'Växelkursen för orderns valuta kunde inte hämtas ännu. Försök igen om en stund; ordern kan inte bokföras i SEK utan kurs.',
|
||||
message_en:
|
||||
'The exchange rate for the order currency could not be fetched yet. Try again shortly; the order cannot be booked in SEK without a rate.',
|
||||
},
|
||||
WEBSHOP_ORDER_REFUND_NOT_CONVERTIBLE: {
|
||||
httpStatus: 409,
|
||||
message_sv:
|
||||
'Återbetalningar kan inte omvandlas till fakturor. Hantera återbetalningen med en kreditfaktura från kundfakturan, eller bokför återbetalningsraden direkt.',
|
||||
message_en:
|
||||
'Refunds cannot be converted to invoices. Handle the refund with a credit note from the customer invoice, or book the refund row directly.',
|
||||
},
|
||||
WEBSHOP_ORDER_REFUND_PARENT_INVOICED: {
|
||||
httpStatus: 409,
|
||||
message_sv:
|
||||
'Ordern fakturerades via en kundfaktura. Återbetalningen hanteras med en kreditfaktura, inte genom att bokföra återbetalningsraden direkt.',
|
||||
message_en:
|
||||
'The order was invoiced through a customer invoice. Handle the refund with a credit note instead of booking the refund row directly.',
|
||||
},
|
||||
WEBSHOP_ORDER_CREATE_INVOICE_CUSTOMER_FAILED: {
|
||||
httpStatus: 500,
|
||||
message_sv: 'Kunden kunde inte skapas från orderns uppgifter.',
|
||||
message_en: 'The customer could not be created from the order data.',
|
||||
},
|
||||
WEBSHOP_ORDER_CREATE_INVOICE_MISSING_CUSTOMER: {
|
||||
httpStatus: 422,
|
||||
message_sv:
|
||||
'Ordern saknar kunduppgifter. Välj en befintlig kund att fakturera.',
|
||||
message_en:
|
||||
'The order has no customer data. Choose an existing customer to invoice.',
|
||||
},
|
||||
}
|
||||
|
||||
const NODE_SYSTEM: Record<string, StructuredErrorEntry> = {
|
||||
ECONNREFUSED: NETWORK_TRANSIENT_ENTRY,
|
||||
ECONNRESET: NETWORK_TRANSIENT_ENTRY,
|
||||
@@ -3396,6 +3470,7 @@ const REGISTRY: Record<string, StructuredErrorEntry> = {
|
||||
...BOLAGSVERKET,
|
||||
...ASSETS,
|
||||
...DIMENSION,
|
||||
...WEBSHOP_ORDERS,
|
||||
...NODE_SYSTEM,
|
||||
}
|
||||
|
||||
|
||||
@@ -899,6 +899,10 @@ export const MASTER_DATA_DUMP_TABLES: MasterDataTableSpec[] = [
|
||||
// NOTE: the date column on transactions is `date` (a previous spec said
|
||||
// booking_date, which does not exist: every backup got an error stub).
|
||||
{ name: 'transactions', file: 'transactions.json', orderBy: 'date' },
|
||||
// Webshop order rows are booking underlag (and carry customer personal
|
||||
// data), so they belong in the archive like transactions do.
|
||||
{ name: 'webshop_orders', file: 'webshop_orders.json', orderBy: 'order_date' },
|
||||
{ name: 'webshop_store_settings', file: 'webshop_store_settings.json' },
|
||||
{ name: 'transaction_voucher_links', file: 'transaction_voucher_links.json' },
|
||||
{ name: 'bank_file_imports', file: 'bank_file_imports.json', orderBy: 'created_at' },
|
||||
{ name: 'cash_accounts', file: 'cash_accounts.json' },
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
buildOrderBookingLines,
|
||||
fallbackVatBreakdown,
|
||||
resolveBookingWarnings,
|
||||
resolvePaymentAccount,
|
||||
DEFAULT_PAYMENT_ACCOUNT,
|
||||
} from '../booking-lines'
|
||||
import type { CreateJournalEntryLineInput, WebshopStoreSettings } from '@/types'
|
||||
|
||||
import { roundOre as round } from '@/lib/money'
|
||||
|
||||
function sumDebits(lines: CreateJournalEntryLineInput[]): number {
|
||||
return round(lines.reduce((sum, l) => sum + l.debit_amount, 0))
|
||||
}
|
||||
function sumCredits(lines: CreateJournalEntryLineInput[]): number {
|
||||
return round(lines.reduce((sum, l) => sum + l.credit_amount, 0))
|
||||
}
|
||||
function lineFor(lines: CreateJournalEntryLineInput[], account: string) {
|
||||
return lines.find((l) => l.account_number === account)
|
||||
}
|
||||
|
||||
const settings: WebshopStoreSettings = {
|
||||
id: 's1',
|
||||
company_id: 'c1',
|
||||
user_id: 'u1',
|
||||
platform: 'woocommerce',
|
||||
store_scope: 'butik.example.se',
|
||||
payment_method_account_map: {
|
||||
swish: { mode: 'book', account: '1930' },
|
||||
klarna_payments: { mode: 'book', account: '1580' },
|
||||
bacs: { mode: 'invoice' },
|
||||
},
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
}
|
||||
|
||||
function makeOrder(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
row_type: 'order' as const,
|
||||
order_number: '1042',
|
||||
payment_method: 'swish',
|
||||
payment_method_title: 'Swish',
|
||||
currency: 'SEK',
|
||||
total: 500,
|
||||
total_tax: 100,
|
||||
total_sek: 500,
|
||||
exchange_rate: 1,
|
||||
vat_breakdown: [{ rate: 25, net: 400, tax: 100 }],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('resolvePaymentAccount', () => {
|
||||
it('resolves a mapped book-mode account', () => {
|
||||
expect(resolvePaymentAccount(makeOrder(), settings)).toEqual({
|
||||
account: '1930',
|
||||
invoiceMode: false,
|
||||
mapped: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('flags invoice-mode methods', () => {
|
||||
const result = resolvePaymentAccount(makeOrder({ payment_method: 'bacs' }), settings)
|
||||
expect(result.invoiceMode).toBe(true)
|
||||
})
|
||||
|
||||
it('falls back to 1680 when unmapped or without settings', () => {
|
||||
expect(resolvePaymentAccount(makeOrder({ payment_method: 'stripe' }), settings).account).toBe(
|
||||
DEFAULT_PAYMENT_ACCOUNT,
|
||||
)
|
||||
expect(resolvePaymentAccount(makeOrder(), null).account).toBe(DEFAULT_PAYMENT_ACCOUNT)
|
||||
expect(resolvePaymentAccount(makeOrder({ payment_method: null }), settings).mapped).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('fallbackVatBreakdown', () => {
|
||||
it('infers 25/12/6 from the tax-to-net ratio', () => {
|
||||
expect(fallbackVatBreakdown(500, 100)).toEqual([{ rate: 25, net: 400, tax: 100 }])
|
||||
expect(fallbackVatBreakdown(112, 12)).toEqual([{ rate: 12, net: 100, tax: 12 }])
|
||||
expect(fallbackVatBreakdown(106, 6)).toEqual([{ rate: 6, net: 100, tax: 6 }])
|
||||
})
|
||||
|
||||
it('returns a 0% bucket for tax-free totals', () => {
|
||||
expect(fallbackVatBreakdown(300, 0)).toEqual([{ rate: 0, net: 300, tax: 0 }])
|
||||
})
|
||||
|
||||
it('falls back to a 25% bucket for unrecognizable mixes', () => {
|
||||
expect(fallbackVatBreakdown(500, 50)).toEqual([{ rate: 25, net: 450, tax: 50 }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildOrderBookingLines', () => {
|
||||
it('books a SEK 25% order: gross to mapped account, net to 3001, VAT to 2611', () => {
|
||||
const lines = buildOrderBookingLines({ order: makeOrder(), settings })
|
||||
expect(lines).toHaveLength(3)
|
||||
expect(lineFor(lines, '1930')).toMatchObject({ debit_amount: 500, credit_amount: 0 })
|
||||
expect(lineFor(lines, '3001')).toMatchObject({ debit_amount: 0, credit_amount: 400 })
|
||||
expect(lineFor(lines, '2611')).toMatchObject({ debit_amount: 0, credit_amount: 100 })
|
||||
expect(sumDebits(lines)).toBe(sumCredits(lines))
|
||||
})
|
||||
|
||||
it('splits mixed VAT rates across 3001/3002/3003 + 2611/2621/2631', () => {
|
||||
const lines = buildOrderBookingLines({
|
||||
order: makeOrder({
|
||||
total: 723,
|
||||
total_tax: 87,
|
||||
total_sek: 723,
|
||||
vat_breakdown: [
|
||||
{ rate: 25, net: 300, tax: 75 },
|
||||
{ rate: 12, net: 100, tax: 12 },
|
||||
{ rate: 6, net: 200, tax: 12 },
|
||||
{ rate: 0, net: 24, tax: 0 },
|
||||
],
|
||||
}),
|
||||
settings,
|
||||
})
|
||||
expect(lineFor(lines, '3001')?.credit_amount).toBe(300)
|
||||
expect(lineFor(lines, '3002')?.credit_amount).toBe(100)
|
||||
expect(lineFor(lines, '3003')?.credit_amount).toBe(200)
|
||||
expect(lineFor(lines, '3004')?.credit_amount).toBe(24)
|
||||
expect(lineFor(lines, '2611')?.credit_amount).toBe(75)
|
||||
expect(lineFor(lines, '2621')?.credit_amount).toBe(12)
|
||||
expect(lineFor(lines, '2631')?.credit_amount).toBe(12)
|
||||
expect(sumDebits(lines)).toBe(sumCredits(lines))
|
||||
})
|
||||
|
||||
it('sends rounding residual to 3740 and stays balanced', () => {
|
||||
const lines = buildOrderBookingLines({
|
||||
order: makeOrder({
|
||||
total: 500.05,
|
||||
total_sek: 500.05,
|
||||
vat_breakdown: [{ rate: 25, net: 400, tax: 100 }],
|
||||
}),
|
||||
settings,
|
||||
})
|
||||
expect(lineFor(lines, '3740')?.credit_amount).toBe(0.05)
|
||||
expect(sumDebits(lines)).toBe(sumCredits(lines))
|
||||
})
|
||||
|
||||
it('mirrors refund rows: credit the payment account, debit revenue and VAT', () => {
|
||||
const lines = buildOrderBookingLines({
|
||||
order: makeOrder({
|
||||
row_type: 'refund',
|
||||
total: -500,
|
||||
total_tax: -100,
|
||||
total_sek: -500,
|
||||
vat_breakdown: [{ rate: 25, net: 400, tax: 100 }],
|
||||
}),
|
||||
settings,
|
||||
})
|
||||
expect(lineFor(lines, '1930')).toMatchObject({ debit_amount: 0, credit_amount: 500 })
|
||||
expect(lineFor(lines, '3001')).toMatchObject({ debit_amount: 400, credit_amount: 0 })
|
||||
expect(lineFor(lines, '2611')).toMatchObject({ debit_amount: 100, credit_amount: 0 })
|
||||
expect(sumDebits(lines)).toBe(sumCredits(lines))
|
||||
})
|
||||
|
||||
it('books non-SEK orders in SEK with currency metadata on money lines only', () => {
|
||||
const lines = buildOrderBookingLines({
|
||||
order: makeOrder({
|
||||
currency: 'EUR',
|
||||
total: 100,
|
||||
total_tax: 20,
|
||||
total_sek: 1153,
|
||||
exchange_rate: 11.53,
|
||||
vat_breakdown: [{ rate: 25, net: 80, tax: 20 }],
|
||||
}),
|
||||
settings,
|
||||
})
|
||||
const gross = lineFor(lines, '1930')
|
||||
expect(gross).toMatchObject({
|
||||
debit_amount: 1153,
|
||||
currency: 'EUR',
|
||||
amount_in_currency: 100,
|
||||
exchange_rate: 11.53,
|
||||
})
|
||||
expect(lineFor(lines, '3001')).toMatchObject({
|
||||
credit_amount: round(80 * 11.53),
|
||||
amount_in_currency: 80,
|
||||
})
|
||||
const rounding = lineFor(lines, '3740')
|
||||
if (rounding) expect(rounding.currency).toBeUndefined()
|
||||
expect(sumDebits(lines)).toBe(sumCredits(lines))
|
||||
})
|
||||
|
||||
it('honors an explicit payment-account override from the dialog', () => {
|
||||
const lines = buildOrderBookingLines({
|
||||
order: makeOrder(),
|
||||
settings,
|
||||
paymentAccount: '1686',
|
||||
})
|
||||
expect(lineFor(lines, '1686')?.debit_amount).toBe(500)
|
||||
})
|
||||
|
||||
it('uses the fallback breakdown when the sync produced none', () => {
|
||||
const lines = buildOrderBookingLines({
|
||||
order: makeOrder({ vat_breakdown: [] }),
|
||||
settings,
|
||||
})
|
||||
expect(lineFor(lines, '3001')?.credit_amount).toBe(400)
|
||||
expect(lineFor(lines, '2611')?.credit_amount).toBe(100)
|
||||
expect(sumDebits(lines)).toBe(sumCredits(lines))
|
||||
})
|
||||
|
||||
it('books a NEGATIVE discount bucket on the opposite side, never abs-flipped', () => {
|
||||
// Gift-card/discount plugin: 25% product 500+125, 0% discount -100.
|
||||
// The old abs() logic booked C 3004 100 + D 3740 200: balanced, wrong.
|
||||
const lines = buildOrderBookingLines({
|
||||
order: makeOrder({
|
||||
total: 525,
|
||||
total_tax: 125,
|
||||
total_sek: 525,
|
||||
vat_breakdown: [
|
||||
{ rate: 25, net: 500, tax: 125 },
|
||||
{ rate: 0, net: -100, tax: 0 },
|
||||
],
|
||||
}),
|
||||
settings,
|
||||
})
|
||||
expect(lineFor(lines, '1930')).toMatchObject({ debit_amount: 525 })
|
||||
expect(lineFor(lines, '3001')).toMatchObject({ credit_amount: 500 })
|
||||
expect(lineFor(lines, '2611')).toMatchObject({ credit_amount: 125 })
|
||||
expect(lineFor(lines, '3004')).toMatchObject({ debit_amount: 100, credit_amount: 0 })
|
||||
expect(lineFor(lines, '3740')).toBeUndefined()
|
||||
expect(sumDebits(lines)).toBe(sumCredits(lines))
|
||||
})
|
||||
|
||||
it('throws when a non-SEK order has no resolved SEK amount', () => {
|
||||
expect(() =>
|
||||
buildOrderBookingLines({
|
||||
order: makeOrder({ currency: 'EUR', total_sek: null, exchange_rate: null }),
|
||||
settings,
|
||||
}),
|
||||
).toThrow(/exchange rate/i)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveBookingWarnings', () => {
|
||||
it('flags a 0%-amount to a known non-SE country', () => {
|
||||
expect(
|
||||
resolveBookingWarnings({
|
||||
currency: 'SEK',
|
||||
total_tax: 0,
|
||||
vat_breakdown: [{ rate: 0, net: 2000, tax: 0 }],
|
||||
customer_country: 'NO',
|
||||
}),
|
||||
).toEqual(['zero_rate_foreign'])
|
||||
})
|
||||
|
||||
it('stays quiet for domestic momsfri and unknown countries', () => {
|
||||
expect(
|
||||
resolveBookingWarnings({
|
||||
currency: 'SEK',
|
||||
total_tax: 0,
|
||||
vat_breakdown: [{ rate: 0, net: 2000, tax: 0 }],
|
||||
customer_country: 'SE',
|
||||
}),
|
||||
).toEqual([])
|
||||
expect(
|
||||
resolveBookingWarnings({
|
||||
currency: 'SEK',
|
||||
total_tax: 0,
|
||||
vat_breakdown: [{ rate: 0, net: 2000, tax: 0 }],
|
||||
customer_country: null,
|
||||
}),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('flags VAT charged on a non-SEK order (OSS check)', () => {
|
||||
expect(
|
||||
resolveBookingWarnings({
|
||||
currency: 'EUR',
|
||||
total_tax: 20,
|
||||
vat_breakdown: [{ rate: 25, net: 80, tax: 20 }],
|
||||
customer_country: 'DK',
|
||||
}),
|
||||
).toEqual(['foreign_vat'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,296 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
import { upsertWebshopOrders } from '../ingest'
|
||||
import type { WebshopOrderUpsert } from '../types'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
|
||||
vi.mock('@/lib/currency/riksbanken', () => ({
|
||||
fetchExchangeRate: vi.fn(async (currency: string) =>
|
||||
currency === 'SEK'
|
||||
? { currency, rate: 1, date: '2026-08-01' }
|
||||
: currency === 'EUR'
|
||||
? { currency, rate: 11.5, date: '2026-08-01' }
|
||||
: null,
|
||||
),
|
||||
}))
|
||||
|
||||
const COMPANY = 'company-1'
|
||||
const USER = 'user-1'
|
||||
|
||||
function makeUpsert(overrides: Partial<WebshopOrderUpsert> = {}): WebshopOrderUpsert {
|
||||
return {
|
||||
platform: 'woocommerce',
|
||||
store_scope: 'butik.example.se',
|
||||
store_label: 'Butiken',
|
||||
connection_id: 'conn-1',
|
||||
row_type: 'order',
|
||||
parent_external_id: null,
|
||||
external_id: 'woo_butik.example.se_order_1001',
|
||||
platform_order_id: '1001',
|
||||
order_number: '1001',
|
||||
status: 'processing',
|
||||
is_paid: true,
|
||||
order_date: '2026-08-01',
|
||||
paid_date: '2026-08-01',
|
||||
currency: 'SEK',
|
||||
total: 500,
|
||||
total_tax: 100,
|
||||
vat_breakdown: [{ rate: 25, net: 400, tax: 100 }],
|
||||
line_items: [],
|
||||
customer_name: 'Test Person',
|
||||
customer_company: null,
|
||||
customer_email: 'test@example.se',
|
||||
customer_orgnr: null,
|
||||
customer_country: 'SE',
|
||||
payment_method: 'swish',
|
||||
payment_method_title: 'Swish',
|
||||
gateway_reference: null,
|
||||
refunded_total: 0,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function existingRow(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'row-1',
|
||||
external_id: 'woo_butik.example.se_order_1001',
|
||||
journal_entry_id: null,
|
||||
invoice_id: null,
|
||||
legacy_transaction_id: null,
|
||||
remote_changed_after_freeze: false,
|
||||
total: 500,
|
||||
total_tax: 100,
|
||||
total_sek: 500,
|
||||
exchange_rate: 1,
|
||||
currency: 'SEK',
|
||||
order_date: '2026-08-01',
|
||||
paid_date: '2026-08-01',
|
||||
is_paid: true,
|
||||
payment_method: 'swish',
|
||||
payment_method_title: 'Swish',
|
||||
gateway_reference: null,
|
||||
order_number: '1001',
|
||||
status: 'processing',
|
||||
refunded_total: 0,
|
||||
store_label: 'Butiken',
|
||||
connection_id: 'conn-1',
|
||||
customer_name: 'Test Person',
|
||||
customer_company: null,
|
||||
customer_email: 'test@example.se',
|
||||
customer_orgnr: null,
|
||||
customer_country: 'SE',
|
||||
vat_breakdown: [{ rate: 25, net: 400, tax: 100 }],
|
||||
line_items: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('upsertWebshopOrders', () => {
|
||||
let mock: ReturnType<typeof createQueuedMockSupabase>
|
||||
const supabase = () => mock.supabase as unknown as SupabaseClient
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mock = createQueuedMockSupabase()
|
||||
})
|
||||
|
||||
it('inserts a new SEK order with resolved FX and no legacy link', async () => {
|
||||
mock.enqueueMany([
|
||||
{ data: [] }, // existing webshop_orders
|
||||
{ data: [] }, // legacy transactions
|
||||
{ data: [{ id: 'new-1', external_id: 'woo_butik.example.se_order_1001' }] }, // insert
|
||||
])
|
||||
|
||||
const result = await upsertWebshopOrders(supabase(), COMPANY, USER, [makeUpsert()])
|
||||
|
||||
expect(result).toMatchObject({ inserted: 1, updated: 0, errors: 0, crossMarked: 0 })
|
||||
const insertArgs = mock.findCall('webshop_orders', 'insert')
|
||||
expect(insertArgs).toBeDefined()
|
||||
const payload = (insertArgs![0] as Record<string, unknown>[])[0]
|
||||
expect(payload).toMatchObject({
|
||||
company_id: COMPANY,
|
||||
user_id: USER,
|
||||
external_id: 'woo_butik.example.se_order_1001',
|
||||
total_sek: 500,
|
||||
exchange_rate: 1,
|
||||
legacy_transaction_id: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('parents a refund to an order inserted in the same call', async () => {
|
||||
mock.enqueueMany([
|
||||
{ data: [] }, // existing (order phase)
|
||||
{ data: [] }, // legacy (order phase)
|
||||
{ data: [{ id: 'order-id-1', external_id: 'woo_butik.example.se_order_1001' }] },
|
||||
{ data: [] }, // existing (refund phase; parent already known via knownIds)
|
||||
{ data: [] }, // legacy (refund phase)
|
||||
{ data: [{ id: 'refund-id-1', external_id: 'woo_butik.example.se_refund_77' }] },
|
||||
])
|
||||
|
||||
const refund = makeUpsert({
|
||||
row_type: 'refund',
|
||||
parent_external_id: 'woo_butik.example.se_order_1001',
|
||||
external_id: 'woo_butik.example.se_refund_77',
|
||||
platform_order_id: '77',
|
||||
total: -500,
|
||||
total_tax: -100,
|
||||
refunded_total: 0,
|
||||
})
|
||||
const result = await upsertWebshopOrders(supabase(), COMPANY, USER, [
|
||||
refund,
|
||||
makeUpsert(),
|
||||
])
|
||||
|
||||
expect(result.inserted).toBe(2)
|
||||
const inserts = mock.findCalls('webshop_orders', 'insert')
|
||||
expect(inserts).toHaveLength(2)
|
||||
const refundPayload = (inserts[1][0] as Record<string, unknown>[])[0]
|
||||
expect(refundPayload).toMatchObject({
|
||||
row_type: 'refund',
|
||||
parent_order_id: 'order-id-1',
|
||||
})
|
||||
})
|
||||
|
||||
it('cross-marks rows whose external_id already exists in the transactions feed', async () => {
|
||||
mock.enqueueMany([
|
||||
{ data: [] },
|
||||
{ data: [{ id: 'txn-9', external_id: 'woo_butik.example.se_order_1001' }] },
|
||||
{ data: [{ id: 'new-1', external_id: 'woo_butik.example.se_order_1001' }] },
|
||||
])
|
||||
|
||||
const result = await upsertWebshopOrders(supabase(), COMPANY, USER, [makeUpsert()])
|
||||
|
||||
expect(result.crossMarked).toBe(1)
|
||||
const payload = (mock.findCall('webshop_orders', 'insert')![0] as Record<string, unknown>[])[0]
|
||||
expect(payload.legacy_transaction_id).toBe('txn-9')
|
||||
})
|
||||
|
||||
it('updates an unfrozen existing row when status moves', async () => {
|
||||
mock.enqueueMany([
|
||||
{ data: [existingRow()] },
|
||||
{ data: [] },
|
||||
{ data: null }, // update
|
||||
])
|
||||
|
||||
const result = await upsertWebshopOrders(supabase(), COMPANY, USER, [
|
||||
makeUpsert({ status: 'completed' }),
|
||||
])
|
||||
|
||||
expect(result).toMatchObject({ inserted: 0, updated: 1, unchanged: 0 })
|
||||
const updateArgs = mock.findCall('webshop_orders', 'update')
|
||||
expect(updateArgs).toBeDefined()
|
||||
expect((updateArgs![0] as Record<string, unknown>).status).toBe('completed')
|
||||
})
|
||||
|
||||
it('counts an identical re-poll as unchanged without writing', async () => {
|
||||
mock.enqueueMany([{ data: [existingRow()] }, { data: [] }])
|
||||
|
||||
const result = await upsertWebshopOrders(supabase(), COMPANY, USER, [makeUpsert()])
|
||||
|
||||
expect(result).toMatchObject({ inserted: 0, updated: 0, unchanged: 1 })
|
||||
expect(mock.findCall('webshop_orders', 'update')).toBeUndefined()
|
||||
expect(mock.findCall('webshop_orders', 'insert')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('treats jsonb-reordered keys as unchanged (Postgres does not preserve key order)', async () => {
|
||||
// What PostgREST returns: same VALUES, different object key order than
|
||||
// what the sync inserts. JSON.stringify comparison falsely flagged every
|
||||
// such row as changed (and every booked row as remote-drifted).
|
||||
mock.enqueueMany([
|
||||
{
|
||||
data: [
|
||||
existingRow({
|
||||
vat_breakdown: [{ net: 400, tax: 100, rate: 25 }],
|
||||
line_items: [],
|
||||
}),
|
||||
],
|
||||
},
|
||||
{ data: [] },
|
||||
])
|
||||
|
||||
const result = await upsertWebshopOrders(supabase(), COMPANY, USER, [makeUpsert()])
|
||||
|
||||
expect(result).toMatchObject({ unchanged: 1, updated: 0, frozenFlagged: 0 })
|
||||
expect(mock.findCall('webshop_orders', 'update')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not flag a booked row when only key order differs', async () => {
|
||||
mock.enqueueMany([
|
||||
{
|
||||
data: [
|
||||
existingRow({
|
||||
journal_entry_id: 'je-1',
|
||||
vat_breakdown: [{ tax: 100, rate: 25, net: 400 }],
|
||||
}),
|
||||
],
|
||||
},
|
||||
{ data: [] },
|
||||
])
|
||||
|
||||
const result = await upsertWebshopOrders(supabase(), COMPANY, USER, [makeUpsert()])
|
||||
|
||||
expect(result.frozenFlagged).toBe(0)
|
||||
expect(mock.findCall('webshop_orders', 'update')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('flags a booked row whose financials drifted instead of updating them', async () => {
|
||||
mock.enqueueMany([
|
||||
{ data: [existingRow({ journal_entry_id: 'je-1' })] },
|
||||
{ data: [] },
|
||||
{ data: null }, // safe-field update
|
||||
])
|
||||
|
||||
const result = await upsertWebshopOrders(supabase(), COMPANY, USER, [
|
||||
makeUpsert({ total: 600, status: 'completed' }),
|
||||
])
|
||||
|
||||
expect(result.frozenFlagged).toBe(1)
|
||||
const update = mock.findCall('webshop_orders', 'update')![0] as Record<string, unknown>
|
||||
expect(update.remote_changed_after_freeze).toBe(true)
|
||||
expect(update.status).toBe('completed')
|
||||
expect(update).not.toHaveProperty('total')
|
||||
expect(update).not.toHaveProperty('total_sek')
|
||||
expect(update).not.toHaveProperty('paid_date')
|
||||
})
|
||||
|
||||
it('leaves total_sek null when the exchange rate cannot resolve', async () => {
|
||||
mock.enqueueMany([
|
||||
{ data: [] },
|
||||
{ data: [] },
|
||||
{ data: [{ id: 'new-1', external_id: 'woo_butik.example.se_order_1001' }] },
|
||||
])
|
||||
|
||||
await upsertWebshopOrders(supabase(), COMPANY, USER, [
|
||||
makeUpsert({ currency: 'ISK', total: 900 }),
|
||||
])
|
||||
|
||||
const payload = (mock.findCall('webshop_orders', 'insert')![0] as Record<string, unknown>[])[0]
|
||||
expect(payload.total_sek).toBeNull()
|
||||
expect(payload.exchange_rate).toBeNull()
|
||||
})
|
||||
|
||||
it('converts non-SEK totals with the fetched rate', async () => {
|
||||
mock.enqueueMany([
|
||||
{ data: [] },
|
||||
{ data: [] },
|
||||
{ data: [{ id: 'new-1', external_id: 'woo_butik.example.se_order_1001' }] },
|
||||
])
|
||||
|
||||
await upsertWebshopOrders(supabase(), COMPANY, USER, [
|
||||
makeUpsert({ currency: 'EUR', total: 100, total_tax: 20 }),
|
||||
])
|
||||
|
||||
const payload = (mock.findCall('webshop_orders', 'insert')![0] as Record<string, unknown>[])[0]
|
||||
expect(payload.total_sek).toBe(1150)
|
||||
expect(payload.exchange_rate).toBe(11.5)
|
||||
})
|
||||
|
||||
it('surfaces select errors without throwing', async () => {
|
||||
mock.enqueueMany([{ data: null, error: { message: 'boom', code: '500' } }])
|
||||
|
||||
const result = await upsertWebshopOrders(supabase(), COMPANY, USER, [makeUpsert()])
|
||||
|
||||
expect(result.errors).toBe(1)
|
||||
expect(result.firstError?.message).toBe('boom')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,251 @@
|
||||
import { roundOre as round } from '@/lib/money'
|
||||
import type {
|
||||
CreateJournalEntryLineInput,
|
||||
WebshopOrder,
|
||||
WebshopStoreSettings,
|
||||
WebshopVatBreakdownLine,
|
||||
} from '@/types'
|
||||
|
||||
/**
|
||||
* Pure builder for the journal lines that PREFILL the order booking dialog.
|
||||
* Never books anything on its own: the user reviews and can override every
|
||||
* line (manual-base doctrine), and the server only validates what comes back
|
||||
* through the engine.
|
||||
*
|
||||
* Shape for an order row (Swish 500 kr incl. 25%):
|
||||
* Debit 1930 (mapped by payment_method) 500.00 gross
|
||||
* Credit 3001 400.00 net per rate
|
||||
* Credit 2611 100.00 VAT per rate
|
||||
* Refund rows mirror (debit revenue/VAT, credit the payment account).
|
||||
* A rounding residual goes to 3740 Öresavrundning so the entry balances.
|
||||
*
|
||||
* Non-SEK orders book in SEK via the row's stored exchange_rate (rate date =
|
||||
* paid date, captured at sync); every line carries the currency metadata trio
|
||||
* exactly like the transaction booking dialog does. Callers must not invoke
|
||||
* this while total_sek is null (booking is blocked until FX resolves).
|
||||
*/
|
||||
|
||||
/** Fallback counter-account when no mapping exists: the old feed's ledger. */
|
||||
export const DEFAULT_PAYMENT_ACCOUNT = '1680'
|
||||
|
||||
/** Revenue account per Swedish VAT rate (BAS 2026). */
|
||||
const REVENUE_ACCOUNT_BY_RATE: Record<number, string> = {
|
||||
25: '3001',
|
||||
12: '3002',
|
||||
6: '3003',
|
||||
0: '3004',
|
||||
}
|
||||
|
||||
/** Output VAT account per rate. */
|
||||
const VAT_ACCOUNT_BY_RATE: Record<number, string> = {
|
||||
25: '2611',
|
||||
12: '2621',
|
||||
6: '2631',
|
||||
}
|
||||
|
||||
/** Öresavrundning. */
|
||||
const ROUNDING_ACCOUNT = '3740'
|
||||
|
||||
/**
|
||||
* Resolve the prefilled payment counter-account for an order from the
|
||||
* per-store mapping. Returns the account plus whether the store marked this
|
||||
* payment method as invoice-flow (the dialog then nudges toward Skapa
|
||||
* faktura instead).
|
||||
*/
|
||||
export function resolvePaymentAccount(
|
||||
order: Pick<WebshopOrder, 'payment_method'>,
|
||||
settings: WebshopStoreSettings | null | undefined,
|
||||
): { account: string; invoiceMode: boolean; mapped: boolean } {
|
||||
const method = order.payment_method
|
||||
const policy = method ? settings?.payment_method_account_map?.[method] : undefined
|
||||
if (!policy) return { account: DEFAULT_PAYMENT_ACCOUNT, invoiceMode: false, mapped: false }
|
||||
if (policy.mode === 'invoice') {
|
||||
return { account: DEFAULT_PAYMENT_ACCOUNT, invoiceMode: true, mapped: true }
|
||||
}
|
||||
return { account: policy.account, invoiceMode: false, mapped: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* When the sync could not build a per-rate breakdown (blocked tax endpoints,
|
||||
* plugin-mangled orders), fall back to one bucket whose rate is inferred
|
||||
* from the tax/net ratio; null rate when nothing matches, so the dialog
|
||||
* shows an editable guess instead of silently wrong accounts.
|
||||
*/
|
||||
export function fallbackVatBreakdown(
|
||||
total: number,
|
||||
totalTax: number,
|
||||
): WebshopVatBreakdownLine[] {
|
||||
const net = round(Math.abs(total) - Math.abs(totalTax))
|
||||
const tax = round(Math.abs(totalTax))
|
||||
if (net <= 0) return [{ rate: 0, net: round(Math.abs(total)), tax: 0 }]
|
||||
if (tax === 0) return [{ rate: 0, net, tax: 0 }]
|
||||
const ratio = tax / net
|
||||
for (const rate of [25, 12, 6]) {
|
||||
if (Math.abs(ratio - rate / 100) < 0.005) return [{ rate, net, tax }]
|
||||
}
|
||||
// Unknown mix: present as 25% bucket for the user to correct.
|
||||
return [{ rate: 25, net, tax }]
|
||||
}
|
||||
|
||||
export type BookingWarning = 'zero_rate_foreign' | 'foreign_vat'
|
||||
|
||||
/**
|
||||
* Advisory (never blocking, per the soft-guard rule) compliance hints for
|
||||
* the booking dialog:
|
||||
* - zero_rate_foreign: a 0%-rate amount on an order with a known non-SE
|
||||
* billing country. The prefill's 3004 (ruta 42) is only right for
|
||||
* domestic momsfri sales; export/EU sales belong on 31xx/33xx accounts.
|
||||
* - foreign_vat: VAT charged on a non-SEK order. Swedish 2611-series output
|
||||
* VAT may be wrong if the merchant is over the EU distance-selling
|
||||
* threshold (OSS) — the store's tax setup decides, the user must check.
|
||||
*/
|
||||
export function resolveBookingWarnings(
|
||||
order: Pick<
|
||||
WebshopOrder,
|
||||
'currency' | 'total_tax' | 'vat_breakdown' | 'customer_country'
|
||||
>,
|
||||
): BookingWarning[] {
|
||||
const warnings: BookingWarning[] = []
|
||||
const hasZeroRateAmount = order.vat_breakdown.some(
|
||||
(b) => b.rate === 0 && b.net !== 0,
|
||||
)
|
||||
if (
|
||||
hasZeroRateAmount &&
|
||||
order.customer_country &&
|
||||
order.customer_country.toUpperCase() !== 'SE'
|
||||
) {
|
||||
warnings.push('zero_rate_foreign')
|
||||
}
|
||||
if (order.currency.toUpperCase() !== 'SEK' && order.total_tax !== 0) {
|
||||
warnings.push('foreign_vat')
|
||||
}
|
||||
return warnings
|
||||
}
|
||||
|
||||
export interface OrderBookingLinesInput {
|
||||
order: Pick<
|
||||
WebshopOrder,
|
||||
| 'row_type'
|
||||
| 'order_number'
|
||||
| 'payment_method'
|
||||
| 'payment_method_title'
|
||||
| 'currency'
|
||||
| 'total'
|
||||
| 'total_tax'
|
||||
| 'total_sek'
|
||||
| 'exchange_rate'
|
||||
| 'vat_breakdown'
|
||||
>
|
||||
settings?: WebshopStoreSettings | null
|
||||
/** Explicit override of the payment counter-account (dialog edit). */
|
||||
paymentAccount?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Build balanced prefill lines for one order/refund row. Throws if total_sek
|
||||
* is required but unresolved: callers gate on it first.
|
||||
*/
|
||||
export function buildOrderBookingLines({
|
||||
order,
|
||||
settings,
|
||||
paymentAccount,
|
||||
}: OrderBookingLinesInput): CreateJournalEntryLineInput[] {
|
||||
const isSek = order.currency.toUpperCase() === 'SEK'
|
||||
const rate = isSek ? 1 : order.exchange_rate
|
||||
const grossSek = isSek ? round(order.total) : order.total_sek
|
||||
if (grossSek === null || grossSek === undefined || !rate) {
|
||||
throw new Error('Order is missing a resolved SEK amount; booking is blocked until the exchange rate resolves')
|
||||
}
|
||||
|
||||
const account = paymentAccount ?? resolvePaymentAccount(order, settings).account
|
||||
// Refund rows carry negative totals; build everything from magnitudes and
|
||||
// apply direction at the end so debit/credit never go negative.
|
||||
const isRefund = order.row_type === 'refund'
|
||||
const grossAbs = round(Math.abs(grossSek))
|
||||
|
||||
const breakdown =
|
||||
order.vat_breakdown.length > 0
|
||||
? order.vat_breakdown
|
||||
: fallbackVatBreakdown(order.total, order.total_tax)
|
||||
|
||||
const toSek = (amount: number) => round(Math.abs(amount) * rate)
|
||||
|
||||
const methodLabel = order.payment_method_title || order.payment_method || ''
|
||||
const baseDescription = methodLabel
|
||||
? `Order ${order.order_number} (${methodLabel})`
|
||||
: `Order ${order.order_number}`
|
||||
const description = isRefund
|
||||
? `Återbetalning order ${order.order_number}`
|
||||
: baseDescription
|
||||
|
||||
const currencyMeta = (amountAbs: number): Partial<CreateJournalEntryLineInput> =>
|
||||
isSek
|
||||
? {}
|
||||
: {
|
||||
currency: order.currency.toUpperCase(),
|
||||
amount_in_currency: amountAbs,
|
||||
exchange_rate: rate,
|
||||
}
|
||||
|
||||
const line = (
|
||||
accountNumber: string,
|
||||
sekAbs: number,
|
||||
side: 'debit' | 'credit',
|
||||
originalAbs: number,
|
||||
): CreateJournalEntryLineInput => ({
|
||||
account_number: accountNumber,
|
||||
debit_amount: side === 'debit' ? sekAbs : 0,
|
||||
credit_amount: side === 'credit' ? sekAbs : 0,
|
||||
line_description: description,
|
||||
...currencyMeta(originalAbs),
|
||||
})
|
||||
|
||||
// Order: money in (debit payment account); refund: money out (credit).
|
||||
const grossSide: 'debit' | 'credit' = isRefund ? 'credit' : 'debit'
|
||||
const counterSide: 'debit' | 'credit' = isRefund ? 'debit' : 'credit'
|
||||
|
||||
const lines: CreateJournalEntryLineInput[] = [
|
||||
line(account, grossAbs, grossSide, round(Math.abs(order.total))),
|
||||
]
|
||||
|
||||
// Buckets are SIGNED: a discount/gift-card bucket carries a negative net
|
||||
// and must book on the OPPOSITE side (a revenue reduction), never as
|
||||
// abs-flipped extra revenue with the difference dumped on 3740 (skeptic
|
||||
// finding). counterSum accumulates the signed counter-direction total so
|
||||
// the residual stays a pure öre artifact.
|
||||
let counterSum = 0
|
||||
const pushSigned = (account: string, signedOriginal: number) => {
|
||||
if (signedOriginal === 0) return
|
||||
const amountAbs = round(Math.abs(signedOriginal))
|
||||
const sekAbs = toSek(amountAbs)
|
||||
if (sekAbs === 0) return
|
||||
const side = signedOriginal > 0 ? counterSide : grossSide
|
||||
lines.push(line(account, sekAbs, side, amountAbs))
|
||||
counterSum = round(counterSum + (signedOriginal > 0 ? sekAbs : -sekAbs))
|
||||
}
|
||||
for (const bucket of breakdown) {
|
||||
const revenueAccount =
|
||||
REVENUE_ACCOUNT_BY_RATE[bucket.rate] ?? REVENUE_ACCOUNT_BY_RATE[25]
|
||||
const vatAccount = VAT_ACCOUNT_BY_RATE[bucket.rate] ?? VAT_ACCOUNT_BY_RATE[25]
|
||||
pushSigned(revenueAccount, round(bucket.net))
|
||||
pushSigned(vatAccount, round(bucket.tax))
|
||||
}
|
||||
|
||||
// Balance residual (per-line rounding, FX drift) to öresavrundning. The
|
||||
// residual can fall on either side. No currency metadata: the residual is
|
||||
// an SEK-conversion artifact, not an amount that exists in the order
|
||||
// currency.
|
||||
const residual = round(grossAbs - counterSum)
|
||||
if (residual !== 0) {
|
||||
const side: 'debit' | 'credit' = residual > 0 ? counterSide : grossSide
|
||||
const residualAbs = round(Math.abs(residual))
|
||||
lines.push({
|
||||
account_number: ROUNDING_ACCOUNT,
|
||||
debit_amount: side === 'debit' ? residualAbs : 0,
|
||||
credit_amount: side === 'credit' ? residualAbs : 0,
|
||||
line_description: description,
|
||||
})
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type {
|
||||
WebshopOrderLineItem,
|
||||
WebshopOrderRowType,
|
||||
WebshopPlatform,
|
||||
WebshopVatBreakdownLine,
|
||||
} from '@/types'
|
||||
|
||||
/**
|
||||
* Row shape the platform syncs (woocommerce/shopify extensions) hand to
|
||||
* upsertWebshopOrders(). Everything the sync knows about one bookable money
|
||||
* event; the service owns FX enrichment, legacy cross-marking and the
|
||||
* frozen-row rules, so none of those fields appear here.
|
||||
*/
|
||||
export interface WebshopOrderUpsert {
|
||||
platform: WebshopPlatform
|
||||
store_scope: string
|
||||
store_label: string | null
|
||||
connection_id: string | null
|
||||
row_type: WebshopOrderRowType
|
||||
/**
|
||||
* external_id of the parent order for refund rows; resolved to
|
||||
* parent_order_id by the service (the parent may be in the same batch or
|
||||
* already in the table from an earlier run). Null on order rows.
|
||||
*/
|
||||
parent_external_id: string | null
|
||||
external_id: string
|
||||
platform_order_id: string
|
||||
order_number: string
|
||||
status: string
|
||||
is_paid: boolean
|
||||
order_date: string
|
||||
paid_date: string | null
|
||||
currency: string
|
||||
total: number
|
||||
total_tax: number
|
||||
vat_breakdown: WebshopVatBreakdownLine[]
|
||||
line_items: WebshopOrderLineItem[]
|
||||
customer_name: string | null
|
||||
customer_company: string | null
|
||||
customer_email: string | null
|
||||
customer_orgnr: string | null
|
||||
customer_country: string | null
|
||||
payment_method: string | null
|
||||
payment_method_title: string | null
|
||||
gateway_reference: string | null
|
||||
refunded_total: number
|
||||
}
|
||||
|
||||
export interface WebshopOrderUpsertResult {
|
||||
inserted: number
|
||||
updated: number
|
||||
/** Existing rows whose incoming payload carried no change. */
|
||||
unchanged: number
|
||||
/** Booked/invoiced rows whose financials drifted remotely; flagged, not updated. */
|
||||
frozenFlagged: number
|
||||
/** Rows linked to an already-imported legacy transactions feed row. */
|
||||
crossMarked: number
|
||||
errors: number
|
||||
/** First error encountered, surfaced for the sync summary/logs. */
|
||||
firstError?: { message: string; code?: string | null }
|
||||
}
|
||||
Reference in New Issue
Block a user