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