* fix(webshop-orders): stop importing failed WooCommerce orders, remove stale rows on failed transition Failed checkouts carry no money event but imported as permanently unbookable 'Ej betald' rows (user report). orderImports() now excludes 'failed' alongside 'trash', and a re-polled order that transitioned to failed deletes its existing row via removeWebshopOrders(), which enforces the freeze boundary app-side: frozen rows are never deleted, and a parent with a frozen refund child is spared because parent_order_id cascades. Removal failures hold the sync cursor like upsert failures do. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014hQZLCdT56j8nAyoQtAs2C * fix(webshop-orders): make failed-order removal race-safe per skeptic findings Repeat every guard on the DELETE statement itself, not only the candidate select: a row booked/marked/invoiced between the two round trips must survive (TOCTOU refutation). Never remove paid rows (orderRemoves gated on !orderIsPaid plus is_paid=false on both statements): money moved at some point, and paid parents are the only rows that can carry refund children, which also closes the cascade race without a DB trigger. Spare cross-marked rows (legacy_transaction_id): the order may be booked via the retired transactions feed without any freeze column set. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014hQZLCdT56j8nAyoQtAs2C * fix(webshop-orders): audit-log successful failed-order removals Compliance swarm finding (ISO A.8.10): the hard delete logged only its failure path. Every successful removal batch now logs companyId, deleted row ids and the requested external_ids, the only deletion record for pre-bokforing rows that carry no behandlingshistorik. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014hQZLCdT56j8nAyoQtAs2C --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
754 lines
26 KiB
TypeScript
754 lines
26 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
|
|
const listOrdersPage = vi.fn()
|
|
const listOrderRefunds = vi.fn()
|
|
|
|
vi.mock('../lib/api-client', () => ({
|
|
listOrdersPage: (...args: unknown[]) => listOrdersPage(...args),
|
|
listOrderRefunds: (...args: unknown[]) => listOrderRefunds(...args),
|
|
isRevokedCredentialsError: (error: unknown) =>
|
|
error instanceof Error && error.message === 'REVOKED',
|
|
WC_PAGE_SIZE: 100,
|
|
}))
|
|
|
|
vi.mock('@/lib/webshop-orders/ingest', () => ({
|
|
upsertWebshopOrders: vi.fn(),
|
|
removeWebshopOrders: vi.fn(),
|
|
}))
|
|
|
|
import { removeWebshopOrders, upsertWebshopOrders } from '@/lib/webshop-orders/ingest'
|
|
import type { WebshopOrderUpsert } from '@/lib/webshop-orders/types'
|
|
import { encryptCredential } from '../lib/credentials'
|
|
import {
|
|
WOOCOMMERCE_IMPORT_SOURCE,
|
|
buildRefundVatBreakdown,
|
|
buildVatBreakdown,
|
|
extractOrgnr,
|
|
mapOrderToWebshopRow,
|
|
mapRefundToWebshopRow,
|
|
orderImports,
|
|
orderIsPaid,
|
|
orderRemoves,
|
|
syncWooCommerceOrders,
|
|
wooOrderExternalId,
|
|
wooRefundExternalId,
|
|
wooStoreScope,
|
|
} from '../lib/order-sync'
|
|
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',
|
|
company_id: 'company-1',
|
|
user_id: 'user-1',
|
|
store_url: 'https://shop.example.se',
|
|
store_name: 'Testbutiken',
|
|
consumer_key_encrypted: encryptCredential('ck_test'),
|
|
consumer_secret_encrypted: encryptCredential('cs_test'),
|
|
key_permissions: 'read',
|
|
status: 'active',
|
|
oauth_state: null,
|
|
currency: 'SEK',
|
|
prices_include_tax: true,
|
|
wc_version: '9.9.5',
|
|
transaction_sync_enabled: true,
|
|
last_order_synced_at: null,
|
|
error_message: null,
|
|
connected_at: '2026-07-01T00:00:00.000Z',
|
|
disconnected_at: null,
|
|
created_at: '2026-07-01T00:00:00.000Z',
|
|
updated_at: '2026-07-01T00:00:00.000Z',
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
function makeOrder(overrides: Partial<WooOrder> = {}): WooOrder {
|
|
return {
|
|
id: 1042,
|
|
number: '1042',
|
|
status: 'processing',
|
|
currency: 'sek',
|
|
total: '1250.00',
|
|
total_tax: '250.00',
|
|
prices_include_tax: true,
|
|
date_created_gmt: '2026-08-01T09:00:00',
|
|
date_modified_gmt: '2026-08-01T09:05:00',
|
|
date_paid_gmt: '2026-08-01T09:04:30',
|
|
payment_method: 'stripe',
|
|
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() {
|
|
const updates: Array<{ table: string; values: Record<string, unknown> }> = []
|
|
const client = {
|
|
from(table: string) {
|
|
const builder = {
|
|
select: () => builder,
|
|
eq: () => builder,
|
|
maybeSingle: async () => ({ data: null, error: null }),
|
|
update: (values: Record<string, unknown>) => {
|
|
updates.push({ table, values })
|
|
return builder
|
|
},
|
|
}
|
|
return builder
|
|
},
|
|
}
|
|
return { client: client as unknown as SupabaseClient, updates }
|
|
}
|
|
|
|
function cursorUpdates(updates: Array<{ table: string; values: Record<string, unknown> }>) {
|
|
return updates.filter(
|
|
(u) => u.table === 'woocommerce_connections' && 'last_order_synced_at' in u.values,
|
|
)
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
// Termination is an empty page; every test starts from a quiet store and
|
|
// enqueues its pages with mockResolvedValueOnce.
|
|
listOrdersPage.mockResolvedValue([])
|
|
listOrderRefunds.mockResolvedValue([])
|
|
vi.mocked(upsertWebshopOrders).mockResolvedValue({ ...emptyUpsertResult })
|
|
vi.mocked(removeWebshopOrders).mockResolvedValue({ removed: 0, errors: 0 })
|
|
})
|
|
|
|
describe('frozen external_id formats', () => {
|
|
// ⚠️ 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',
|
|
)
|
|
})
|
|
|
|
it('refund id format is frozen', () => {
|
|
expect(wooRefundExternalId('shop.example.se', 77)).toBe(
|
|
'woo_shop.example.se_refund_77',
|
|
)
|
|
})
|
|
|
|
it('store scope strips exactly the https prefix and keeps host + path', () => {
|
|
expect(wooStoreScope('https://shop.example.se')).toBe('shop.example.se')
|
|
expect(wooStoreScope('https://example.se/butik')).toBe('example.se/butik')
|
|
})
|
|
|
|
it('legacy import source constant is frozen', () => {
|
|
expect(WOOCOMMERCE_IMPORT_SOURCE).toBe('woocommerce')
|
|
})
|
|
})
|
|
|
|
describe('orderImports / orderRemoves / orderIsPaid', () => {
|
|
it('every status except trash and failed 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)
|
|
expect(orderImports(makeOrder({ status: 'failed', date_paid_gmt: null }))).toBe(false)
|
|
})
|
|
|
|
it('only unpaid failed orders trigger removal of an existing row', () => {
|
|
expect(orderRemoves(makeOrder({ status: 'failed', date_paid_gmt: null }))).toBe(true)
|
|
// A failed order that was at some point paid keeps its row: money moved.
|
|
expect(orderRemoves(makeOrder({ status: 'failed' }))).toBe(false)
|
|
expect(orderRemoves(makeOrder({ status: 'trash', date_paid_gmt: null }))).toBe(false)
|
|
expect(orderRemoves(makeOrder())).toBe(false)
|
|
expect(orderRemoves(makeOrder({ status: 'cancelled' }))).toBe(false)
|
|
})
|
|
|
|
it('is_paid follows date_paid', () => {
|
|
expect(orderIsPaid(makeOrder())).toBe(true)
|
|
expect(orderIsPaid(makeOrder({ date_paid_gmt: null }))).toBe(false)
|
|
})
|
|
})
|
|
|
|
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('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 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('mapRefundToWebshopRow', () => {
|
|
const connection = { id: 'conn-1', store_name: 'Testbutiken' }
|
|
const refund: WooRefund = {
|
|
id: 77,
|
|
amount: '250.00',
|
|
reason: 'Retur',
|
|
date_created_gmt: '2026-08-03T10:00:00',
|
|
}
|
|
|
|
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(
|
|
mapRefundToWebshopRow(connection, 's', makeOrder(), { ...refund, amount: '0' }),
|
|
).toEqual([])
|
|
})
|
|
})
|
|
|
|
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('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' }],
|
|
})
|
|
listOrdersPage.mockResolvedValueOnce([order])
|
|
listOrderRefunds.mockResolvedValueOnce([
|
|
{ id: 77, amount: '250.00', reason: 'Retur', date_created_gmt: '2026-08-03T10:00:00' },
|
|
])
|
|
vi.mocked(upsertWebshopOrders).mockResolvedValueOnce({
|
|
...emptyUpsertResult,
|
|
inserted: 2,
|
|
})
|
|
|
|
const summary = await syncWooCommerceOrders(client, makeConnection())
|
|
|
|
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 WebshopOrderUpsert[]).map((r) => r.external_id)).toEqual([
|
|
'woo_shop.example.se_order_1042',
|
|
'woo_shop.example.se_refund_77',
|
|
])
|
|
|
|
// Cursor persisted from the page's max date_modified_gmt, branded UTC,
|
|
// and any stale error_message is cleared on progress.
|
|
const cursors = cursorUpdates(updates)
|
|
expect(cursors).toHaveLength(1)
|
|
expect(cursors[0].values.last_order_synced_at).toBe('2026-08-01T09:05:00.000Z')
|
|
expect(cursors[0].values.error_message).toBeNull()
|
|
|
|
// Second list call proves cursor pagination: modified_after advanced to
|
|
// the last row's timestamp, page reset to 1, terminated by the empty page.
|
|
expect(listOrdersPage).toHaveBeenCalledTimes(2)
|
|
expect(listOrdersPage.mock.calls[1][1]).toEqual({
|
|
modifiedAfter: '2026-08-01T09:05:00.000Z',
|
|
page: 1,
|
|
})
|
|
})
|
|
|
|
it('imports unpaid orders without fetching refunds for them', async () => {
|
|
const { client } = makeSupabaseMock()
|
|
listOrdersPage.mockResolvedValueOnce([
|
|
makeOrder({ status: 'pending', date_paid_gmt: null, refunds: [] }),
|
|
])
|
|
|
|
await syncWooCommerceOrders(client, makeConnection())
|
|
|
|
expect(listOrderRefunds).not.toHaveBeenCalled()
|
|
const [, , , rows] = vi.mocked(upsertWebshopOrders).mock.calls[0]
|
|
expect((rows as WebshopOrderUpsert[])[0]).toMatchObject({ is_paid: false })
|
|
})
|
|
|
|
it('removes the existing row of a failed order instead of importing it', async () => {
|
|
const { client, updates } = makeSupabaseMock()
|
|
listOrdersPage.mockResolvedValueOnce([
|
|
makeOrder({
|
|
status: 'failed',
|
|
date_paid_gmt: null,
|
|
// A stray refunds stub must not trigger a refund fetch for a
|
|
// non-importing order.
|
|
refunds: [{ id: 9, reason: '', total: '-100.00' }],
|
|
}),
|
|
])
|
|
vi.mocked(removeWebshopOrders).mockResolvedValueOnce({ removed: 1, errors: 0 })
|
|
|
|
const summary = await syncWooCommerceOrders(client, makeConnection())
|
|
|
|
expect(summary).toMatchObject({ fetched: 1, inserted: 0, removed: 1, errors: 0 })
|
|
expect(upsertWebshopOrders).not.toHaveBeenCalled()
|
|
expect(listOrderRefunds).not.toHaveBeenCalled()
|
|
expect(removeWebshopOrders).toHaveBeenCalledWith(client, 'company-1', [
|
|
'woo_shop.example.se_order_1042',
|
|
])
|
|
// The removal is complete work: the cursor advances normally.
|
|
const cursors = cursorUpdates(updates)
|
|
expect(cursors).toHaveLength(1)
|
|
expect(cursors[0].values.last_order_synced_at).toBe('2026-08-01T09:05:00.000Z')
|
|
})
|
|
|
|
it('neither imports nor removes a paid order the store reports as failed', async () => {
|
|
const { client } = makeSupabaseMock()
|
|
listOrdersPage.mockResolvedValueOnce([makeOrder({ status: 'failed' })])
|
|
|
|
const summary = await syncWooCommerceOrders(client, makeConnection())
|
|
|
|
expect(summary).toMatchObject({ fetched: 1, inserted: 0, removed: 0, errors: 0 })
|
|
expect(upsertWebshopOrders).not.toHaveBeenCalled()
|
|
expect(removeWebshopOrders).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('holds the cursor below a page whose removal reported errors', async () => {
|
|
const { client, updates } = makeSupabaseMock()
|
|
listOrdersPage.mockResolvedValueOnce([makeOrder({ status: 'failed', date_paid_gmt: null })])
|
|
vi.mocked(removeWebshopOrders).mockResolvedValueOnce({ removed: 0, 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('holds the cursor below an order whose refund fetch failed', async () => {
|
|
const { client, updates } = makeSupabaseMock()
|
|
const order = makeOrder({ refunds: [{ id: 77, reason: '', total: '-250.00' }] })
|
|
listOrdersPage.mockResolvedValueOnce([order])
|
|
listOrderRefunds.mockRejectedValueOnce(new Error('502 from host'))
|
|
|
|
const summary = await syncWooCommerceOrders(client, makeConnection())
|
|
|
|
expect(summary.errors).toBe(1)
|
|
// date_modified 09:05:00 minus 1s: the next run re-lists this order.
|
|
const cursors = cursorUpdates(updates)
|
|
expect(cursors).toHaveLength(1)
|
|
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) =>
|
|
makeOrder({ id: i + 1, number: String(i + 1) }),
|
|
)
|
|
const later = makeOrder({
|
|
id: 500,
|
|
number: '500',
|
|
date_modified_gmt: '2026-08-01T10:00:00',
|
|
})
|
|
listOrdersPage.mockResolvedValueOnce(tie).mockResolvedValueOnce([later])
|
|
|
|
const summary = await syncWooCommerceOrders(client, makeConnection())
|
|
|
|
expect(summary.fetched).toBe(101)
|
|
expect(listOrdersPage).toHaveBeenCalledTimes(3)
|
|
const [firstArgs, secondArgs, thirdArgs] = listOrdersPage.mock.calls.map((c) => c[1])
|
|
// Full page, all one timestamp: same cursor, next offset page.
|
|
expect(secondArgs).toEqual({ modifiedAfter: firstArgs.modifiedAfter, page: 2 })
|
|
// Progress within the tie page: cursor moves, offset resets.
|
|
expect(thirdArgs).toEqual({ modifiedAfter: '2026-08-01T10:00:00.000Z', page: 1 })
|
|
})
|
|
|
|
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' })])
|
|
|
|
const summary = await syncWooCommerceOrders(client, makeConnection())
|
|
|
|
expect(summary.errors).toBe(1)
|
|
expect(upsertWebshopOrders).not.toHaveBeenCalled()
|
|
// Deliberate: a permanently corrupt total must not stall the feed.
|
|
expect(cursorUpdates(updates)).toHaveLength(1)
|
|
})
|
|
|
|
it('does nothing for a connection without credentials or not active', async () => {
|
|
const { client } = makeSupabaseMock()
|
|
const summary = await syncWooCommerceOrders(
|
|
client,
|
|
makeConnection({ consumer_key_encrypted: null }),
|
|
)
|
|
expect(summary.fetched).toBe(0)
|
|
expect(listOrdersPage).not.toHaveBeenCalled()
|
|
|
|
const revokedSummary = await syncWooCommerceOrders(
|
|
client,
|
|
makeConnection({ status: 'revoked' }),
|
|
)
|
|
expect(revokedSummary.fetched).toBe(0)
|
|
})
|
|
|
|
it('flips the connection to revoked when the store rejects the credentials', async () => {
|
|
const { client, updates } = makeSupabaseMock()
|
|
listOrdersPage.mockRejectedValueOnce(new Error('REVOKED'))
|
|
|
|
const summary = await syncWooCommerceOrders(client, makeConnection())
|
|
|
|
expect(summary.revoked).toBe(true)
|
|
const revokeUpdate = updates.find((u) => u.table === 'woocommerce_connections')
|
|
expect(revokeUpdate?.values.status).toBe('revoked')
|
|
})
|
|
|
|
it('stops before fetching when the deadline is already reached', async () => {
|
|
const { client } = makeSupabaseMock()
|
|
const summary = await syncWooCommerceOrders(
|
|
client,
|
|
makeConnection(),
|
|
undefined,
|
|
Date.now() - 1,
|
|
)
|
|
expect(summary.deadlineReached).toBe(true)
|
|
expect(listOrdersPage).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('skips remaining refund fetches on deadline and holds the cursor for them', async () => {
|
|
const { client, updates } = makeSupabaseMock()
|
|
const refunded = makeOrder({ refunds: [{ id: 77, reason: '', total: '-1.00' }] })
|
|
// The list call itself consumes the whole budget, so the deadline is
|
|
// comfortably alive at the loop check and expired by the refund loop.
|
|
const deadlineMs = Date.now() + 200
|
|
listOrdersPage.mockImplementationOnce(async () => {
|
|
await new Promise((resolve) => setTimeout(resolve, 250))
|
|
return [refunded]
|
|
})
|
|
const summary = await syncWooCommerceOrders(client, makeConnection(), undefined, deadlineMs)
|
|
|
|
expect(summary.deadlineReached).toBe(true)
|
|
expect(listOrderRefunds).not.toHaveBeenCalled()
|
|
const cursors = cursorUpdates(updates)
|
|
expect(cursors).toHaveLength(1)
|
|
expect(cursors[0].values.last_order_synced_at).toBe('2026-08-01T09:04:59.000Z')
|
|
})
|
|
})
|