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:
Mattsson
2026-08-12 15:08:13 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 89cfa82f2f
commit c35b2547fb
47 changed files with 6104 additions and 698 deletions
@@ -261,7 +261,7 @@ describe('woocommerce extension routes', () => {
it('returns 404 without an active connection', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null })
enqueue({ data: null })
enqueue({ data: [] })
const res = await findRoute('POST', '/sync').handler(
makeRequest('POST'),
makeContext(supabase),
@@ -272,12 +272,15 @@ describe('woocommerce extension routes', () => {
it('runs the sync on the service client and returns the summary', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null })
enqueue({ data: { id: 'conn-1', status: 'active' } })
enqueue({ data: [{ id: 'conn-1', status: 'active' }] })
vi.mocked(syncWooCommerceOrders).mockResolvedValue({
fetched: 3,
refundsFetched: 1,
imported: 4,
duplicates: 0,
inserted: 4,
updated: 0,
unchanged: 0,
frozenFlagged: 0,
crossMarked: 0,
errors: 0,
})
const res = await findRoute('POST', '/sync').handler(
@@ -286,7 +289,7 @@ describe('woocommerce extension routes', () => {
)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.transactions.imported).toBe(4)
expect(body.transactions.inserted).toBe(4)
expect(vi.mocked(syncWooCommerceOrders).mock.calls[0][0]).toEqual({ service: true })
})
})
@@ -12,28 +12,22 @@ vi.mock('../lib/api-client', () => ({
WC_PAGE_SIZE: 100,
}))
vi.mock('@/lib/transactions/ingest', () => ({
ingestTransactions: vi.fn(),
vi.mock('@/lib/webshop-orders/ingest', () => ({
upsertWebshopOrders: vi.fn(),
}))
vi.mock('@/lib/cash-accounts/service', () => ({
ensureManualCashAccount: vi.fn().mockResolvedValue('cash-account-1'),
}))
vi.mock('@/lib/import/account-sync', () => ({
syncMappedAccounts: vi.fn().mockResolvedValue({ error: null }),
}))
import { ingestTransactions } from '@/lib/transactions/ingest'
import { ensureManualCashAccount } from '@/lib/cash-accounts/service'
import { upsertWebshopOrders } from '@/lib/webshop-orders/ingest'
import type { WebshopOrderUpsert } from '@/lib/webshop-orders/types'
import { encryptCredential } from '../lib/credentials'
import {
WOOCOMMERCE_IMPORT_SOURCE,
WOOCOMMERCE_LEDGER_ACCOUNT,
mapOrder,
mapRefund,
orderQualifies,
rowBehindLock,
buildRefundVatBreakdown,
buildVatBreakdown,
extractOrgnr,
mapOrderToWebshopRow,
mapRefundToWebshopRow,
orderImports,
orderIsPaid,
syncWooCommerceOrders,
wooOrderExternalId,
wooRefundExternalId,
@@ -43,6 +37,15 @@ import type { WooCommerceConnection, WooOrder, WooRefund } from '../types'
process.env.WOOCOMMERCE_CREDENTIALS_ENCRYPTION_KEY = 'test-key'
const emptyUpsertResult = {
inserted: 0,
updated: 0,
unchanged: 0,
frozenFlagged: 0,
crossMarked: 0,
errors: 0,
}
function makeConnection(overrides: Partial<WooCommerceConnection> = {}): WooCommerceConnection {
return {
id: 'conn-1',
@@ -85,25 +88,40 @@ function makeOrder(overrides: Partial<WooOrder> = {}): WooOrder {
payment_method_title: 'Kortbetalning',
transaction_id: 'pi_abc123',
refunds: [],
billing: {
first_name: 'Test',
last_name: 'Person',
company: 'Testbolaget AB',
email: 'kund@example.se',
},
line_items: [
{
id: 1,
name: 'Produkt A',
quantity: 2,
total: '1000.00',
total_tax: '250.00',
taxes: [{ id: 3, total: '250.00' }],
},
],
tax_lines: [
{ rate_id: 3, rate_percent: 25, label: 'Moms 25%', tax_total: '250.00', shipping_tax_total: '0.00' },
],
shipping_lines: [],
meta_data: [],
...overrides,
}
}
/** Minimal chainable supabase mock covering the sync's query patterns. */
function makeSupabaseMock(options: { lockThrough?: string | null } = {}) {
function makeSupabaseMock() {
const updates: Array<{ table: string; values: Record<string, unknown> }> = []
const client = {
from(table: string) {
const builder = {
select: () => builder,
eq: () => builder,
maybeSingle: async () => ({
data:
table === 'company_settings'
? { bookkeeping_locked_through: options.lockThrough ?? null }
: null,
error: null,
}),
maybeSingle: async () => ({ data: null, error: null }),
update: (values: Record<string, unknown>) => {
updates.push({ table, values })
return builder
@@ -127,17 +145,15 @@ beforeEach(() => {
// enqueues its pages with mockResolvedValueOnce.
listOrdersPage.mockResolvedValue([])
listOrderRefunds.mockResolvedValue([])
vi.mocked(ingestTransactions).mockResolvedValue({
imported: 0,
duplicates: 0,
errors: 0,
} as Awaited<ReturnType<typeof ingestTransactions>>)
vi.mocked(upsertWebshopOrders).mockResolvedValue({ ...emptyUpsertResult })
})
describe('frozen external_id formats', () => {
// ⚠️ These assert the exact persisted formats. If this test fails, you are
// about to orphan every previously imported WooCommerce row: do not update
// the expectation without a coordinated backfill (see order-sync.ts).
// ⚠️ These assert the exact persisted formats, now shared between
// webshop_orders.external_id and the legacy transactions rows the
// cross-mark joins against. If this test fails, you are about to orphan
// every previously imported WooCommerce row AND break the legacy overlap
// join: do not update the expectation without a coordinated backfill.
it('order id format is frozen', () => {
expect(wooOrderExternalId('shop.example.se', 1042)).toBe(
'woo_shop.example.se_order_1042',
@@ -155,51 +171,123 @@ describe('frozen external_id formats', () => {
expect(wooStoreScope('https://example.se/butik')).toBe('example.se/butik')
})
it('import source and ledger account are frozen', () => {
it('legacy import source constant is frozen', () => {
expect(WOOCOMMERCE_IMPORT_SOURCE).toBe('woocommerce')
expect(WOOCOMMERCE_LEDGER_ACCOUNT).toBe('1680')
})
})
describe('orderQualifies', () => {
it('requires date_paid and excludes trashed orders', () => {
expect(orderQualifies(makeOrder())).toBe(true)
expect(orderQualifies(makeOrder({ status: 'refunded' }))).toBe(true)
expect(orderQualifies(makeOrder({ date_paid_gmt: null }))).toBe(false)
expect(orderQualifies(makeOrder({ status: 'trash' }))).toBe(false)
describe('orderImports / orderIsPaid', () => {
it('every non-trash status imports, paid or not', () => {
expect(orderImports(makeOrder())).toBe(true)
expect(orderImports(makeOrder({ status: 'pending', date_paid_gmt: null }))).toBe(true)
expect(orderImports(makeOrder({ status: 'refunded' }))).toBe(true)
expect(orderImports(makeOrder({ status: 'trash' }))).toBe(false)
})
it('is_paid follows date_paid', () => {
expect(orderIsPaid(makeOrder())).toBe(true)
expect(orderIsPaid(makeOrder({ date_paid_gmt: null }))).toBe(false)
})
})
describe('mapOrder', () => {
it('maps a paid order to one gross row dated by date_paid', () => {
const rows = mapOrder('shop.example.se', makeOrder())
expect(rows).toEqual([
{
date: '2026-08-01',
description: 'WooCommerce-order #1042',
amount: 1250,
currency: 'SEK',
external_id: 'woo_shop.example.se_order_1042',
import_source: 'woocommerce',
reference: 'pi_abc123',
},
describe('mapOrderToWebshopRow', () => {
const connection = { id: 'conn-1', store_name: 'Testbutiken' }
it('maps the full booking underlag', () => {
const rows = mapOrderToWebshopRow(connection, 'shop.example.se', makeOrder())
expect(rows).toHaveLength(1)
expect(rows[0]).toMatchObject({
platform: 'woocommerce',
store_scope: 'shop.example.se',
store_label: 'Testbutiken',
connection_id: 'conn-1',
row_type: 'order',
external_id: 'woo_shop.example.se_order_1042',
platform_order_id: '1042',
order_number: '1042',
status: 'processing',
is_paid: true,
order_date: '2026-08-01',
paid_date: '2026-08-01',
currency: 'SEK',
total: 1250,
total_tax: 250,
customer_name: 'Test Person',
customer_company: 'Testbolaget AB',
customer_email: 'kund@example.se',
payment_method: 'stripe',
payment_method_title: 'Kortbetalning',
gateway_reference: 'pi_abc123',
})
expect(rows[0].vat_breakdown).toEqual([{ rate: 25, net: 1000, tax: 250 }])
expect(rows[0].line_items).toEqual([
{ name: 'Produkt A', quantity: 2, total: 1000, total_tax: 250, vat_rate: 25 },
])
})
it('rounds string money to two decimals', () => {
const rows = mapOrder('s', makeOrder({ total: '99.995' }))
expect(rows[0].amount).toBe(100)
it('imports unpaid orders with is_paid false and no paid_date', () => {
const rows = mapOrderToWebshopRow(
connection,
's',
makeOrder({ status: 'pending', date_paid_gmt: null }),
)
expect(rows).toHaveLength(1)
expect(rows[0]).toMatchObject({ is_paid: false, paid_date: null, order_date: '2026-08-01' })
})
it('skips unpaid, trashed, zero-total and unparseable orders', () => {
expect(mapOrder('s', makeOrder({ date_paid_gmt: null }))).toEqual([])
expect(mapOrder('s', makeOrder({ status: 'trash' }))).toEqual([])
expect(mapOrder('s', makeOrder({ total: '0.00' }))).toEqual([])
expect(mapOrder('s', makeOrder({ total: 'not-a-number' }))).toEqual([])
it('skips trashed, unparseable-total and zero-total orders', () => {
expect(mapOrderToWebshopRow(connection, 's', makeOrder({ status: 'trash' }))).toEqual([])
expect(
mapOrderToWebshopRow(connection, 's', makeOrder({ total: 'not-a-number' })),
).toEqual([])
// 100% coupon order: paid but zero money; importing it would strand an
// unbookable row (the engine refuses zero-sum entries).
expect(mapOrderToWebshopRow(connection, 's', makeOrder({ total: '0.00' }))).toEqual([])
})
it('captures the billing country uppercased', () => {
const rows = mapOrderToWebshopRow(
connection,
's',
makeOrder({ billing: { first_name: 'A', last_name: 'B', country: 'dk' } }),
)
expect(rows[0].customer_country).toBe('DK')
})
it('snapshots shipping and fee lines so the invoice conversion covers order.total', () => {
const rows = mapOrderToWebshopRow(
connection,
's',
makeOrder({
shipping_lines: [
{ method_title: 'Postnord', total: '80.00', total_tax: '20.00', taxes: [{ id: 3, total: '20.00' }] },
],
fee_lines: [
{ name: 'Fakturaavgift', total: '25.00', total_tax: '6.25', taxes: [{ id: 3, total: '6.25' }] },
],
}),
)
expect(rows[0].line_items.map((i) => i.name)).toEqual([
'Produkt A',
'Postnord',
'Fakturaavgift',
])
expect(rows[0].line_items[1]).toMatchObject({ total: 80, total_tax: 20, vat_rate: 25 })
expect(rows[0].line_items[2]).toMatchObject({ total: 25, total_tax: 6.25, vat_rate: 25 })
})
it('sums inline refund totals into refunded_total', () => {
const rows = mapOrderToWebshopRow(
connection,
's',
makeOrder({ refunds: [{ id: 77, reason: '', total: '-250.00' }] }),
)
expect(rows[0].refunded_total).toBe(250)
})
})
describe('mapRefund', () => {
describe('mapRefundToWebshopRow', () => {
const connection = { id: 'conn-1', store_name: 'Testbutiken' }
const refund: WooRefund = {
id: 77,
amount: '250.00',
@@ -207,37 +295,220 @@ describe('mapRefund', () => {
date_created_gmt: '2026-08-03T10:00:00',
}
it('maps a refund to one negative row dated by the refund date', () => {
const rows = mapRefund('shop.example.se', makeOrder(), refund)
expect(rows).toEqual([
{
date: '2026-08-03',
description: 'WooCommerce-återbetalning order #1042',
amount: -250,
currency: 'SEK',
external_id: 'woo_shop.example.se_refund_77',
import_source: 'woocommerce',
reference: null,
},
])
it('maps a refund to a negative row parented by external id, with PRORATED VAT', () => {
const rows = mapRefundToWebshopRow(connection, 'shop.example.se', makeOrder(), refund)
expect(rows).toHaveLength(1)
expect(rows[0]).toMatchObject({
row_type: 'refund',
parent_external_id: 'woo_shop.example.se_order_1042',
external_id: 'woo_shop.example.se_refund_77',
order_number: '1042',
order_date: '2026-08-03',
total: -250,
// 250 of a 1250 order (net 1000 + moms 250): the reversal carries the
// sale's mix. A zero here would leave ruta 10 over-declared (the
// skeptic's counterexample ledger).
total_tax: -50,
is_paid: true,
})
expect(rows[0].vat_breakdown).toEqual([{ rate: 25, net: 200, tax: 50 }])
})
it('skips zero-amount refunds', () => {
expect(mapRefund('s', makeOrder(), { ...refund, amount: '0' })).toEqual([])
expect(
mapRefundToWebshopRow(connection, 's', makeOrder(), { ...refund, amount: '0' }),
).toEqual([])
})
})
describe('rowBehindLock', () => {
it('drops dates on/before the lock and keeps later ones', () => {
expect(rowBehindLock('2026-06-30', '2026-06-30')).toBe(true)
expect(rowBehindLock('2026-06-15', '2026-06-30')).toBe(true)
expect(rowBehindLock('2026-07-01', '2026-06-30')).toBe(false)
expect(rowBehindLock('2026-06-15', null)).toBe(false)
describe('buildRefundVatBreakdown', () => {
it('prefers the refund\'s own line allocation over proration', () => {
const refundWithLines: WooRefund = {
id: 78,
amount: '125.00',
reason: '',
date_created_gmt: '2026-08-03T10:00:00',
line_items: [
{ id: 9, total: '-100.00', total_tax: '-25.00', taxes: [{ id: 3, total: '-25.00' }] },
],
}
expect(buildRefundVatBreakdown(makeOrder(), refundWithLines)).toEqual({
breakdown: [{ rate: 25, net: 100, tax: 25 }],
totalTax: 25,
})
})
it('prorates a mixed-rate order for amount-only refunds', () => {
const order = makeOrder({
total: '1000.00',
total_tax: '160.00',
line_items: [
{ id: 1, name: 'A', quantity: 1, total: '500.00', total_tax: '125.00', taxes: [{ id: 3, total: '125.00' }] },
{ id: 2, name: 'B', quantity: 1, total: '340.00', total_tax: '35.00', taxes: [{ id: 4, total: '35.00' }] },
],
tax_lines: [
{ rate_id: 3, rate_percent: 25, tax_total: '125.00', shipping_tax_total: '0.00' },
{ rate_id: 4, rate_percent: 12, tax_total: '35.00', shipping_tax_total: '0.00' },
],
})
const halfRefund: WooRefund = {
id: 79,
amount: '500.00',
reason: '',
date_created_gmt: '2026-08-03T10:00:00',
}
const { breakdown, totalTax } = buildRefundVatBreakdown(order, halfRefund)
expect(breakdown).toEqual([
{ rate: 25, net: 250, tax: 62.5 },
{ rate: 12, net: 170, tax: 17.5 },
])
expect(totalTax).toBe(80)
})
it('returns empty when the order itself has no breakdown', () => {
const order = makeOrder({ line_items: [], shipping_lines: [], tax_lines: [] })
const amountOnly: WooRefund = {
id: 80,
amount: '100.00',
reason: '',
date_created_gmt: '2026-08-03T10:00:00',
}
expect(buildRefundVatBreakdown(order, amountOnly)).toEqual({
breakdown: [],
totalTax: 0,
})
})
})
describe('buildVatBreakdown', () => {
it('groups line and shipping taxes per rate', () => {
const order = makeOrder({
line_items: [
{
id: 1,
name: 'A',
quantity: 1,
total: '400.00',
total_tax: '100.00',
taxes: [{ id: 3, total: '100.00' }],
},
{
id: 2,
name: 'B',
quantity: 1,
total: '100.00',
total_tax: '12.00',
taxes: [{ id: 4, total: '12.00' }],
},
],
shipping_lines: [
{ total: '49.00', total_tax: '12.25', taxes: [{ id: 3, total: '12.25' }] },
],
tax_lines: [
{ rate_id: 3, rate_percent: 25, tax_total: '112.25', shipping_tax_total: '12.25' },
{ rate_id: 4, rate_percent: 12, tax_total: '12.00', shipping_tax_total: '0.00' },
],
})
expect(buildVatBreakdown(order)).toEqual([
{ rate: 25, net: 449, tax: 112.25 },
{ rate: 12, net: 100, tax: 12 },
])
})
it('lands untaxed lines in the 0% bucket', () => {
const order = makeOrder({
line_items: [
{ id: 1, name: 'A', quantity: 1, total: '300.00', total_tax: '0.00', taxes: [] },
],
tax_lines: [],
total_tax: '0.00',
})
expect(buildVatBreakdown(order)).toEqual([{ rate: 0, net: 300, tax: 0 }])
})
it('infers the rate from the ratio when tax_lines are missing', () => {
const order = makeOrder({
line_items: [
{ id: 1, name: 'A', quantity: 1, total: '400.00', total_tax: '100.00' },
],
tax_lines: [],
})
expect(buildVatBreakdown(order)).toEqual([{ rate: 25, net: 400, tax: 100 }])
})
it('returns [] when the payload has no line data (dialog falls back)', () => {
const order = makeOrder({ line_items: [], shipping_lines: [], tax_lines: [] })
expect(buildVatBreakdown(order)).toEqual([])
})
it('returns [] for tax-stripped stores: empty taxes arrays are NOT tax data', () => {
// Hardened hosts serialize `taxes: []` on every line while hiding the
// amounts; treating that as tax data booked the whole VAT into 3740.
const order = makeOrder({
total: '500.00',
total_tax: '100.00',
line_items: [
{ id: 1, name: 'A', quantity: 1, total: '400.00', total_tax: '', taxes: [] },
],
tax_lines: [],
shipping_lines: [],
})
expect(buildVatBreakdown(order)).toEqual([])
})
it('includes fee lines so their VAT reaches 2611 instead of the 3740 residual', () => {
const order = makeOrder({
total: '562.50',
total_tax: '112.50',
fee_lines: [
{ name: 'Fakturaavgift', total: '50.00', total_tax: '12.50', taxes: [{ id: 3, total: '12.50' }] },
],
})
expect(buildVatBreakdown(order)).toEqual([{ rate: 25, net: 1050, tax: 262.5 }])
})
it('keeps discount lines SIGNED (negative net stays negative)', () => {
const order = makeOrder({
total: '525.00',
total_tax: '125.00',
line_items: [
{ id: 1, name: 'Produkt', quantity: 1, total: '500.00', total_tax: '125.00', taxes: [{ id: 3, total: '125.00' }] },
{ id: 2, name: 'Presentkort', quantity: 1, total: '-100.00', total_tax: '0.00', taxes: [] },
],
})
expect(buildVatBreakdown(order)).toEqual([
{ rate: 25, net: 500, tax: 125 },
{ rate: 0, net: -100, tax: 0 },
])
})
})
describe('extractOrgnr', () => {
it('reads an orgnr embedded in the billing company field', () => {
expect(
extractOrgnr(makeOrder({ billing: { company: 'Testbolaget AB 556677-8899' } })),
).toBe('556677-8899')
})
it('scans meta_data for orgnr-ish keys', () => {
expect(
extractOrgnr(
makeOrder({
billing: { company: 'Testbolaget AB' },
meta_data: [{ key: '_billing_org_nr', value: '5566778899' }],
}),
),
).toBe('556677-8899')
})
it('returns null when nothing matches', () => {
expect(extractOrgnr(makeOrder())).toBeNull()
expect(extractOrgnr(makeOrder({ billing: undefined, meta_data: undefined }))).toBeNull()
})
})
describe('syncWooCommerceOrders', () => {
it('ingests order and refund rows against the 1680 cash account and advances the cursor', async () => {
it('upserts order and refund rows and advances the cursor', async () => {
const { client, updates } = makeSupabaseMock()
const order = makeOrder({
refunds: [{ id: 77, reason: 'Retur', total: '-250.00' }],
@@ -246,32 +517,22 @@ describe('syncWooCommerceOrders', () => {
listOrderRefunds.mockResolvedValueOnce([
{ id: 77, amount: '250.00', reason: 'Retur', date_created_gmt: '2026-08-03T10:00:00' },
])
vi.mocked(ingestTransactions).mockResolvedValueOnce({
imported: 2,
duplicates: 0,
errors: 0,
} as Awaited<ReturnType<typeof ingestTransactions>>)
vi.mocked(upsertWebshopOrders).mockResolvedValueOnce({
...emptyUpsertResult,
inserted: 2,
})
const summary = await syncWooCommerceOrders(client, makeConnection())
expect(summary).toMatchObject({ fetched: 1, refundsFetched: 1, imported: 2, duplicates: 0 })
expect(ensureManualCashAccount).toHaveBeenCalledWith(
client,
'company-1',
'1680',
'SEK',
'WooCommerce-saldo',
)
expect(ingestTransactions).toHaveBeenCalledTimes(1)
const [, companyId, userId, rows, ingestOptions] =
vi.mocked(ingestTransactions).mock.calls[0]
expect(summary).toMatchObject({ fetched: 1, refundsFetched: 1, inserted: 2, errors: 0 })
expect(upsertWebshopOrders).toHaveBeenCalledTimes(1)
const [, companyId, userId, rows] = vi.mocked(upsertWebshopOrders).mock.calls[0]
expect(companyId).toBe('company-1')
expect(userId).toBe('user-1')
expect((rows as Array<{ external_id: string }>).map((r) => r.external_id)).toEqual([
expect((rows as WebshopOrderUpsert[]).map((r) => r.external_id)).toEqual([
'woo_shop.example.se_order_1042',
'woo_shop.example.se_refund_77',
])
expect(ingestOptions).toEqual({ settlementAccount: '1680', skipAutoCategorization: true })
// Cursor persisted from the page's max date_modified_gmt, branded UTC,
// and any stale error_message is cleared on progress.
@@ -289,24 +550,17 @@ describe('syncWooCommerceOrders', () => {
})
})
it('drops rows dated on/before the bookkeeping lock on every run', async () => {
const { client, updates } = makeSupabaseMock({ lockThrough: '2026-08-02' })
// Order paid 2026-08-01 (behind lock), refund created 2026-08-03 (after).
const order = makeOrder({ refunds: [{ id: 77, reason: '', total: '-250.00' }] })
listOrdersPage.mockResolvedValueOnce([order])
listOrderRefunds.mockResolvedValueOnce([
{ id: 77, amount: '250.00', reason: '', date_created_gmt: '2026-08-03T10:00:00' },
it('imports unpaid orders without fetching refunds for them', async () => {
const { client } = makeSupabaseMock()
listOrdersPage.mockResolvedValueOnce([
makeOrder({ status: 'pending', date_paid_gmt: null, refunds: [] }),
])
const summary = await syncWooCommerceOrders(client, makeConnection())
await syncWooCommerceOrders(client, makeConnection())
expect(summary.skippedLocked).toBe(1)
const [, , , rows] = vi.mocked(ingestTransactions).mock.calls[0]
expect((rows as Array<{ external_id: string }>).map((r) => r.external_id)).toEqual([
'woo_shop.example.se_refund_77',
])
// The cursor still advances: the drop is by design, not a failure.
expect(cursorUpdates(updates)).toHaveLength(1)
expect(listOrderRefunds).not.toHaveBeenCalled()
const [, , , rows] = vi.mocked(upsertWebshopOrders).mock.calls[0]
expect((rows as WebshopOrderUpsert[])[0]).toMatchObject({ is_paid: false })
})
it('holds the cursor below an order whose refund fetch failed', async () => {
@@ -324,6 +578,22 @@ describe('syncWooCommerceOrders', () => {
expect(cursors[0].values.last_order_synced_at).toBe('2026-08-01T09:04:59.000Z')
})
it('holds the cursor below a page whose upsert reported errors', async () => {
const { client, updates } = makeSupabaseMock()
listOrdersPage.mockResolvedValueOnce([makeOrder()])
vi.mocked(upsertWebshopOrders).mockResolvedValueOnce({
...emptyUpsertResult,
errors: 1,
})
const summary = await syncWooCommerceOrders(client, makeConnection())
expect(summary.errors).toBe(1)
const cursors = cursorUpdates(updates)
expect(cursors).toHaveLength(1)
expect(cursors[0].values.last_order_synced_at).toBe('2026-08-01T09:04:59.000Z')
})
it('pages through a full same-timestamp tie by offset, then resumes cursor pagination', async () => {
const { client } = makeSupabaseMock()
const tie = Array.from({ length: 100 }, (_, i) =>
@@ -347,37 +617,6 @@ describe('syncWooCommerceOrders', () => {
expect(thirdArgs).toEqual({ modifiedAfter: '2026-08-01T10:00:00.000Z', page: 1 })
})
it('falls back to the first order currency when store settings were unreadable', async () => {
const { client } = makeSupabaseMock()
listOrdersPage.mockResolvedValueOnce([makeOrder({ currency: 'eur' })])
await syncWooCommerceOrders(client, makeConnection({ currency: null }))
expect(ensureManualCashAccount).toHaveBeenCalledWith(
client,
'company-1',
'1680',
'EUR',
'WooCommerce-saldo',
)
})
it('surfaces a cash-account failure on the connection instead of failing silently', async () => {
const { client, updates } = makeSupabaseMock()
listOrdersPage.mockResolvedValueOnce([makeOrder()])
vi.mocked(ensureManualCashAccount).mockRejectedValueOnce(
new Error('cash account 1680 exists with currency EUR'),
)
await expect(syncWooCommerceOrders(client, makeConnection())).rejects.toThrow(
/currency EUR/,
)
const errorUpdate = updates.find(
(u) => u.table === 'woocommerce_connections' && 'error_message' in u.values,
)
expect(errorUpdate?.values.error_message).toMatch(/1680/)
})
it('counts an unparseable order total as an error without stalling the cursor', async () => {
const { client, updates } = makeSupabaseMock()
listOrdersPage.mockResolvedValueOnce([makeOrder({ total: 'not-a-number' })])
@@ -385,7 +624,7 @@ describe('syncWooCommerceOrders', () => {
const summary = await syncWooCommerceOrders(client, makeConnection())
expect(summary.errors).toBe(1)
expect(ingestTransactions).not.toHaveBeenCalled()
expect(upsertWebshopOrders).not.toHaveBeenCalled()
// Deliberate: a permanently corrupt total must not stall the feed.
expect(cursorUpdates(updates)).toHaveLength(1)
})
@@ -16,19 +16,19 @@ describe('syncSummary', () => {
it('reports a deadline-truncated run as partial, never as complete', () => {
expect(
syncSummary({ transactions: { deadlineReached: true, fetched: 120, imported: 80 } }),
syncSummary({ transactions: { deadlineReached: true, fetched: 120, inserted: 80 } }),
).toEqual({ reason: 'partial', values: { fetched: 120, imported: 80, errors: 0 } })
// Even a zero-fetch truncated run is partial, not "empty": the window was
// not exhausted, so claiming the store had nothing would be false.
expect(
syncSummary({ transactions: { deadlineReached: true, fetched: 0, imported: 0 } }),
syncSummary({ transactions: { deadlineReached: true, fetched: 0, inserted: 0 } }),
).toEqual({ reason: 'partial', values: { fetched: 0, imported: 0, errors: 0 } })
})
it('a truncated run with row errors keeps both facts', () => {
expect(
syncSummary({
transactions: { deadlineReached: true, fetched: 50, imported: 40, errors: 3 },
transactions: { deadlineReached: true, fetched: 50, inserted: 40, errors: 3 },
}),
).toEqual({ reason: 'partial', values: { fetched: 50, imported: 40, errors: 3 } })
})
@@ -36,9 +36,9 @@ describe('syncSummary', () => {
it('distinguishes empty, errors and feed outcomes', () => {
expect(syncSummary({ transactions: { fetched: 0 } })).toEqual({ reason: 'empty' })
expect(
syncSummary({ transactions: { fetched: 3, imported: 2, errors: 1 } }),
syncSummary({ transactions: { fetched: 3, inserted: 2, errors: 1 } }),
).toEqual({ reason: 'errors', values: { fetched: 3, imported: 2, errors: 1 } })
expect(syncSummary({ transactions: { fetched: 3, imported: 3 } })).toEqual({
expect(syncSummary({ transactions: { fetched: 3, inserted: 3 } })).toEqual({
reason: 'feed',
values: { fetched: 3, imported: 3 },
})
+78 -30
View File
@@ -69,21 +69,27 @@ async function guardConnectPreconditions(auth: AuthedContext): Promise<NextRespo
}
/**
* Existing-connection preflight for both connect paths: 409 on an active
* connection or a fresh pending handshake, and supersede stale pendings so
* their oauth_state can never complete a late callback.
* Existing-connection preflight for both connect paths, scoped to the SAME
* store: 409 when this store is already actively connected or mid-handshake,
* and supersede stale pendings for it so their oauth_state can never
* complete a late callback. Other stores are untouched: a company may
* connect several stores (multi-store, migration 20260811073422).
*/
async function blockOrSupersedeExisting(auth: AuthedContext): Promise<NextResponse | null> {
async function blockOrSupersedeExisting(
auth: AuthedContext,
storeUrl: string,
): Promise<NextResponse | null> {
const { data: existing } = await auth.supabase
.from('woocommerce_connections')
.select('id, status, created_at')
.eq('company_id', auth.companyId)
.eq('store_url', storeUrl)
.in('status', ['active', 'pending'])
.order('created_at', { ascending: false })
if (existing?.some((c) => c.status === 'active')) {
return NextResponse.json(
{ error: 'Företaget har redan en ansluten WooCommerce-butik. Koppla från den först.' },
{ error: 'Butiken är redan ansluten. Koppla från den först om du vill ansluta om den.' },
{ status: 409 },
)
}
@@ -106,6 +112,7 @@ async function blockOrSupersedeExisting(auth: AuthedContext): Promise<NextRespon
oauth_state: null,
})
.eq('company_id', auth.companyId)
.eq('store_url', storeUrl)
.eq('status', 'pending')
}
return null
@@ -119,19 +126,23 @@ export const woocommerceApiRoutes: ApiRouteDefinition[] = [
const auth = await requireUserAndCompany(ctx)
if (auth instanceof NextResponse) return auth
// Prefer the active connection; otherwise surface the most recent row
// so the panel can show pending/error/revoked states.
// Multi-store: every active connection, plus the most recent inactive
// row when nothing is active (so the panel can show a pending/error/
// revoked state instead of an empty list). `connection` mirrors the
// first entry for callers of the old single-store shape.
const { data: rows } = await auth.supabase
.from('woocommerce_connections')
.select(STATUS_COLUMNS)
.eq('company_id', auth.companyId)
.order('created_at', { ascending: false })
.limit(10)
.limit(25)
const connection = rows?.find((r) => r.status === 'active') ?? rows?.[0] ?? null
const active = (rows ?? []).filter((r) => r.status === 'active')
const connections = active.length > 0 ? active : rows?.[0] ? [rows[0]] : []
const payload: WooCommerceStatusResponse = {
configured: isWooCommerceConfigured(),
connection,
connection: connections[0] ?? null,
connections,
}
return NextResponse.json(payload)
},
@@ -168,7 +179,7 @@ export const woocommerceApiRoutes: ApiRouteDefinition[] = [
)
}
const conflict = await blockOrSupersedeExisting(auth)
const conflict = await blockOrSupersedeExisting(auth, storeUrl)
if (conflict) return conflict
// Persist the CSRF state BEFORE handing the user to the store: the
@@ -252,7 +263,7 @@ export const woocommerceApiRoutes: ApiRouteDefinition[] = [
)
}
const conflict = await blockOrSupersedeExisting(auth)
const conflict = await blockOrSupersedeExisting(auth, storeUrl)
if (conflict) return conflict
// Verify before storing: a typo'd key must fail here, not at 03:45.
@@ -335,7 +346,7 @@ export const woocommerceApiRoutes: ApiRouteDefinition[] = [
{
method: 'POST',
path: '/sync',
handler: async (_request: Request, ctx?: ExtensionContext) => {
handler: async (request: Request, ctx?: ExtensionContext) => {
const log = ctx?.log ?? console
const auth = await requireUserAndCompany(ctx)
if (auth instanceof NextResponse) return auth
@@ -355,17 +366,23 @@ export const woocommerceApiRoutes: ApiRouteDefinition[] = [
if (!rl.ok) return rl.response!
// Membership-scoped lookup via the user client; the sync itself runs on
// the service client (cursor updates and ingest are service paths). The
// manual button ignores transaction_sync_enabled (that flag gates the
// nightly cron): pressing it IS the opt-in.
const { data: connection } = await auth.supabase
// the service client (cursor updates and upserts are service paths).
// The manual button ignores transaction_sync_enabled (that flag gates
// the nightly cron): pressing it IS the opt-in. connection_id targets
// one store; omitted, every active store syncs within one time budget.
const body = (await request.json().catch(() => ({}))) as { connection_id?: string }
let query = auth.supabase
.from('woocommerce_connections')
.select('*')
.eq('company_id', auth.companyId)
.eq('status', 'active')
.maybeSingle()
if (body.connection_id) query = query.eq('id', body.connection_id)
const { data: connections } = await query.order('last_order_synced_at', {
ascending: true,
nullsFirst: true,
})
if (!connection) {
if (!connections || connections.length === 0) {
return NextResponse.json(
{ error: 'Ingen ansluten WooCommerce-butik.' },
{ status: 404 },
@@ -378,17 +395,44 @@ export const woocommerceApiRoutes: ApiRouteDefinition[] = [
// a slow host would be killed at the dispatcher's maxDuration with no
// cursor persisted; with one it stops cleanly, reports a partial sync
// and resumes where it stopped on the next press.
const summary = await syncWooCommerceOrders(
serviceClient,
connection as WooCommerceConnection,
undefined,
Date.now() + 240_000,
)
return NextResponse.json({ success: true, transactions: summary })
const deadlineMs = Date.now() + 240_000
// One entry per processed store, plus an explicit skipped count:
// returning only the last summary hid per-store failures and reported
// success for zero work when the deadline expired early.
const results: Array<{
connection_id: string
store_url: string
summary: Awaited<ReturnType<typeof syncWooCommerceOrders>>
}> = []
let skipped = 0
for (const connection of connections as WooCommerceConnection[]) {
if (Date.now() >= deadlineMs) {
skipped += 1
continue
}
const summary = await syncWooCommerceOrders(
serviceClient,
connection,
undefined,
deadlineMs,
)
results.push({
connection_id: connection.id,
store_url: connection.store_url,
summary,
})
}
return NextResponse.json({
success: true,
results,
skipped,
// Single-store shape kept for the panel's toast summary: the panel
// always syncs one connection_id, so this IS that store's summary.
transactions: results[results.length - 1]?.summary ?? null,
})
} catch (error) {
log.error('[woocommerce] Manual sync failed', {
message: error instanceof Error ? error.message : String(error),
connection_id: connection.id,
})
return NextResponse.json(
{ error: 'Synkroniseringen misslyckades. Försök igen.' },
@@ -418,17 +462,21 @@ export const woocommerceApiRoutes: ApiRouteDefinition[] = [
})
if (!rl.ok) return rl.response!
const body = (await request.json().catch(() => ({}))) as { enabled?: unknown }
const body = (await request.json().catch(() => ({}))) as {
enabled?: unknown
connection_id?: string
}
if (typeof body.enabled !== 'boolean') {
return NextResponse.json({ error: 'enabled (boolean) krävs.' }, { status: 400 })
}
const { data: updated, error: updateError } = await auth.supabase
let updateQuery = auth.supabase
.from('woocommerce_connections')
.update({ transaction_sync_enabled: body.enabled })
.eq('company_id', auth.companyId)
.eq('status', 'active')
.select('id')
if (body.connection_id) updateQuery = updateQuery.eq('id', body.connection_id)
const { data: updated, error: updateError } = await updateQuery.select('id')
if (updateError) {
return NextResponse.json(
@@ -15,6 +15,7 @@ import { useFormat } from '@/lib/hooks/use-format'
import { failureDescription } from '@/lib/browser/action-failure'
import type { ErrorLocale } from '@/lib/errors/get-error-message'
import { KeyRound, Link2, Loader2, RefreshCw, ShoppingCart, Unlink } from 'lucide-react'
import { PaymentMethodMappingForm } from '@/components/orders/PaymentMethodMappingForm'
import {
wooRequest,
syncSummary,
@@ -22,17 +23,23 @@ import {
WOO_SYNC_TIMEOUT_MS,
type WooSyncPayload,
} from '../lib/settings-actions'
import type { WooCommerceStatusResponse } from '../types'
import type { WooCommerceConnectionStatus, WooCommerceStatusResponse } from '../types'
type ConnectionInfo = NonNullable<WooCommerceStatusResponse['connection']>
const STATUS_VARIANT: Record<ConnectionInfo['status'], 'success' | 'secondary' | 'destructive' | 'warning'> = {
const STATUS_VARIANT: Record<
WooCommerceConnectionStatus['status'],
'success' | 'secondary' | 'destructive' | 'warning'
> = {
active: 'success',
pending: 'secondary',
revoked: 'warning',
error: 'destructive',
}
/** Client twin of wooStoreScope(): store identity used by the mapping table. */
function storeScopeOf(storeUrl: string): string {
return storeUrl.replace(/^https:\/\//, '')
}
export default function WooCommerceSettingsPanel() {
const t = useTranslations('woocommerce')
const tCommon = useTranslations('common')
@@ -45,16 +52,15 @@ export default function WooCommerceSettingsPanel() {
const [loading, setLoading] = useState(true)
const [loadFailed, setLoadFailed] = useState(false)
const [configured, setConfigured] = useState(false)
const [connection, setConnection] = useState<ConnectionInfo | null>(null)
const [connections, setConnections] = useState<WooCommerceConnectionStatus[]>([])
const [storeUrl, setStoreUrl] = useState('')
const [manualMode, setManualMode] = useState(false)
const [consumerKey, setConsumerKey] = useState('')
const [consumerSecret, setConsumerSecret] = useState('')
const [connecting, setConnecting] = useState(false)
const [disconnecting, setDisconnecting] = useState(false)
const [confirmDisconnect, setConfirmDisconnect] = useState(false)
const [syncing, setSyncing] = useState(false)
const [togglingTransactionSync, setTogglingTransactionSync] = useState(false)
// Per-store busy/confirm states, keyed by connection id.
const [busyId, setBusyId] = useState<string | null>(null)
const [confirmDisconnectId, setConfirmDisconnectId] = useState<string | null>(null)
const failureCopy = { timeout: t('action_timeout'), network: t('action_network') }
@@ -73,7 +79,10 @@ export default function WooCommerceSettingsPanel() {
}
setLoadFailed(false)
setConfigured(result.data.configured)
setConnection(result.data.connection)
// Older payload shape fallback: a single `connection`.
setConnections(
result.data.connections ?? (result.data.connection ? [result.data.connection] : []),
)
}, [locale])
useEffect(() => {
@@ -155,6 +164,7 @@ export default function WooCommerceSettingsPanel() {
toast({ title: t('connected_toast_title'), description: t('connected_toast_description') })
setConsumerKey('')
setConsumerSecret('')
setStoreUrl('')
setManualMode(false)
await loadStatus()
} finally {
@@ -162,12 +172,13 @@ export default function WooCommerceSettingsPanel() {
}
}
async function handleSyncNow() {
if (syncing) return
setSyncing(true)
async function handleSyncNow(connectionId: string) {
if (busyId) return
setBusyId(connectionId)
try {
const result = await wooRequest<WooSyncPayload>({
url: '/api/extensions/ext/woocommerce/sync',
body: { connection_id: connectionId },
locale,
timeoutMs: WOO_SYNC_TIMEOUT_MS,
})
@@ -199,17 +210,17 @@ export default function WooCommerceSettingsPanel() {
}
await loadStatus()
} finally {
setSyncing(false)
setBusyId(null)
}
}
async function handleToggleTransactionSync(enabled: boolean) {
if (togglingTransactionSync) return
setTogglingTransactionSync(true)
async function handleToggleTransactionSync(connectionId: string, enabled: boolean) {
if (busyId) return
setBusyId(connectionId)
try {
const result = await wooRequest({
url: '/api/extensions/ext/woocommerce/transaction-sync',
body: { enabled },
body: { enabled, connection_id: connectionId },
locale,
})
if (!result.ok) {
@@ -227,18 +238,18 @@ export default function WooCommerceSettingsPanel() {
})
await loadStatus()
} finally {
setTogglingTransactionSync(false)
setBusyId(null)
}
}
async function handleDisconnect() {
if (!connection || disconnecting) return
setDisconnecting(true)
async function handleDisconnect(connectionId: string) {
if (busyId) return
setBusyId(connectionId)
try {
const result = await wooRequest({
url: '/api/extensions/ext/woocommerce/disconnect',
method: 'DELETE',
body: { connection_id: connection.id },
body: { connection_id: connectionId },
locale,
})
if (!result.ok) {
@@ -252,10 +263,10 @@ export default function WooCommerceSettingsPanel() {
// The key still exists in the store's wp-admin; only the merchant can
// delete it there, so the toast says so.
toast({ title: t('disconnected_toast_title'), description: t('disconnected_toast_description') })
setConfirmDisconnect(false)
setConfirmDisconnectId(null)
await loadStatus()
} finally {
setDisconnecting(false)
setBusyId(null)
}
}
@@ -301,8 +312,7 @@ export default function WooCommerceSettingsPanel() {
)
}
const isActive = connection?.status === 'active'
const showConnectForm = !connection || !isActive
const hasActive = connections.some((c) => c.status === 'active')
return (
<Card>
@@ -312,194 +322,224 @@ export default function WooCommerceSettingsPanel() {
<CardContent className="space-y-6 pt-0">
<p className="text-sm text-muted-foreground">{t('description')}</p>
{connection && (
<div className="flex flex-wrap items-center justify-between gap-4 rounded-lg border border-border p-4">
<div className="flex items-center gap-3">
<ShoppingCart className="h-5 w-5 text-muted-foreground" />
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium">
{connection.store_name || connection.store_url || t('unnamed_store')}
</span>
<Badge variant={STATUS_VARIANT[connection.status]}>
{t(`status_${connection.status}`)}
</Badge>
{connections.map((connection) => {
const isActive = connection.status === 'active'
const busy = busyId === connection.id
// Handlers early-return while ANY request runs (shared busyId), so
// every card's controls disable; the spinner stays on the busy one.
const blocked = busyId !== null
return (
<div key={connection.id} className="space-y-4 rounded-lg border border-border p-4">
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex items-center gap-3">
<ShoppingCart className="h-5 w-5 text-muted-foreground" />
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium">
{connection.store_name || connection.store_url || t('unnamed_store')}
</span>
<Badge variant={STATUS_VARIANT[connection.status]}>
{t(`status_${connection.status}`)}
</Badge>
</div>
{connection.store_name && (
<p className="mt-1 text-sm text-muted-foreground">{connection.store_url}</p>
)}
{isActive && connection.connected_at && (
<p className="mt-1 text-sm text-muted-foreground">
{t('connected_since', { date: formatDateLong(connection.connected_at) })}
</p>
)}
{connection.status === 'pending' && (
<p className="mt-1 text-sm text-muted-foreground">{t('pending_note')}</p>
)}
{connection.error_message && (
// Shown for active connections too: a sync that cannot
// run must not hide behind a healthy "Ansluten" badge.
<p className="mt-1 text-sm text-destructive">{connection.error_message}</p>
)}
</div>
</div>
{connection.store_name && (
<p className="mt-1 text-sm text-muted-foreground">{connection.store_url}</p>
)}
{isActive && connection.connected_at && (
<p className="mt-1 text-sm text-muted-foreground">
{t('connected_since', { date: formatDateLong(connection.connected_at) })}
</p>
)}
{connection.status === 'pending' && (
<p className="mt-1 text-sm text-muted-foreground">{t('pending_note')}</p>
)}
{connection.error_message && (
// Shown for active connections too: a sync that cannot run
// (e.g. cash-account currency conflict) must not hide
// behind a healthy-looking "Ansluten" badge.
<p className="mt-1 text-sm text-destructive">{connection.error_message}</p>
{isActive && (
confirmDisconnectId === connection.id ? (
<div className="flex items-center gap-2">
<Button
variant="destructive"
size="sm"
onClick={() => handleDisconnect(connection.id)}
disabled={blocked}
>
{t('disconnect_confirm')}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setConfirmDisconnectId(null)}
disabled={blocked}
>
{t('cancel')}
</Button>
</div>
) : (
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleSyncNow(connection.id)}
disabled={blocked}
>
{busy ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<RefreshCw className="mr-2 h-4 w-4" />
)}
{busy ? t('syncing') : t('sync_now')}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setConfirmDisconnectId(connection.id)}
disabled={blocked}
>
<Unlink className="mr-2 h-4 w-4" />
{t('disconnect')}
</Button>
</div>
)
)}
</div>
</div>
{isActive && (
confirmDisconnect ? (
<div className="flex items-center gap-2">
<Button
variant="destructive"
size="sm"
onClick={handleDisconnect}
disabled={disconnecting}
>
{t('disconnect_confirm')}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setConfirmDisconnect(false)}
disabled={disconnecting}
>
{t('cancel')}
</Button>
</div>
) : (
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={handleSyncNow} disabled={syncing}>
{syncing ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{isActive && (
<div className="flex flex-wrap items-start justify-between gap-4 border-t border-border pt-4">
<div className="min-w-0 max-w-prose space-y-1">
<p className="text-sm font-medium">{t('transaction_sync_title')}</p>
<p className="text-sm text-muted-foreground">
{t('transaction_sync_description')}
</p>
{connection.transaction_sync_enabled ? (
<p className="text-xs text-muted-foreground">
{connection.last_order_synced_at
? t('transaction_sync_last_synced', {
date: formatDateLong(connection.last_order_synced_at),
})
: t('transaction_sync_never_synced')}
</p>
) : (
<RefreshCw className="mr-2 h-4 w-4" />
<p className="text-xs text-muted-foreground">
{t('transaction_sync_backfill_note')}
</p>
)}
{syncing ? t('syncing') : t('sync_now')}
</Button>
<Button variant="outline" size="sm" onClick={() => setConfirmDisconnect(true)}>
<Unlink className="mr-2 h-4 w-4" />
{t('disconnect')}
</Button>
</div>
)
)}
</div>
)}
{showConnectForm && (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="woocommerce-store-url">{t('store_url_label')}</Label>
<Input
id="woocommerce-store-url"
type="url"
inputMode="url"
placeholder="https://minbutik.se"
value={storeUrl}
onChange={(e) => setStoreUrl(e.target.value)}
disabled={connecting}
/>
</div>
{manualMode ? (
<div className="space-y-4 rounded-lg border border-border p-4">
<p className="text-sm text-muted-foreground">{t('manual_hint')}</p>
<div className="space-y-2">
<Label htmlFor="woocommerce-consumer-key">{t('consumer_key_label')}</Label>
<Input
id="woocommerce-consumer-key"
autoComplete="off"
placeholder="ck_..."
value={consumerKey}
onChange={(e) => setConsumerKey(e.target.value)}
disabled={connecting}
</div>
<Switch
checked={connection.transaction_sync_enabled}
onCheckedChange={(enabled) =>
handleToggleTransactionSync(connection.id, enabled)
}
disabled={blocked}
aria-label={t('transaction_sync_title')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="woocommerce-consumer-secret">{t('consumer_secret_label')}</Label>
<Input
id="woocommerce-consumer-secret"
type="password"
autoComplete="off"
placeholder="cs_..."
value={consumerSecret}
onChange={(e) => setConsumerSecret(e.target.value)}
disabled={connecting}
/>
</div>
<div className="flex items-center gap-2">
<Button
onClick={handleManualConnect}
disabled={connecting || !storeUrl || !consumerKey || !consumerSecret}
>
{connecting ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<KeyRound className="mr-2 h-4 w-4" />
)}
{connecting ? t('connecting') : t('manual_connect')}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setManualMode(false)}
disabled={connecting}
>
{t('cancel')}
</Button>
</div>
</div>
) : (
<div>
<div className="flex items-center gap-2">
<Button onClick={handleConnect} disabled={connecting || !storeUrl}>
{connecting ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Link2 className="mr-2 h-4 w-4" />
)}
{connecting ? t('connecting') : t('connect')}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setManualMode(true)}
disabled={connecting}
>
{t('manual_toggle')}
</Button>
</div>
<p className="mt-3 text-sm text-muted-foreground">{t('connect_hint')}</p>
</div>
)}
</div>
)}
)}
{isActive && connection && (
<div className="flex flex-wrap items-start justify-between gap-4 rounded-lg border border-border p-4">
<div className="min-w-0 max-w-prose space-y-1">
<p className="text-sm font-medium">{t('transaction_sync_title')}</p>
<p className="text-sm text-muted-foreground">{t('transaction_sync_description')}</p>
{connection.transaction_sync_enabled ? (
<p className="text-xs text-muted-foreground">
{connection.last_order_synced_at
? t('transaction_sync_last_synced', {
date: formatDateLong(connection.last_order_synced_at),
})
: t('transaction_sync_never_synced')}
</p>
) : (
<p className="text-xs text-muted-foreground">
{t('transaction_sync_backfill_note')}
</p>
{isActive && connection.store_url && (
<PaymentMethodMappingForm
platform="woocommerce"
storeScope={storeScopeOf(connection.store_url)}
/>
)}
</div>
<Switch
checked={connection.transaction_sync_enabled}
onCheckedChange={handleToggleTransactionSync}
disabled={togglingTransactionSync}
aria-label={t('transaction_sync_title')}
)
})}
<div className="space-y-4">
{hasActive && (
<p className="text-sm font-medium">{t('add_store')}</p>
)}
<div className="space-y-2">
<Label htmlFor="woocommerce-store-url">{t('store_url_label')}</Label>
<Input
id="woocommerce-store-url"
type="url"
inputMode="url"
placeholder="https://minbutik.se"
value={storeUrl}
onChange={(e) => setStoreUrl(e.target.value)}
disabled={connecting}
/>
</div>
)}
{manualMode ? (
<div className="space-y-4 rounded-lg border border-border p-4">
<p className="text-sm text-muted-foreground">{t('manual_hint')}</p>
<div className="space-y-2">
<Label htmlFor="woocommerce-consumer-key">{t('consumer_key_label')}</Label>
<Input
id="woocommerce-consumer-key"
autoComplete="off"
placeholder="ck_..."
value={consumerKey}
onChange={(e) => setConsumerKey(e.target.value)}
disabled={connecting}
/>
</div>
<div className="space-y-2">
<Label htmlFor="woocommerce-consumer-secret">{t('consumer_secret_label')}</Label>
<Input
id="woocommerce-consumer-secret"
type="password"
autoComplete="off"
placeholder="cs_..."
value={consumerSecret}
onChange={(e) => setConsumerSecret(e.target.value)}
disabled={connecting}
/>
</div>
<div className="flex items-center gap-2">
<Button
onClick={handleManualConnect}
disabled={connecting || !storeUrl || !consumerKey || !consumerSecret}
>
{connecting ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<KeyRound className="mr-2 h-4 w-4" />
)}
{connecting ? t('connecting') : t('manual_connect')}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setManualMode(false)}
disabled={connecting}
>
{t('cancel')}
</Button>
</div>
</div>
) : (
<div>
<div className="flex items-center gap-2">
<Button onClick={handleConnect} disabled={connecting || !storeUrl}>
{connecting ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Link2 className="mr-2 h-4 w-4" />
)}
{connecting ? t('connecting') : t('connect')}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setManualMode(true)}
disabled={connecting}
>
{t('manual_toggle')}
</Button>
</div>
<p className="mt-3 text-sm text-muted-foreground">{t('connect_hint')}</p>
</div>
)}
</div>
</CardContent>
</Card>
)
+389 -260
View File
@@ -1,9 +1,9 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { ingestTransactions } from '@/lib/transactions/ingest'
import { ensureManualCashAccount } from '@/lib/cash-accounts/service'
import { syncMappedAccounts } from '@/lib/import/account-sync'
import { upsertWebshopOrders } from '@/lib/webshop-orders/ingest'
import type { WebshopOrderUpsert } from '@/lib/webshop-orders/types'
import { createLogger, type Logger } from '@/lib/logger'
import type { RawTransaction } from '@/types'
import { roundOre as round } from '@/lib/money'
import type { WebshopOrderLineItem, WebshopVatBreakdownLine } from '@/types'
import {
listOrdersPage,
listOrderRefunds,
@@ -17,23 +17,24 @@ import type { WooCommerceConnection, WooOrder, WooRefund } from '../types'
const defaultLog = createLogger('woocommerce/order-sync')
/**
* WooCommerce order sync: the store's paid orders and refunds treated as a
* bank-style feed.
* WooCommerce order sync: the store's orders and refunds as rich rows in
* public.webshop_orders (the Orders page), replacing the earlier
* transactions-inbox feed.
*
* The store becomes a cash account on ledger 1680 (Andra kortfristiga
* fordringar: money the payment gateways owe the merchant), and orders land
* in the transactions inbox exactly like PSD2 bank rows: deduped on
* external_id, bound to the cash account so booking settles against 1680, and
* categorized/booked by the user through the normal flows. Nothing here
* auto-books. 1686 (Fordringar för kontokort och kuponger) would be the
* closest BAS account but is owned by the Stripe feed, and cash_accounts
* enforces one account per ledger per company.
* Every non-trash order imports (including unpaid ones: the Orders page shows
* order state and the invoice flow needs pre-payment orders), carrying the
* full booking underlag the wc/v3 payload already contains: customer billing
* snapshot, payment method, per-rate VAT breakdown and line items. Refunds
* are separate negative rows parented to their order. Nothing here books
* anything: booking is a manual per-order act on the Orders page (feed-only
* doctrine, same as before, one table over).
*
* Row model: a paid order produces one positive row for its gross total; each
* refund produces one negative row. Payment-processor fees never appear:
* core wc/v3 does not expose them (they belong to the gateway, e.g. the
* Stripe feed for Stripe-gateway stores). order.transaction_id rides along as
* the row reference for later gateway-side reconciliation.
* The write path is upsertWebshopOrders() (lib/webshop-orders/ingest), which
* owns FX enrichment, the frozen-row rules for booked orders and the
* cross-mark against rows the retired transactions feed already imported.
* Rows already imported as transactions stay bookable there; the external_id
* scheme below is shared with that feed precisely so the overlap is a string
* join.
*
* Pagination is CURSOR-based, not offset-based: each request asks for the
* oldest orders with date_modified strictly after the current cursor
@@ -50,32 +51,24 @@ const defaultLog = createLogger('woocommerce/order-sync')
* picked up by the next run's overlap re-poll.
*
* Cursor: woocommerce_connections.last_order_synced_at, re-polled with a 24h
* overlap. It never advances past failed work: a page with refund-fetch
* failures, ingest errors, or deadline-skipped refunds caps the persisted
* cursor just below the earliest affected order's date_modified, so the next
* run re-lists exactly the orders whose rows are incomplete (re-seen complete
* rows collide on (company_id, external_id) and are skipped). First run
* fetches BACKFILL_DAYS back.
* overlap. Overlap re-polls are how status changes, late date_paid and new
* refunds land: they are real upserts now, not dedup no-ops. The cursor
* never advances past failed work: a page with refund-fetch failures, upsert
* errors, or deadline-skipped refunds caps the persisted cursor just below
* the earliest affected order's date_modified, so the next run re-lists
* exactly the orders whose rows are incomplete. First run fetches
* BACKFILL_DAYS back.
*
* Lock-date guard: modified_after selects on date_modified, but rows are
* dated by date_paid / refund date_created, which can be arbitrarily older
* (a refund or edit bumps date_modified long after payment). Rows dated on or
* before company_settings.bookkeeping_locked_through are therefore dropped at
* map time on EVERY run: the enforce_company_lock_date trigger makes them
* permanently unbookable, and feed rows are undeletable by design, so
* importing them would create permanent inbox noise. Dropped rows are counted
* in skipped_locked and logged.
* Rows behind company_settings.bookkeeping_locked_through import too (the
* page is an order overview, not just a booking queue); the booking route
* and the period-lock triggers refuse to BOOK them, and the UI explains why.
*/
/** BAS ledger account for the WooCommerce store cash account. */
export const WOOCOMMERCE_LEDGER_ACCOUNT = '1680'
/** BAS 2026 name for 1680; used when creating the chart account. */
const WOOCOMMERCE_LEDGER_ACCOUNT_NAME = 'Andra kortfristiga fordringar'
/** transactions.import_source for WooCommerce feed rows. */
/** transactions.import_source the retired feed used; kept for reference. */
export const WOOCOMMERCE_IMPORT_SOURCE = 'woocommerce'
/** First-run backfill window (matches the Enable Banking convention). */
export const BACKFILL_DAYS = 90
/** Cursor re-poll overlap; external_id dedup makes duplicates no-ops. */
/** Cursor re-poll overlap; upsert-on-external_id makes overlaps idempotent. */
const CURSOR_OVERLAP_MS = 24 * 60 * 60 * 1000
/**
* Safety cap on orders per run (matches the Stripe feed's MAX_TXNS_PER_RUN).
@@ -86,11 +79,12 @@ const CURSOR_OVERLAP_MS = 24 * 60 * 60 * 1000
const MAX_ORDERS_PER_RUN = 10_000
/**
* ⚠️ STORED-KEY FORMATS. These are persisted to transactions.external_id and
* dedup compares stored ids byte-for-byte, exactly like the Stripe and Enable
* Banking schemes. Changing a template silently orphans every prior row and
* re-imports the whole feed on the next sync. Locked by the frozen-format
* test in order-sync.test.ts; any change MUST ship a coordinated backfill.
* ⚠️ STORED-KEY FORMATS. These are persisted to webshop_orders.external_id
* (and historically to transactions.external_id by the retired feed; the
* cross-mark join depends on the schemes staying byte-identical). Changing a
* template silently orphans every prior row and re-imports the whole feed on
* the next sync. Locked by the frozen-format test in order-sync.test.ts; any
* change MUST ship a coordinated backfill.
*
* The scope is the store's normalized host(+path), NOT the connection id, so
* a disconnect/reconnect of the same store keeps every previously imported
@@ -113,12 +107,16 @@ export interface WooCommerceSyncSummary {
fetched: number
/** Refund objects fetched for refunded orders in the window. */
refundsFetched: number
/** New inbox rows inserted. */
imported: number
/** Rows skipped by external_id / content dedup. */
duplicates: number
/** Rows dropped because they are dated on/before the bookkeeping lock. */
skippedLocked: number
/** New webshop_orders rows inserted. */
inserted: number
/** Existing rows refreshed (status, refunds, billing, FX). */
updated: number
/** Re-polled rows with nothing new. */
unchanged: number
/** Booked rows whose financials drifted remotely (flagged, not touched). */
frozenFlagged: number
/** Rows linked to a row the retired transactions feed already imported. */
crossMarked: number
errors: number
/** Set when the caller's time budget ran out before all pages processed. */
deadlineReached?: boolean
@@ -126,19 +124,16 @@ export interface WooCommerceSyncSummary {
revoked?: boolean
}
const round = (n: number) => Math.round(n * 100) / 100
/**
* Money fields arrive as strings; unparseable input returns null so callers
* can tell a corrupt total (counted + logged in buildPageRows) from a
* legitimate zero (silently skipped).
* can tell a corrupt total (counted + logged) from a legitimate zero.
*/
function parseAmount(value: string): number | null {
const parsed = Number.parseFloat(value)
return Number.isFinite(parsed) ? round(parsed) : null
}
/** Whether a qualifying order's total cannot be read as money. */
/** Whether an order's total cannot be read as money. */
export function orderAmountUnparseable(order: Pick<WooOrder, 'total'>): boolean {
return parseAmount(order.total) === null
}
@@ -157,172 +152,321 @@ function gmtToMs(timestamp: string): number {
return Date.parse(gmtToIso(timestamp))
}
/**
* Whether an order belongs in the feed: it must have been paid (date_paid is
* the revenue signal; pending/failed/cancelled-before-payment orders never
* carry one) and not be trashed. Status 'refunded' stays IN: a fully refunded
* order was still paid, and its refunds land as separate negative rows so the
* pair nets to zero instead of the gross silently disappearing.
*/
export function orderQualifies(order: Pick<WooOrder, 'status' | 'date_paid_gmt'>): boolean {
return Boolean(order.date_paid_gmt) && order.status !== 'trash'
/** Whether the order has been paid (drives is_paid and refund fetching). */
export function orderIsPaid(order: Pick<WooOrder, 'date_paid_gmt'>): boolean {
return Boolean(order.date_paid_gmt)
}
/** Every non-trash order imports; trash is the store's recycle bin. */
export function orderImports(order: Pick<WooOrder, 'status'>): boolean {
return order.status !== 'trash'
}
/**
* Map a paid order to its gross feed row. Dates use date_paid (when the money
* event happened), not date_created: booked entries, invoice matching, and
* month boundaries all want the payment date. Descriptions are deterministic
* from immutable data (order numbers never change) because the content-dedup
* bridge keys off them.
* Best-effort Swedish orgnr from the billing company field or B2B-plugin
* meta. NEVER trusted for legal invoice fields without user confirmation:
* plugins vary and customers typo.
*/
export function mapOrder(storeScope: string, order: WooOrder): RawTransaction[] {
if (!orderQualifies(order)) return []
const amount = parseAmount(order.total)
if (amount === null || amount === 0) return []
return [
{
date: isoDateOfGmt(order.date_paid_gmt!),
description: `WooCommerce-order #${order.number}`,
amount,
currency: order.currency.toUpperCase(),
external_id: wooOrderExternalId(storeScope, order.id),
import_source: WOOCOMMERCE_IMPORT_SOURCE,
reference: order.transaction_id || null,
},
]
}
/** Map one refund of a paid order to its negative feed row. */
export function mapRefund(
storeScope: string,
order: Pick<WooOrder, 'number' | 'currency'>,
refund: WooRefund,
): RawTransaction[] {
const amount = parseAmount(refund.amount)
if (amount === null || amount === 0) return []
return [
{
date: isoDateOfGmt(refund.date_created_gmt),
description: `WooCommerce-återbetalning order #${order.number}`,
amount: -amount,
currency: order.currency.toUpperCase(),
external_id: wooRefundExternalId(storeScope, refund.id),
import_source: WOOCOMMERCE_IMPORT_SOURCE,
reference: null,
},
]
}
/** Company lock date (YYYY-MM-DD) or null; read once per run. */
async function fetchLockThrough(
supabase: SupabaseClient,
companyId: string,
): Promise<string | null> {
const { data: settings } = await supabase
.from('company_settings')
.select('bookkeeping_locked_through')
.eq('company_id', companyId)
.maybeSingle()
return (
(settings as { bookkeeping_locked_through?: string | null } | null)
?.bookkeeping_locked_through ?? null
)
}
/** Whether a feed-row date is on/before the lock date (=> never bookable). */
export function rowBehindLock(rowDate: string, lockThrough: string | null): boolean {
return lockThrough !== null && rowDate <= lockThrough
}
/**
* Window start (ISO, UTC) for the first modified_after list call. With a
* cursor: cursor minus the 24h overlap. First run: BACKFILL_DAYS back. (The
* lock date no longer floors the window: it selects on date_modified while
* rows are dated by date_paid, so the real guard is rowBehindLock at map
* time, applied on every run.)
*/
function resolveWindowStartIso(connection: WooCommerceConnection): string {
if (connection.last_order_synced_at) {
const cursorMs = Date.parse(connection.last_order_synced_at)
return new Date(Math.max(0, cursorMs - CURSOR_OVERLAP_MS)).toISOString()
export function extractOrgnr(order: Pick<WooOrder, 'billing' | 'meta_data'>): string | null {
const ORGNR = /(\d{6})[-\s]?(\d{4})/
const fromCompany = order.billing?.company?.match(ORGNR)
if (fromCompany) return `${fromCompany[1]}-${fromCompany[2]}`
for (const meta of order.meta_data ?? []) {
if (typeof meta.value !== 'string') continue
if (!/org(anisations)?[._\s-]*n(umme)?r/i.test(meta.key)) continue
const match = meta.value.match(ORGNR)
if (match) return `${match[1]}-${match[2]}`
}
return new Date(Date.now() - BACKFILL_DAYS * 86_400_000).toISOString()
return null
}
/**
* Make sure the store cash account exists (ledger 1680, source manual so a
* later remap/promotion follows the normal cash-account rules) and, on the
* first run, that 1680 exists in the chart of accounts: the booking dialog
* and AccountPicker only list chart accounts.
*
* Currency comes from the store settings read at connect time, falling back
* to the first fetched order's real currency (settings/general is blocked on
* some hardened stores, and guessing SEK for an EUR store would poison the
* account). A conflict with an existing 1680 cash account throws; the caller
* surfaces that on the connection so the panel shows why nothing syncs.
*/
async function ensureStoreAccount(
supabase: SupabaseClient,
connection: WooCommerceConnection,
fallbackCurrency: string | undefined,
firstRun: boolean,
log: Logger,
): Promise<void> {
const currency =
connection.currency?.toUpperCase() || fallbackCurrency?.toUpperCase() || 'SEK'
try {
await ensureManualCashAccount(
supabase,
connection.company_id,
WOOCOMMERCE_LEDGER_ACCOUNT,
currency,
'WooCommerce-saldo',
)
} catch (accountError) {
// Typically a currency conflict with an existing 1680 cash account. Made
// visible on the connection: without this the panel shows a healthy
// "Ansluten" store that silently never syncs.
await supabase
.from('woocommerce_connections')
.update({
error_message:
'Kassakontot för butiken (1680) kunde inte skapas. Kontrollera att befintligt konto 1680 har samma valuta som butiken.',
})
.eq('id', connection.id)
throw accountError
}
if (firstRun) {
const sync = await syncMappedAccounts(
supabase,
connection.company_id,
connection.user_id,
[
{
sourceAccount: WOOCOMMERCE_LEDGER_ACCOUNT,
sourceName: WOOCOMMERCE_LEDGER_ACCOUNT_NAME,
targetAccount: WOOCOMMERCE_LEDGER_ACCOUNT,
targetName: WOOCOMMERCE_LEDGER_ACCOUNT_NAME,
confidence: 1,
matchType: 'exact',
isOverride: false,
},
],
false,
)
if (sync.error) {
// Rows still import and bind to the cash account; only the chart
// listing is affected (the account can be added manually), so this is
// deliberately non-fatal.
log.warn('chart sync for 1680 failed', {
companyId: connection.company_id,
error: sync.error,
})
/** A money-bearing part of an order: product line, shipping or fee. */
interface WooTaxablePart {
total: string
total_tax: string
taxes?: Array<{ id: number; total: string }>
}
function rateMapOf(order: Pick<WooOrder, 'tax_lines'>): Map<number, number> {
const rateByTaxId = new Map<number, number>()
for (const taxLine of order.tax_lines ?? []) {
if (typeof taxLine.rate_percent === 'number') {
rateByTaxId.set(taxLine.rate_id, taxLine.rate_percent)
}
}
return rateByTaxId
}
/** Resolve one part's VAT rate: tax_lines join first, own ratio second. */
function rateOfPart(part: WooTaxablePart, rateByTaxId: Map<number, number>): number {
const net = parseAmount(part.total) ?? 0
const tax = parseAmount(part.total_tax) ?? 0
const taxId = part.taxes?.find((t) => parseAmount(t.total) !== null)?.id
const joined = taxId !== undefined ? rateByTaxId.get(taxId) : undefined
if (joined !== undefined) return joined
// Signed ratio: a negative discount line at 25% has tax/net > 0 too.
return tax !== 0 && net !== 0
? ([25, 12, 6].find((r) => Math.abs(tax / net - r / 100) < 0.01) ?? 25)
: 0
}
/**
* Group the order's line, shipping and fee taxes into per-rate buckets.
* Buckets carry SIGNED amounts: a discount/gift-card line contributes a
* negative net so the booking split books it as a revenue reduction, not
* flipped revenue. When the payload carries no usable tax data at all but
* the order total says tax was charged (hardened stores stripping tax
* detail), returns [] and the booking dialog falls back to ratio inference.
*/
export function buildVatBreakdown(order: WooOrder): WebshopVatBreakdownLine[] {
const rateByTaxId = rateMapOf(order)
const buckets = new Map<number, { net: number; tax: number }>()
const add = (rate: number, net: number, tax: number) => {
const bucket = buckets.get(rate) ?? { net: 0, tax: 0 }
bucket.net = round(bucket.net + net)
bucket.tax = round(bucket.tax + tax)
buckets.set(rate, bucket)
}
const parts: WooTaxablePart[] = [
...(order.line_items ?? []),
...(order.shipping_lines ?? []),
...(order.fee_lines ?? []),
]
if (parts.length === 0) return []
// "Tax data" means an actual per-line tax allocation. wc/v3 serializes
// `taxes: []` on every line even when the store hides tax detail, so an
// empty array proves nothing; a non-empty taxes array or a non-zero
// total_tax does.
let sawTaxData = false
for (const part of parts) {
const net = parseAmount(part.total) ?? 0
const tax = parseAmount(part.total_tax) ?? 0
if ((part.taxes && part.taxes.length > 0) || tax !== 0) sawTaxData = true
add(rateOfPart(part, rateByTaxId), net, tax)
}
const orderTax = parseAmount(order.total_tax) ?? 0
if (!sawTaxData && orderTax > 0) return []
return Array.from(buckets.entries())
.filter(([, { net, tax }]) => net !== 0 || tax !== 0)
.map(([rate, { net, tax }]) => ({ rate, net, tax }))
.sort((a, b) => b.rate - a.rate)
}
/**
* VAT buckets for one refund. Preference order:
* 1. The refund's own line allocation (line_items with negative totals):
* grouped exactly like the order's parts; magnitudes are returned
* positive (the refund row's row_type carries the direction).
* 2. Amount-only refunds: prorate the PARENT order's breakdown by
* refund/order ratio, so the VAT reversal follows the sale's actual mix.
* Never returns a confident 0%-bucket for a sale that carried VAT: that
* would book a refund with no moms reversal (skeptic finding).
*/
export function buildRefundVatBreakdown(
order: WooOrder,
refund: WooRefund,
): { breakdown: WebshopVatBreakdownLine[]; totalTax: number } {
const rateByTaxId = rateMapOf(order)
const refundParts = (refund.line_items ?? []).filter(
(part) => (parseAmount(part.total) ?? 0) !== 0 || (parseAmount(part.total_tax) ?? 0) !== 0,
)
if (refundParts.length > 0) {
const buckets = new Map<number, { net: number; tax: number }>()
for (const part of refundParts) {
const net = Math.abs(parseAmount(part.total) ?? 0)
const tax = Math.abs(parseAmount(part.total_tax) ?? 0)
const rate = rateOfPart(part, rateByTaxId)
const bucket = buckets.get(rate) ?? { net: 0, tax: 0 }
bucket.net = round(bucket.net + net)
bucket.tax = round(bucket.tax + tax)
buckets.set(rate, bucket)
}
const breakdown = Array.from(buckets.entries())
.map(([rate, { net, tax }]) => ({ rate, net, tax }))
.sort((a, b) => b.rate - a.rate)
const totalTax = round(breakdown.reduce((sum, b) => sum + b.tax, 0))
return { breakdown, totalTax }
}
// Amount-only refund: prorate the order's mix. Per-bucket rounding drift
// lands on the booking's 3740 residual line.
const orderBreakdown = buildVatBreakdown(order)
const orderTotal = Math.abs(parseAmount(order.total) ?? 0)
const refundAmount = Math.abs(parseAmount(refund.amount) ?? 0)
if (orderBreakdown.length === 0 || orderTotal === 0 || refundAmount === 0) {
return { breakdown: [], totalTax: 0 }
}
const ratio = refundAmount / orderTotal
const breakdown = orderBreakdown
.map(({ rate, net, tax }) => ({
rate,
net: round(net * ratio),
tax: round(tax * ratio),
}))
.filter(({ net, tax }) => net !== 0 || tax !== 0)
const totalTax = round(breakdown.reduce((sum, b) => sum + b.tax, 0))
return { breakdown, totalTax }
}
/**
* The stored line snapshot covers EVERYTHING inside order.total: product
* lines, shipping and fees. The invoice conversion builds its rows from
* this snapshot, so an omitted shipping line would silently shrink the
* customer's invoice (skeptic finding).
*/
function mapLineItems(order: WooOrder): WebshopOrderLineItem[] {
const rateByTaxId = rateMapOf(order)
const rateOrNull = (part: WooTaxablePart): number | null => {
const taxId = part.taxes?.find((t) => parseAmount(t.total) !== null)?.id
return taxId !== undefined ? (rateByTaxId.get(taxId) ?? null) : null
}
const products = (order.line_items ?? []).map((item) => ({
name: item.name,
quantity: item.quantity,
total: parseAmount(item.total) ?? 0,
total_tax: parseAmount(item.total_tax) ?? 0,
vat_rate: rateOrNull(item),
}))
const shipping = (order.shipping_lines ?? [])
.filter((line) => (parseAmount(line.total) ?? 0) !== 0)
.map((line) => ({
name: line.method_title || 'Frakt',
quantity: 1,
total: parseAmount(line.total) ?? 0,
total_tax: parseAmount(line.total_tax) ?? 0,
vat_rate: rateOrNull(line),
}))
const fees = (order.fee_lines ?? [])
.filter((line) => (parseAmount(line.total) ?? 0) !== 0)
.map((line) => ({
name: line.name || 'Avgift',
quantity: 1,
total: parseAmount(line.total) ?? 0,
total_tax: parseAmount(line.total_tax) ?? 0,
vat_rate: rateOrNull(line),
}))
return [...products, ...shipping, ...fees]
}
function customerName(order: WooOrder): string | null {
const name = [order.billing?.first_name, order.billing?.last_name]
.filter(Boolean)
.join(' ')
.trim()
return name || null
}
/** Sum of refund totals (positive) reported inline on the order. */
function refundedTotal(order: WooOrder): number {
let sum = 0
for (const refund of order.refunds ?? []) {
const amount = parseAmount(refund.total)
if (amount !== null) sum = round(sum + Math.abs(amount))
}
return sum
}
/** Map one order to its webshop_orders upsert row. */
export function mapOrderToWebshopRow(
connection: Pick<WooCommerceConnection, 'id' | 'store_name'>,
storeScope: string,
order: WooOrder,
): WebshopOrderUpsert[] {
if (!orderImports(order)) return []
const total = parseAmount(order.total)
// Zero-total orders (100% coupon) carry no bookable money event; importing
// them would strand an unbookable "Att bokföra" row (the engine refuses
// zero-sum entries and feed rows are undeletable).
if (total === null || total === 0) return []
return [
{
platform: 'woocommerce',
store_scope: storeScope,
store_label: connection.store_name,
connection_id: connection.id,
row_type: 'order',
parent_external_id: null,
external_id: wooOrderExternalId(storeScope, order.id),
platform_order_id: String(order.id),
order_number: order.number,
status: order.status,
is_paid: orderIsPaid(order),
order_date: isoDateOfGmt(order.date_created_gmt),
paid_date: order.date_paid_gmt ? isoDateOfGmt(order.date_paid_gmt) : null,
currency: order.currency.toUpperCase(),
total,
total_tax: parseAmount(order.total_tax) ?? 0,
vat_breakdown: buildVatBreakdown(order),
line_items: mapLineItems(order),
customer_name: customerName(order),
customer_company: order.billing?.company || null,
customer_email: order.billing?.email || null,
customer_orgnr: extractOrgnr(order),
customer_country: order.billing?.country?.toUpperCase() || null,
payment_method: order.payment_method || null,
payment_method_title: order.payment_method_title || null,
gateway_reference: order.transaction_id || null,
refunded_total: refundedTotal(order),
},
]
}
/** Map one refund of a paid order to its negative upsert row. */
export function mapRefundToWebshopRow(
connection: Pick<WooCommerceConnection, 'id' | 'store_name'>,
storeScope: string,
order: WooOrder,
refund: WooRefund,
): WebshopOrderUpsert[] {
const amount = parseAmount(refund.amount)
if (amount === null || amount === 0) return []
// The refund's VAT reversal: from its own line allocation, else prorated
// from the parent order's mix. Without this the refund books with zero
// moms and ruta 10 stays over-declared (skeptic finding). Buckets hold
// positive magnitudes; row_type 'refund' carries the direction, and
// total/total_tax are negative like the money movement.
const { breakdown, totalTax } = buildRefundVatBreakdown(order, refund)
return [
{
platform: 'woocommerce',
store_scope: storeScope,
store_label: connection.store_name,
connection_id: connection.id,
row_type: 'refund',
parent_external_id: wooOrderExternalId(storeScope, order.id),
external_id: wooRefundExternalId(storeScope, refund.id),
platform_order_id: String(refund.id),
order_number: order.number,
status: 'refund',
is_paid: true,
order_date: isoDateOfGmt(refund.date_created_gmt),
paid_date: isoDateOfGmt(refund.date_created_gmt),
currency: order.currency.toUpperCase(),
total: -Math.abs(amount),
total_tax: -totalTax,
vat_breakdown: breakdown,
line_items: [],
customer_name: customerName(order),
customer_company: order.billing?.company || null,
customer_email: order.billing?.email || null,
customer_orgnr: extractOrgnr(order),
customer_country: order.billing?.country?.toUpperCase() || null,
payment_method: order.payment_method || null,
payment_method_title: order.payment_method_title || null,
gateway_reference: null,
refunded_total: 0,
},
]
}
interface PageRowsOutcome {
rows: RawTransaction[]
rows: WebshopOrderUpsert[]
/**
* date_modified (ms) of every order whose refund rows are incomplete this
* run (fetch failed or skipped on deadline). The cursor must not advance
@@ -332,44 +476,34 @@ interface PageRowsOutcome {
hitDeadline: boolean
}
/** Rows for one page of orders: gross rows plus refund rows where present. */
/** Upsert rows for one page of orders: order rows plus refund rows. */
async function buildPageRows(
creds: WooCredentials,
connection: WooCommerceConnection,
storeScope: string,
orders: WooOrder[],
lockThrough: string | null,
summary: WooCommerceSyncSummary,
log: Logger,
deadlineMs?: number,
): Promise<PageRowsOutcome> {
const outcome: PageRowsOutcome = { rows: [], incompleteModifiedMs: [], hitDeadline: false }
const push = (mapped: RawTransaction[]) => {
for (const row of mapped) {
if (rowBehindLock(row.date, lockThrough)) {
summary.skippedLocked += 1
continue
}
outcome.rows.push(row)
}
}
for (const order of orders) {
// A corrupt total is counted and logged, never silently identical to a
// zero-total order. Deliberately NOT held via the cursor: a permanently
// corrupt total would stall the whole feed forever, where a skipped row
// plus a loud error can be followed up.
if (orderQualifies(order) && orderAmountUnparseable(order)) {
if (orderImports(order) && orderAmountUnparseable(order)) {
summary.errors += 1
log.warn('unparseable order total; row skipped', {
orderId: order.id,
total: order.total,
})
}
push(mapOrder(storeScope, order))
// Refunds only exist for qualifying (paid) orders: a refund row without
// its gross counterpart would be an unexplainable negative in the inbox.
if (!orderQualifies(order) || order.refunds.length === 0) continue
outcome.rows.push(...mapOrderToWebshopRow(connection, storeScope, order))
// Refunds only exist for paid orders; a refund row without its parent
// would be an unexplainable negative.
if (!orderIsPaid(order) || (order.refunds?.length ?? 0) === 0) continue
// Refund fetches are one request per refunded order against a slow host;
// without this check a single mass-refund page could blow through the
@@ -392,7 +526,7 @@ async function buildPageRows(
amount: refund.amount,
})
}
push(mapRefund(storeScope, order, refund))
outcome.rows.push(...mapRefundToWebshopRow(connection, storeScope, order, refund))
}
} catch (refundError) {
// The order row still imports; the cursor is capped below this order's
@@ -408,6 +542,18 @@ async function buildPageRows(
return outcome
}
/**
* Window start (ISO, UTC) for the first modified_after list call. With a
* cursor: cursor minus the 24h overlap. First run: BACKFILL_DAYS back.
*/
function resolveWindowStartIso(connection: WooCommerceConnection): string {
if (connection.last_order_synced_at) {
const cursorMs = Date.parse(connection.last_order_synced_at)
return new Date(Math.max(0, cursorMs - CURSOR_OVERLAP_MS)).toISOString()
}
return new Date(Date.now() - BACKFILL_DAYS * 86_400_000).toISOString()
}
export async function syncWooCommerceOrders(
supabase: SupabaseClient,
connection: WooCommerceConnection,
@@ -423,9 +569,11 @@ export async function syncWooCommerceOrders(
const summary: WooCommerceSyncSummary = {
fetched: 0,
refundsFetched: 0,
imported: 0,
duplicates: 0,
skippedLocked: 0,
inserted: 0,
updated: 0,
unchanged: 0,
frozenFlagged: 0,
crossMarked: 0,
errors: 0,
}
if (
@@ -438,8 +586,6 @@ export async function syncWooCommerceOrders(
const creds = credentialsOf(connection)
const storeScope = wooStoreScope(connection.store_url)
const firstRun = !connection.last_order_synced_at
const lockThrough = await fetchLockThrough(supabase, connection.company_id)
let modifiedAfter = resolveWindowStartIso(connection)
// Offset page within a same-timestamp tie only; 1 whenever the cursor moves.
@@ -449,7 +595,6 @@ export async function syncWooCommerceOrders(
: 0
// Earliest incomplete work this run; the persisted cursor never passes it.
let failureFloorMs = Number.POSITIVE_INFINITY
let accountEnsured = false
try {
for (;;) {
@@ -457,7 +602,7 @@ export async function syncWooCommerceOrders(
summary.deadlineReached = true
log.info('time budget exhausted; stopping order sync', {
connectionId: connection.id,
processed: summary.imported + summary.duplicates,
processed: summary.inserted + summary.updated + summary.unchanged,
})
break
}
@@ -469,19 +614,11 @@ export async function syncWooCommerceOrders(
if (orders.length === 0) break
summary.fetched += orders.length
// Deferred until the window is known non-empty so a quiet store costs
// one API call and zero DB writes; also gives us a real order currency
// as the fallback for stores whose settings are unreadable.
if (!accountEnsured) {
await ensureStoreAccount(supabase, connection, orders[0].currency, firstRun, log)
accountEnsured = true
}
const page = await buildPageRows(
creds,
connection,
storeScope,
orders,
lockThrough,
summary,
log,
deadlineMs,
@@ -492,24 +629,22 @@ export async function syncWooCommerceOrders(
const lastMs = gmtToMs(orders[orders.length - 1].date_modified_gmt)
if (page.rows.length > 0) {
// Auto-categorization is skipped on purpose: booking WooCommerce
// money is a human decision in the inbox (feed-only doctrine, same
// as the Stripe feed). Invoice matching still runs (suggestions
// only), and FX enrichment covers non-SEK stores.
const result = await ingestTransactions(
const result = await upsertWebshopOrders(
supabase,
connection.company_id,
connection.user_id,
page.rows,
{ settlementAccount: WOOCOMMERCE_LEDGER_ACCOUNT, skipAutoCategorization: true },
)
summary.imported += result.imported
summary.duplicates += result.duplicates
summary.inserted += result.inserted
summary.updated += result.updated
summary.unchanged += result.unchanged
summary.frozenFlagged += result.frozenFlagged
summary.crossMarked += result.crossMarked
summary.errors += result.errors
if (result.errors > 0) {
// Failed inserts are dropped inside ingest; hold the cursor below
// this page so the next run re-lists and retries it rather than
// turning a transient DB error into permanently missing rows.
// Failed upserts are dropped inside the service; hold the cursor
// below this page so the next run re-lists and retries it rather
// than turning a transient DB error into permanently missing rows.
failureFloorMs = Math.min(failureFloorMs, firstMs - 1000)
}
}
@@ -579,12 +714,6 @@ export async function syncWooCommerceOrders(
throw err
}
if (summary.skippedLocked > 0) {
log.info('rows behind the bookkeeping lock were skipped', {
connectionId: connection.id,
skippedLocked: summary.skippedLocked,
})
}
log.info('woocommerce order sync done', {
connectionId: connection.id,
...summary,
@@ -107,12 +107,13 @@ export interface WooSyncPayload {
transactions?: {
fetched?: number
refundsFetched?: number
imported?: number
duplicates?: number
inserted?: number
updated?: number
unchanged?: number
errors?: number
revoked?: boolean
deadlineReached?: boolean
}
} | null
}
type SyncCounts = {
@@ -148,7 +149,8 @@ export function syncSummary(payload: WooSyncPayload | null): WooSyncSummary {
if (typeof summary.fetched !== 'number') return { reason: 'unknown' }
const fetched = summary.fetched
const imported = typeof summary.imported === 'number' ? summary.imported : 0
// "imported" in the user-facing sentence = new rows this run (inserts).
const imported = typeof summary.inserted === 'number' ? summary.inserted : 0
const errors = typeof summary.errors === 'number' ? summary.errors : 0
if (summary.deadlineReached === true) {
+78 -14
View File
@@ -25,21 +25,27 @@ export interface WooCommerceConnection {
updated_at: string
}
/** Connection fields safe for the browser (never encrypted credentials). */
export type WooCommerceConnectionStatus = Pick<
WooCommerceConnection,
| 'id'
| 'status'
| 'store_url'
| 'store_name'
| 'currency'
| 'error_message'
| 'connected_at'
| 'transaction_sync_enabled'
| 'last_order_synced_at'
>
/** Status payload returned by GET /api/extensions/ext/woocommerce/status. */
export interface WooCommerceStatusResponse {
configured: boolean
connection: Pick<
WooCommerceConnection,
| 'id'
| 'status'
| 'store_url'
| 'store_name'
| 'currency'
| 'error_message'
| 'connected_at'
| 'transaction_sync_enabled'
| 'last_order_synced_at'
> | null
/** First entry of `connections`; kept for the old single-store shape. */
connection: WooCommerceConnectionStatus | null
/** Multi-store: every active connection (or the latest inactive row). */
connections: WooCommerceConnectionStatus[]
}
/**
@@ -59,7 +65,7 @@ export interface WooOrder {
prices_include_tax: boolean
date_created_gmt: string
date_modified_gmt: string
/** Set when payment completed; the feed's inclusion criterion and row date. */
/** Set when payment completed; drives is_paid and the paid date. */
date_paid_gmt: string | null
payment_method: string
payment_method_title: string
@@ -67,15 +73,73 @@ export interface WooOrder {
transaction_id: string
/** Summary of refunds against this order; totals are negative strings. */
refunds: Array<{ id: number; reason: string; total: string }>
/**
* The fields below always ride along in the wc/v3 order payload; the feed
* historically discarded them at map time. The Orders page persists them
* (customer, per-rate VAT, line snapshot), so the type now keeps them.
* All are optional-tolerant at runtime: hardened stores and old WC
* versions can serve partial payloads.
*/
billing?: {
first_name?: string
last_name?: string
company?: string
email?: string
/** ISO 3166-1 alpha-2 (e.g. 'SE'); drives the 0%-sale export/EU hint. */
country?: string
}
line_items?: Array<{
id: number
name: string
quantity: number
/** Line net total after discounts, excl. tax. */
total: string
total_tax: string
taxes?: Array<{ id: number; total: string }>
}>
tax_lines?: Array<{
rate_id: number
rate_percent?: number
label?: string
tax_total: string
shipping_tax_total: string
}>
shipping_lines?: Array<{
method_title?: string
total: string
total_tax: string
taxes?: Array<{ id: number; total: string }>
}>
/** Payment surcharges / plugin fees; part of order.total like shipping. */
fee_lines?: Array<{
name?: string
total: string
total_tax: string
taxes?: Array<{ id: number; total: string }>
}>
meta_data?: Array<{ key: string; value: unknown }>
}
/** Minimal wc/v3 order-refund shape (GET /orders/{id}/refunds). */
/** wc/v3 order-refund shape (GET /orders/{id}/refunds). */
export interface WooRefund {
id: number
/** Refund amount as a positive string. */
amount: string
reason: string
date_created_gmt: string
/**
* Per-line refund allocation with NEGATIVE totals, present when the
* merchant refunded specific lines. Amount-only refunds ship an empty
* array; the sync then prorates VAT from the parent order's breakdown.
*/
line_items?: Array<{
id: number
name?: string
quantity?: number
total: string
total_tax: string
taxes?: Array<{ id: number; total: string }>
}>
}
/** Store metadata read at connect time. */