feat(woocommerce): store order/refund feed extension (#1442)
* feat(woocommerce): store order/refund feed extension Connect a WooCommerce store via the wc-auth key handshake (manual key fallback) with per-store consumer key/secret AES-256-GCM encrypted at rest, and import paid orders and refunds into the transactions inbox as a bank-style feed on the 1680 cash account. Feed-only: nothing auto-books, gateway fees/payouts are out of scope (core wc/v3 does not expose them). Sync is cursor-paginated on modified_after (offset pages only inside same-second date_modified ties), terminates on an empty page, holds the cursor below failed refund fetches / ingest errors / deadline-skipped work, checks the time budget between refund fetches, and drops rows dated on or before bookkeeping_locked_through on every run. Nightly cron gated on the extension registry + new paid capability woocommerce_sync (backfilled to existing bank_sync grant holders). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(migrations): move woocommerce migrations past main's 20260806090000 origin/main gained 20260806090000_recurring_schedule_interval_months while this branch was in flight; identical version timestamps abort the Supabase apply, so the two new migrations move to 20260806170000/20260806170100. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(woocommerce): resolve CodeRabbit review findings - callback 503s early when WOOCOMMERCE_CREDENTIALS_ENCRYPTION_KEY is unset: encryptCredential would otherwise throw after the probe and strand the pending row without error_message - disconnect and upstream-revoke clear the encrypted consumer key/secret: nothing reads them after revoke and keeping decryptable dead credentials is unnecessary retention - manual sync gets a 240s time budget and the panel reports a truncated run as 'partial, sync again' instead of a normal completion - listOrderRefunds terminates on an empty batch (hosts may cap per_page), dedupes by id against hosts that ignore page, and caps total pages - unparseable money strings count as errors and log instead of being silently identical to a zero total - pg test uses per-run unique store URLs so committed rows cannot hit the store_url partial unique index across pg-real runs Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(woocommerce): resolve CodeRabbit cycle-2 findings - listOrderRefunds throws when the page cap is exhausted with data still flowing, instead of returning a silently partial list the sync cursor would advance past; the error routes into the existing held-cursor refund-retry path - partial sync results keep the row-error count, and the partial toast string surfaces it (ICU plural, hidden at zero) in both locales Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: retrigger CI after dropped push event 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
cd344b6dbb
commit
707d597b2e
@@ -0,0 +1,73 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { listOrderRefunds, type WooCredentials } from '../lib/api-client'
|
||||
|
||||
const CREDS: WooCredentials = {
|
||||
storeUrl: 'https://shop.example.se',
|
||||
consumerKey: 'ck_test',
|
||||
consumerSecret: 'cs_test',
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
function makeRefunds(startId: number, count: number) {
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
id: startId + i,
|
||||
amount: '10.00',
|
||||
reason: '',
|
||||
date_created_gmt: '2026-08-01T10:00:00',
|
||||
}))
|
||||
}
|
||||
|
||||
const fetchMock = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('listOrderRefunds', () => {
|
||||
it('terminates on an empty page, not a short one (hosts may cap per_page)', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(jsonResponse(makeRefunds(1, 50))) // short but non-empty
|
||||
.mockResolvedValueOnce(jsonResponse(makeRefunds(51, 50)))
|
||||
.mockResolvedValueOnce(jsonResponse([]))
|
||||
|
||||
const refunds = await listOrderRefunds(CREDS, 42)
|
||||
expect(refunds).toHaveLength(100)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('stops when a host ignoring `page` repeats the same rows', async () => {
|
||||
// Fresh Response per call: a Response body is single-use.
|
||||
fetchMock.mockImplementation(async () => jsonResponse(makeRefunds(1, 100)))
|
||||
|
||||
const refunds = await listOrderRefunds(CREDS, 42)
|
||||
expect(refunds).toHaveLength(100)
|
||||
// Page 1 full of fresh rows, page 2 identical → zero fresh → stop.
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('throws instead of returning a silently partial list when the page cap is exhausted', async () => {
|
||||
// Ten full pages of genuinely fresh rows: the cap trips with data still
|
||||
// flowing, and a partial return would let the sync cursor pass unseen
|
||||
// refunds. The thrown error routes into the caller's held-cursor retry.
|
||||
fetchMock.mockImplementation(async (url: string | URL) => {
|
||||
const page = Number(new URL(String(url)).searchParams.get('page'))
|
||||
return jsonResponse(makeRefunds(page * 1000, 100))
|
||||
})
|
||||
|
||||
await expect(listOrderRefunds(CREDS, 42)).rejects.toThrow(
|
||||
/Refund pagination cap exceeded/,
|
||||
)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(10)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,355 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
// Force the capability gate to run but stub requireCapability so entitlement
|
||||
// is controlled per test. Mirrors the stripe/enable-banking suites.
|
||||
vi.mock('@/lib/entitlements/has-capability', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/entitlements/has-capability')>()
|
||||
return { ...actual, requireCapability: vi.fn().mockResolvedValue(null) }
|
||||
})
|
||||
|
||||
// Never let a unit test reach a real WooCommerce host: the credential probe
|
||||
// is mocked, the pure helpers (normalizeStoreUrl) stay real.
|
||||
vi.mock('../lib/api-client', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../lib/api-client')>()
|
||||
return { ...actual, testConnectionAndFetchStoreInfo: vi.fn() }
|
||||
})
|
||||
|
||||
// The sync engine has its own suite; here it only needs to be callable.
|
||||
vi.mock('../lib/order-sync', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../lib/order-sync')>()
|
||||
return { ...actual, syncWooCommerceOrders: vi.fn() }
|
||||
})
|
||||
|
||||
vi.mock('@/lib/auth/api-keys', () => ({
|
||||
createServiceClientNoCookies: vi.fn(() => ({ service: true })),
|
||||
}))
|
||||
|
||||
import { woocommerceExtension } from '../index'
|
||||
import { requireCapability, capabilityBlockedResponse } from '@/lib/entitlements/has-capability'
|
||||
import { CAPABILITY } from '@/lib/entitlements/keys'
|
||||
import { testConnectionAndFetchStoreInfo } from '../lib/api-client'
|
||||
import { syncWooCommerceOrders } from '../lib/order-sync'
|
||||
import { decryptCredential } from '../lib/credentials'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
import type { ExtensionContext } from '@/lib/extensions/types'
|
||||
|
||||
function findRoute(method: string, path: string) {
|
||||
const route = woocommerceExtension.apiRoutes?.find(
|
||||
(r) => r.method === method && r.path === path,
|
||||
)
|
||||
expect(route, `${method} ${path} must be registered`).toBeDefined()
|
||||
return route!
|
||||
}
|
||||
|
||||
function makeRequest(method: string, body?: unknown): Request {
|
||||
return new Request('https://test.local/api/extensions/ext/woocommerce/x', {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
function makeContext(supabase: unknown): ExtensionContext {
|
||||
return {
|
||||
userId: 'user-1',
|
||||
companyId: 'company-1',
|
||||
extensionId: 'woocommerce',
|
||||
requestId: 'req_test',
|
||||
supabase,
|
||||
emit: vi.fn().mockResolvedValue(undefined),
|
||||
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
settings: {
|
||||
get: vi.fn().mockResolvedValue(null),
|
||||
set: vi.fn().mockResolvedValue(undefined),
|
||||
clear: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any
|
||||
}
|
||||
|
||||
const USER = { id: 'user-1', is_anonymous: false }
|
||||
|
||||
describe('woocommerce extension routes', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(requireCapability).mockResolvedValue(null)
|
||||
vi.stubEnv('WOOCOMMERCE_CREDENTIALS_ENCRYPTION_KEY', 'test-key')
|
||||
vi.stubEnv('NEXT_PUBLIC_APP_URL', 'http://localhost:3000')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
describe('GET /status', () => {
|
||||
it('returns 401 without a user', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
supabase.auth.getUser.mockResolvedValue({ data: { user: null }, error: null })
|
||||
const res = await findRoute('GET', '/status').handler(
|
||||
makeRequest('GET'),
|
||||
makeContext(supabase),
|
||||
)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('prefers the active connection and reports configured', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null })
|
||||
enqueue({
|
||||
data: [
|
||||
{ id: 'c2', status: 'revoked' },
|
||||
{ id: 'c1', status: 'active', store_url: 'https://shop.example.se' },
|
||||
],
|
||||
})
|
||||
const res = await findRoute('GET', '/status').handler(
|
||||
makeRequest('GET'),
|
||||
makeContext(supabase),
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.configured).toBe(true)
|
||||
expect(body.connection.id).toBe('c1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /connect', () => {
|
||||
it('returns 401 without a user', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
supabase.auth.getUser.mockResolvedValue({ data: { user: null }, error: null })
|
||||
const res = await findRoute('POST', '/connect').handler(
|
||||
makeRequest('POST', {}),
|
||||
makeContext(supabase),
|
||||
)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('blocks anonymous (sandbox) users before any external call', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
supabase.auth.getUser.mockResolvedValue({
|
||||
data: { user: { id: 'user-1', is_anonymous: true } },
|
||||
error: null,
|
||||
})
|
||||
const res = await findRoute('POST', '/connect').handler(
|
||||
makeRequest('POST', {}),
|
||||
makeContext(supabase),
|
||||
)
|
||||
expect(res.status).toBe(403)
|
||||
const body = await res.json()
|
||||
expect(body.sandbox_blocked).toBe(true)
|
||||
})
|
||||
|
||||
it('returns 403 capability_blocked when not entitled', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null })
|
||||
enqueue({ data: { is_sandbox: false } })
|
||||
vi.mocked(requireCapability).mockResolvedValue(
|
||||
capabilityBlockedResponse(CAPABILITY.woocommerce_sync),
|
||||
)
|
||||
const res = await findRoute('POST', '/connect').handler(
|
||||
makeRequest('POST', { store_url: 'https://shop.example.se' }),
|
||||
makeContext(supabase),
|
||||
)
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
|
||||
it('rejects an invalid or http store URL with 400', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null })
|
||||
enqueue({ data: { is_sandbox: false } })
|
||||
const res = await findRoute('POST', '/connect').handler(
|
||||
makeRequest('POST', { store_url: 'http://insecure.se' }),
|
||||
makeContext(supabase),
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('stages a pending row and returns the wc-auth authorize URL', async () => {
|
||||
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
|
||||
supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null })
|
||||
enqueue({ data: { is_sandbox: false } }) // guardSandbox
|
||||
enqueue({ data: [] }) // no existing active/pending
|
||||
enqueue({ data: { id: 'conn-1' } }) // insert pending
|
||||
const res = await findRoute('POST', '/connect').handler(
|
||||
makeRequest('POST', { store_url: 'Shop.Example.se/' }),
|
||||
makeContext(supabase),
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.url).toMatch(/^https:\/\/shop\.example\.se\/wc-auth\/v1\/authorize\?/)
|
||||
expect(body.url).toContain('scope=read')
|
||||
expect(body.url).toContain(
|
||||
encodeURIComponent('http://localhost:3000/api/extensions/woocommerce/callback'),
|
||||
)
|
||||
const inserted = findCall('woocommerce_connections', 'insert')?.[0] as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
expect(inserted.store_url).toBe('https://shop.example.se')
|
||||
expect(inserted.status).toBe('pending')
|
||||
expect(inserted.oauth_state).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /manual-connect', () => {
|
||||
it('rejects missing keys with 400', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null })
|
||||
enqueue({ data: { is_sandbox: false } })
|
||||
const res = await findRoute('POST', '/manual-connect').handler(
|
||||
makeRequest('POST', { store_url: 'https://shop.example.se', consumer_key: 'ck_x' }),
|
||||
makeContext(supabase),
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('rejects with 400 when the credential probe fails', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null })
|
||||
enqueue({ data: { is_sandbox: false } })
|
||||
enqueue({ data: [] }) // no existing connection
|
||||
vi.mocked(testConnectionAndFetchStoreInfo).mockRejectedValue(new Error('401'))
|
||||
const res = await findRoute('POST', '/manual-connect').handler(
|
||||
makeRequest('POST', {
|
||||
store_url: 'https://shop.example.se',
|
||||
consumer_key: 'ck_x',
|
||||
consumer_secret: 'cs_y',
|
||||
}),
|
||||
makeContext(supabase),
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('verifies, encrypts and activates on the happy path', async () => {
|
||||
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
|
||||
supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null })
|
||||
enqueue({ data: { is_sandbox: false } })
|
||||
enqueue({ data: [] }) // no existing connection
|
||||
enqueue({ data: { id: 'conn-1', store_url: 'https://shop.example.se' } }) // insert
|
||||
vi.mocked(testConnectionAndFetchStoreInfo).mockResolvedValue({
|
||||
name: 'Testbutiken',
|
||||
currency: 'SEK',
|
||||
prices_include_tax: true,
|
||||
wc_version: '9.9.5',
|
||||
})
|
||||
const ctx = makeContext(supabase)
|
||||
const res = await findRoute('POST', '/manual-connect').handler(
|
||||
makeRequest('POST', {
|
||||
store_url: 'https://shop.example.se',
|
||||
consumer_key: 'ck_x',
|
||||
consumer_secret: 'cs_y',
|
||||
}),
|
||||
ctx,
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
const inserted = findCall('woocommerce_connections', 'insert')?.[0] as Record<
|
||||
string,
|
||||
string
|
||||
>
|
||||
expect(inserted.status).toBe('active')
|
||||
expect(inserted.store_name).toBe('Testbutiken')
|
||||
// Secrets never stored in plaintext, and they decrypt back.
|
||||
expect(inserted.consumer_key_encrypted).not.toContain('ck_x')
|
||||
expect(decryptCredential(inserted.consumer_key_encrypted)).toBe('ck_x')
|
||||
expect(decryptCredential(inserted.consumer_secret_encrypted)).toBe('cs_y')
|
||||
expect(ctx.emit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'woocommerce.connected' }),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /sync', () => {
|
||||
it('returns 404 without an active connection', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null })
|
||||
enqueue({ data: null })
|
||||
const res = await findRoute('POST', '/sync').handler(
|
||||
makeRequest('POST'),
|
||||
makeContext(supabase),
|
||||
)
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
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' } })
|
||||
vi.mocked(syncWooCommerceOrders).mockResolvedValue({
|
||||
fetched: 3,
|
||||
refundsFetched: 1,
|
||||
imported: 4,
|
||||
duplicates: 0,
|
||||
errors: 0,
|
||||
})
|
||||
const res = await findRoute('POST', '/sync').handler(
|
||||
makeRequest('POST'),
|
||||
makeContext(supabase),
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.transactions.imported).toBe(4)
|
||||
expect(vi.mocked(syncWooCommerceOrders).mock.calls[0][0]).toEqual({ service: true })
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /transaction-sync', () => {
|
||||
it('rejects a non-boolean enabled with 400', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null })
|
||||
const res = await findRoute('POST', '/transaction-sync').handler(
|
||||
makeRequest('POST', { enabled: 'yes' }),
|
||||
makeContext(supabase),
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('persists the toggle for the active connection', async () => {
|
||||
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
|
||||
supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null })
|
||||
enqueue({ data: [{ id: 'conn-1' }] })
|
||||
const res = await findRoute('POST', '/transaction-sync').handler(
|
||||
makeRequest('POST', { enabled: false }),
|
||||
makeContext(supabase),
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
expect(findCall('woocommerce_connections', 'update')?.[0]).toEqual({
|
||||
transaction_sync_enabled: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('DELETE /disconnect', () => {
|
||||
it('returns 404 when no connection exists', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null })
|
||||
enqueue({ data: [] })
|
||||
const res = await findRoute('DELETE', '/disconnect').handler(
|
||||
makeRequest('DELETE', {}),
|
||||
makeContext(supabase),
|
||||
)
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('revokes (never deletes) and emits the audit event', async () => {
|
||||
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
|
||||
supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null })
|
||||
enqueue({
|
||||
data: [{ id: 'conn-1', status: 'active', store_url: 'https://shop.example.se' }],
|
||||
})
|
||||
enqueue({ data: null }) // update
|
||||
const ctx = makeContext(supabase)
|
||||
const res = await findRoute('DELETE', '/disconnect').handler(
|
||||
makeRequest('DELETE', {}),
|
||||
ctx,
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
const updated = findCall('woocommerce_connections', 'update')?.[0] as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
expect(updated.status).toBe('revoked')
|
||||
expect(ctx.emit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'woocommerce.disconnected' }),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import {
|
||||
encryptCredential,
|
||||
decryptCredential,
|
||||
isWooCommerceConfigured,
|
||||
} from '../lib/credentials'
|
||||
import { normalizeStoreUrl } from '../lib/api-client'
|
||||
|
||||
describe('credential codec', () => {
|
||||
beforeEach(() => {
|
||||
process.env.WOOCOMMERCE_CREDENTIALS_ENCRYPTION_KEY = 'test-key'
|
||||
})
|
||||
|
||||
it('round-trips a consumer key', () => {
|
||||
const ciphertext = encryptCredential('ck_1234567890abcdef')
|
||||
expect(ciphertext).not.toContain('ck_1234567890abcdef')
|
||||
expect(decryptCredential(ciphertext)).toBe('ck_1234567890abcdef')
|
||||
})
|
||||
|
||||
it('produces a fresh IV per encryption (no ciphertext reuse)', () => {
|
||||
expect(encryptCredential('cs_secret')).not.toBe(encryptCredential('cs_secret'))
|
||||
})
|
||||
|
||||
it('rejects tampered ciphertext (GCM auth tag)', () => {
|
||||
const ciphertext = encryptCredential('cs_secret')
|
||||
const tampered = ciphertext.slice(0, -2) + (ciphertext.endsWith('AA') ? 'BB' : 'AA')
|
||||
expect(() => decryptCredential(tampered)).toThrow()
|
||||
})
|
||||
|
||||
it('requires the env key', () => {
|
||||
delete process.env.WOOCOMMERCE_CREDENTIALS_ENCRYPTION_KEY
|
||||
expect(isWooCommerceConfigured()).toBe(false)
|
||||
expect(() => encryptCredential('x')).toThrow(/WOOCOMMERCE_CREDENTIALS_ENCRYPTION_KEY/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeStoreUrl', () => {
|
||||
it('normalizes bare domains, case, and trailing slashes', () => {
|
||||
expect(normalizeStoreUrl('MinButik.se')).toBe('https://minbutik.se')
|
||||
expect(normalizeStoreUrl('https://Shop.Example.se/')).toBe('https://shop.example.se')
|
||||
expect(normalizeStoreUrl(' https://shop.example.se ')).toBe('https://shop.example.se')
|
||||
})
|
||||
|
||||
it('keeps subdirectory installs', () => {
|
||||
expect(normalizeStoreUrl('https://example.se/butik/')).toBe('https://example.se/butik')
|
||||
})
|
||||
|
||||
it('refuses private and internal hosts (SSRF guard)', () => {
|
||||
expect(normalizeStoreUrl('https://localhost')).toBeNull()
|
||||
expect(normalizeStoreUrl('https://foo.localhost')).toBeNull()
|
||||
expect(normalizeStoreUrl('https://intranet.local')).toBeNull()
|
||||
expect(normalizeStoreUrl('https://db.internal')).toBeNull()
|
||||
expect(normalizeStoreUrl('https://127.0.0.1')).toBeNull()
|
||||
expect(normalizeStoreUrl('https://10.0.0.5')).toBeNull()
|
||||
expect(normalizeStoreUrl('https://172.20.1.1')).toBeNull()
|
||||
expect(normalizeStoreUrl('https://192.168.1.10')).toBeNull()
|
||||
expect(normalizeStoreUrl('https://169.254.169.254')).toBeNull()
|
||||
expect(normalizeStoreUrl('https://[::1]')).toBeNull()
|
||||
})
|
||||
|
||||
it('refuses http, credentials, queries and garbage', () => {
|
||||
expect(normalizeStoreUrl('http://insecure.se')).toBeNull()
|
||||
expect(normalizeStoreUrl('https://user:pass@shop.se')).toBeNull()
|
||||
expect(normalizeStoreUrl('https://shop.se/?a=1')).toBeNull()
|
||||
expect(normalizeStoreUrl('https://shop.se/#frag')).toBeNull()
|
||||
expect(normalizeStoreUrl('not a url at all')).toBeNull()
|
||||
expect(normalizeStoreUrl('')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,450 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
|
||||
const listOrdersPage = vi.fn()
|
||||
const listOrderRefunds = vi.fn()
|
||||
|
||||
vi.mock('../lib/api-client', () => ({
|
||||
listOrdersPage: (...args: unknown[]) => listOrdersPage(...args),
|
||||
listOrderRefunds: (...args: unknown[]) => listOrderRefunds(...args),
|
||||
isRevokedCredentialsError: (error: unknown) =>
|
||||
error instanceof Error && error.message === 'REVOKED',
|
||||
WC_PAGE_SIZE: 100,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/transactions/ingest', () => ({
|
||||
ingestTransactions: 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 { encryptCredential } from '../lib/credentials'
|
||||
import {
|
||||
WOOCOMMERCE_IMPORT_SOURCE,
|
||||
WOOCOMMERCE_LEDGER_ACCOUNT,
|
||||
mapOrder,
|
||||
mapRefund,
|
||||
orderQualifies,
|
||||
rowBehindLock,
|
||||
syncWooCommerceOrders,
|
||||
wooOrderExternalId,
|
||||
wooRefundExternalId,
|
||||
wooStoreScope,
|
||||
} from '../lib/order-sync'
|
||||
import type { WooCommerceConnection, WooOrder, WooRefund } from '../types'
|
||||
|
||||
process.env.WOOCOMMERCE_CREDENTIALS_ENCRYPTION_KEY = 'test-key'
|
||||
|
||||
function makeConnection(overrides: Partial<WooCommerceConnection> = {}): WooCommerceConnection {
|
||||
return {
|
||||
id: 'conn-1',
|
||||
company_id: 'company-1',
|
||||
user_id: 'user-1',
|
||||
store_url: 'https://shop.example.se',
|
||||
store_name: 'Testbutiken',
|
||||
consumer_key_encrypted: encryptCredential('ck_test'),
|
||||
consumer_secret_encrypted: encryptCredential('cs_test'),
|
||||
key_permissions: 'read',
|
||||
status: 'active',
|
||||
oauth_state: null,
|
||||
currency: 'SEK',
|
||||
prices_include_tax: true,
|
||||
wc_version: '9.9.5',
|
||||
transaction_sync_enabled: true,
|
||||
last_order_synced_at: null,
|
||||
error_message: null,
|
||||
connected_at: '2026-07-01T00:00:00.000Z',
|
||||
disconnected_at: null,
|
||||
created_at: '2026-07-01T00:00:00.000Z',
|
||||
updated_at: '2026-07-01T00:00:00.000Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeOrder(overrides: Partial<WooOrder> = {}): WooOrder {
|
||||
return {
|
||||
id: 1042,
|
||||
number: '1042',
|
||||
status: 'processing',
|
||||
currency: 'sek',
|
||||
total: '1250.00',
|
||||
total_tax: '250.00',
|
||||
prices_include_tax: true,
|
||||
date_created_gmt: '2026-08-01T09:00:00',
|
||||
date_modified_gmt: '2026-08-01T09:05:00',
|
||||
date_paid_gmt: '2026-08-01T09:04:30',
|
||||
payment_method: 'stripe',
|
||||
payment_method_title: 'Kortbetalning',
|
||||
transaction_id: 'pi_abc123',
|
||||
refunds: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/** Minimal chainable supabase mock covering the sync's query patterns. */
|
||||
function makeSupabaseMock(options: { lockThrough?: string | null } = {}) {
|
||||
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,
|
||||
}),
|
||||
update: (values: Record<string, unknown>) => {
|
||||
updates.push({ table, values })
|
||||
return builder
|
||||
},
|
||||
}
|
||||
return builder
|
||||
},
|
||||
}
|
||||
return { client: client as unknown as SupabaseClient, updates }
|
||||
}
|
||||
|
||||
function cursorUpdates(updates: Array<{ table: string; values: Record<string, unknown> }>) {
|
||||
return updates.filter(
|
||||
(u) => u.table === 'woocommerce_connections' && 'last_order_synced_at' in u.values,
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Termination is an empty page; every test starts from a quiet store and
|
||||
// enqueues its pages with mockResolvedValueOnce.
|
||||
listOrdersPage.mockResolvedValue([])
|
||||
listOrderRefunds.mockResolvedValue([])
|
||||
vi.mocked(ingestTransactions).mockResolvedValue({
|
||||
imported: 0,
|
||||
duplicates: 0,
|
||||
errors: 0,
|
||||
} as Awaited<ReturnType<typeof ingestTransactions>>)
|
||||
})
|
||||
|
||||
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).
|
||||
it('order id format is frozen', () => {
|
||||
expect(wooOrderExternalId('shop.example.se', 1042)).toBe(
|
||||
'woo_shop.example.se_order_1042',
|
||||
)
|
||||
})
|
||||
|
||||
it('refund id format is frozen', () => {
|
||||
expect(wooRefundExternalId('shop.example.se', 77)).toBe(
|
||||
'woo_shop.example.se_refund_77',
|
||||
)
|
||||
})
|
||||
|
||||
it('store scope strips exactly the https prefix and keeps host + path', () => {
|
||||
expect(wooStoreScope('https://shop.example.se')).toBe('shop.example.se')
|
||||
expect(wooStoreScope('https://example.se/butik')).toBe('example.se/butik')
|
||||
})
|
||||
|
||||
it('import source and ledger account are 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('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',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('rounds string money to two decimals', () => {
|
||||
const rows = mapOrder('s', makeOrder({ total: '99.995' }))
|
||||
expect(rows[0].amount).toBe(100)
|
||||
})
|
||||
|
||||
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([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('mapRefund', () => {
|
||||
const refund: WooRefund = {
|
||||
id: 77,
|
||||
amount: '250.00',
|
||||
reason: 'Retur',
|
||||
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('skips zero-amount refunds', () => {
|
||||
expect(mapRefund('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('syncWooCommerceOrders', () => {
|
||||
it('ingests order and refund rows against the 1680 cash account and advances the cursor', async () => {
|
||||
const { client, updates } = makeSupabaseMock()
|
||||
const order = makeOrder({
|
||||
refunds: [{ id: 77, reason: 'Retur', total: '-250.00' }],
|
||||
})
|
||||
listOrdersPage.mockResolvedValueOnce([order])
|
||||
listOrderRefunds.mockResolvedValueOnce([
|
||||
{ id: 77, amount: '250.00', reason: 'Retur', date_created_gmt: '2026-08-03T10:00:00' },
|
||||
])
|
||||
vi.mocked(ingestTransactions).mockResolvedValueOnce({
|
||||
imported: 2,
|
||||
duplicates: 0,
|
||||
errors: 0,
|
||||
} as Awaited<ReturnType<typeof ingestTransactions>>)
|
||||
|
||||
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(companyId).toBe('company-1')
|
||||
expect(userId).toBe('user-1')
|
||||
expect((rows as Array<{ external_id: string }>).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.
|
||||
const cursors = cursorUpdates(updates)
|
||||
expect(cursors).toHaveLength(1)
|
||||
expect(cursors[0].values.last_order_synced_at).toBe('2026-08-01T09:05:00.000Z')
|
||||
expect(cursors[0].values.error_message).toBeNull()
|
||||
|
||||
// Second list call proves cursor pagination: modified_after advanced to
|
||||
// the last row's timestamp, page reset to 1, terminated by the empty page.
|
||||
expect(listOrdersPage).toHaveBeenCalledTimes(2)
|
||||
expect(listOrdersPage.mock.calls[1][1]).toEqual({
|
||||
modifiedAfter: '2026-08-01T09:05:00.000Z',
|
||||
page: 1,
|
||||
})
|
||||
})
|
||||
|
||||
it('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' },
|
||||
])
|
||||
|
||||
const summary = 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)
|
||||
})
|
||||
|
||||
it('holds the cursor below an order whose refund fetch failed', async () => {
|
||||
const { client, updates } = makeSupabaseMock()
|
||||
const order = makeOrder({ refunds: [{ id: 77, reason: '', total: '-250.00' }] })
|
||||
listOrdersPage.mockResolvedValueOnce([order])
|
||||
listOrderRefunds.mockRejectedValueOnce(new Error('502 from host'))
|
||||
|
||||
const summary = await syncWooCommerceOrders(client, makeConnection())
|
||||
|
||||
expect(summary.errors).toBe(1)
|
||||
// date_modified 09:05:00 minus 1s: the next run re-lists this order.
|
||||
const cursors = cursorUpdates(updates)
|
||||
expect(cursors).toHaveLength(1)
|
||||
expect(cursors[0].values.last_order_synced_at).toBe('2026-08-01T09:04:59.000Z')
|
||||
})
|
||||
|
||||
it('pages through a full same-timestamp tie by offset, then resumes cursor pagination', async () => {
|
||||
const { client } = makeSupabaseMock()
|
||||
const tie = Array.from({ length: 100 }, (_, i) =>
|
||||
makeOrder({ id: i + 1, number: String(i + 1) }),
|
||||
)
|
||||
const later = makeOrder({
|
||||
id: 500,
|
||||
number: '500',
|
||||
date_modified_gmt: '2026-08-01T10:00:00',
|
||||
})
|
||||
listOrdersPage.mockResolvedValueOnce(tie).mockResolvedValueOnce([later])
|
||||
|
||||
const summary = await syncWooCommerceOrders(client, makeConnection())
|
||||
|
||||
expect(summary.fetched).toBe(101)
|
||||
expect(listOrdersPage).toHaveBeenCalledTimes(3)
|
||||
const [firstArgs, secondArgs, thirdArgs] = listOrdersPage.mock.calls.map((c) => c[1])
|
||||
// Full page, all one timestamp: same cursor, next offset page.
|
||||
expect(secondArgs).toEqual({ modifiedAfter: firstArgs.modifiedAfter, page: 2 })
|
||||
// Progress within the tie page: cursor moves, offset resets.
|
||||
expect(thirdArgs).toEqual({ modifiedAfter: '2026-08-01T10:00:00.000Z', page: 1 })
|
||||
})
|
||||
|
||||
it('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' })])
|
||||
|
||||
const summary = await syncWooCommerceOrders(client, makeConnection())
|
||||
|
||||
expect(summary.errors).toBe(1)
|
||||
expect(ingestTransactions).not.toHaveBeenCalled()
|
||||
// Deliberate: a permanently corrupt total must not stall the feed.
|
||||
expect(cursorUpdates(updates)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('does nothing for a connection without credentials or not active', async () => {
|
||||
const { client } = makeSupabaseMock()
|
||||
const summary = await syncWooCommerceOrders(
|
||||
client,
|
||||
makeConnection({ consumer_key_encrypted: null }),
|
||||
)
|
||||
expect(summary.fetched).toBe(0)
|
||||
expect(listOrdersPage).not.toHaveBeenCalled()
|
||||
|
||||
const revokedSummary = await syncWooCommerceOrders(
|
||||
client,
|
||||
makeConnection({ status: 'revoked' }),
|
||||
)
|
||||
expect(revokedSummary.fetched).toBe(0)
|
||||
})
|
||||
|
||||
it('flips the connection to revoked when the store rejects the credentials', async () => {
|
||||
const { client, updates } = makeSupabaseMock()
|
||||
listOrdersPage.mockRejectedValueOnce(new Error('REVOKED'))
|
||||
|
||||
const summary = await syncWooCommerceOrders(client, makeConnection())
|
||||
|
||||
expect(summary.revoked).toBe(true)
|
||||
const revokeUpdate = updates.find((u) => u.table === 'woocommerce_connections')
|
||||
expect(revokeUpdate?.values.status).toBe('revoked')
|
||||
})
|
||||
|
||||
it('stops before fetching when the deadline is already reached', async () => {
|
||||
const { client } = makeSupabaseMock()
|
||||
const summary = await syncWooCommerceOrders(
|
||||
client,
|
||||
makeConnection(),
|
||||
undefined,
|
||||
Date.now() - 1,
|
||||
)
|
||||
expect(summary.deadlineReached).toBe(true)
|
||||
expect(listOrdersPage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips remaining refund fetches on deadline and holds the cursor for them', async () => {
|
||||
const { client, updates } = makeSupabaseMock()
|
||||
const refunded = makeOrder({ refunds: [{ id: 77, reason: '', total: '-1.00' }] })
|
||||
// The list call itself consumes the whole budget, so the deadline is
|
||||
// comfortably alive at the loop check and expired by the refund loop.
|
||||
const deadlineMs = Date.now() + 200
|
||||
listOrdersPage.mockImplementationOnce(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 250))
|
||||
return [refunded]
|
||||
})
|
||||
const summary = await syncWooCommerceOrders(client, makeConnection(), undefined, deadlineMs)
|
||||
|
||||
expect(summary.deadlineReached).toBe(true)
|
||||
expect(listOrderRefunds).not.toHaveBeenCalled()
|
||||
const cursors = cursorUpdates(updates)
|
||||
expect(cursors).toHaveLength(1)
|
||||
expect(cursors[0].values.last_order_synced_at).toBe('2026-08-01T09:04:59.000Z')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { syncSummary } from '../lib/settings-actions'
|
||||
|
||||
describe('syncSummary', () => {
|
||||
it('maps an unreadable body to unknown', () => {
|
||||
expect(syncSummary(null)).toEqual({ reason: 'unknown' })
|
||||
expect(syncSummary({})).toEqual({ reason: 'unknown' })
|
||||
expect(syncSummary({ transactions: {} })).toEqual({ reason: 'unknown' })
|
||||
})
|
||||
|
||||
it('reports revoked before anything else', () => {
|
||||
expect(syncSummary({ transactions: { revoked: true, fetched: 5 } })).toEqual({
|
||||
reason: 'revoked',
|
||||
})
|
||||
})
|
||||
|
||||
it('reports a deadline-truncated run as partial, never as complete', () => {
|
||||
expect(
|
||||
syncSummary({ transactions: { deadlineReached: true, fetched: 120, imported: 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 } }),
|
||||
).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 },
|
||||
}),
|
||||
).toEqual({ reason: 'partial', values: { fetched: 50, imported: 40, errors: 3 } })
|
||||
})
|
||||
|
||||
it('distinguishes empty, errors and feed outcomes', () => {
|
||||
expect(syncSummary({ transactions: { fetched: 0 } })).toEqual({ reason: 'empty' })
|
||||
expect(
|
||||
syncSummary({ transactions: { fetched: 3, imported: 2, errors: 1 } }),
|
||||
).toEqual({ reason: 'errors', values: { fetched: 3, imported: 2, errors: 1 } })
|
||||
expect(syncSummary({ transactions: { fetched: 3, imported: 3 } })).toEqual({
|
||||
reason: 'feed',
|
||||
values: { fetched: 3, imported: 3 },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,527 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { ApiRouteDefinition, ExtensionContext } from '@/lib/extensions/types'
|
||||
import { checkRateLimit } from '@/lib/auth/rate-limit-http'
|
||||
import { requireCapability } from '@/lib/entitlements/has-capability'
|
||||
import { CAPABILITY } from '@/lib/entitlements/keys'
|
||||
import { guardSandbox, sandboxBlockedResponse } from '@/lib/sandbox/guard'
|
||||
import { createServiceClientNoCookies } from '@/lib/auth/api-keys'
|
||||
import { isWooCommerceConfigured, encryptCredential } from './lib/credentials'
|
||||
import { normalizeStoreUrl, testConnectionAndFetchStoreInfo } from './lib/api-client'
|
||||
import { buildAuthorizeUrl } from './lib/connect'
|
||||
import { syncWooCommerceOrders } from './lib/order-sync'
|
||||
import type { WooCommerceConnection, WooCommerceStatusResponse } from './types'
|
||||
|
||||
// Per-user limits: connect/disconnect start outward-facing handshakes, sync
|
||||
// hits the merchant's WooCommerce host.
|
||||
const RATE_LIMIT_CONNECT = { maxRequests: 10, windowMs: 60_000 }
|
||||
const RATE_LIMIT_DISCONNECT = { maxRequests: 10, windowMs: 60_000 }
|
||||
const RATE_LIMIT_SYNC = { maxRequests: 10, windowMs: 60_000 }
|
||||
|
||||
// A pending row younger than this blocks a second connect attempt so a
|
||||
// double-click cannot start two handshake round-trips (only one state would
|
||||
// survive, stranding the other at the callback).
|
||||
const PENDING_FRESH_MS = 60_000
|
||||
|
||||
const NOT_CONFIGURED_MESSAGE =
|
||||
'WooCommerce-integrationen är inte konfigurerad på den här installationen.'
|
||||
|
||||
/** Columns safe to hand to the browser: never the encrypted credentials. */
|
||||
const STATUS_COLUMNS =
|
||||
'id, status, store_url, store_name, currency, error_message, connected_at, transaction_sync_enabled, last_order_synced_at'
|
||||
|
||||
type AuthedContext = {
|
||||
supabase: ExtensionContext['supabase']
|
||||
userId: string
|
||||
isAnonymous: boolean
|
||||
companyId: string
|
||||
}
|
||||
|
||||
/** Shared auth preamble: cookie user + company context, or an error response. */
|
||||
async function requireUserAndCompany(
|
||||
ctx: ExtensionContext | undefined,
|
||||
): Promise<AuthedContext | NextResponse> {
|
||||
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
if (!ctx?.companyId) {
|
||||
return NextResponse.json({ error: 'Company context required' }, { status: 400 })
|
||||
}
|
||||
return {
|
||||
supabase,
|
||||
userId: user.id,
|
||||
isAnonymous: Boolean(user.is_anonymous),
|
||||
companyId: ctx.companyId,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Guards shared by both connect paths: sandbox users never reach external
|
||||
* stores (same doctrine as Stripe connect), and the feed is a paid
|
||||
* capability (woocommerce_sync).
|
||||
*/
|
||||
async function guardConnectPreconditions(auth: AuthedContext): Promise<NextResponse | null> {
|
||||
if (auth.isAnonymous) return sandboxBlockedResponse()
|
||||
const sandboxBlocked = await guardSandbox(auth.supabase, auth.companyId)
|
||||
if (sandboxBlocked) return sandboxBlocked
|
||||
return requireCapability(auth.supabase, auth.companyId, CAPABILITY.woocommerce_sync)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
async function blockOrSupersedeExisting(auth: AuthedContext): Promise<NextResponse | null> {
|
||||
const { data: existing } = await auth.supabase
|
||||
.from('woocommerce_connections')
|
||||
.select('id, status, created_at')
|
||||
.eq('company_id', auth.companyId)
|
||||
.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.' },
|
||||
{ status: 409 },
|
||||
)
|
||||
}
|
||||
const pending = existing?.filter((c) => c.status === 'pending') ?? []
|
||||
const freshPending = pending.find(
|
||||
(c) => Date.now() - new Date(c.created_at).getTime() < PENDING_FRESH_MS,
|
||||
)
|
||||
if (freshPending) {
|
||||
return NextResponse.json(
|
||||
{ error: 'En anslutning pågår redan. Vänta och försök igen.' },
|
||||
{ status: 409 },
|
||||
)
|
||||
}
|
||||
if (pending.length > 0) {
|
||||
await auth.supabase
|
||||
.from('woocommerce_connections')
|
||||
.update({
|
||||
status: 'error',
|
||||
error_message: 'Superseded by new connection attempt',
|
||||
oauth_state: null,
|
||||
})
|
||||
.eq('company_id', auth.companyId)
|
||||
.eq('status', 'pending')
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export const woocommerceApiRoutes: ApiRouteDefinition[] = [
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/status',
|
||||
handler: async (_request: Request, ctx?: ExtensionContext) => {
|
||||
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.
|
||||
const { data: rows } = await auth.supabase
|
||||
.from('woocommerce_connections')
|
||||
.select(STATUS_COLUMNS)
|
||||
.eq('company_id', auth.companyId)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(10)
|
||||
|
||||
const connection = rows?.find((r) => r.status === 'active') ?? rows?.[0] ?? null
|
||||
const payload: WooCommerceStatusResponse = {
|
||||
configured: isWooCommerceConfigured(),
|
||||
connection,
|
||||
}
|
||||
return NextResponse.json(payload)
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/connect',
|
||||
handler: async (request: Request, ctx?: ExtensionContext) => {
|
||||
const log = ctx?.log ?? console
|
||||
const auth = await requireUserAndCompany(ctx)
|
||||
if (auth instanceof NextResponse) return auth
|
||||
|
||||
const blocked = await guardConnectPreconditions(auth)
|
||||
if (blocked) return blocked
|
||||
|
||||
const rl = await checkRateLimit({
|
||||
prefix: 'woocommerce:connect',
|
||||
identifier: auth.userId,
|
||||
...RATE_LIMIT_CONNECT,
|
||||
})
|
||||
if (!rl.ok) return rl.response!
|
||||
|
||||
if (!isWooCommerceConfigured()) {
|
||||
return NextResponse.json({ error: NOT_CONFIGURED_MESSAGE }, { status: 503 })
|
||||
}
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as { store_url?: unknown }
|
||||
const storeUrl =
|
||||
typeof body.store_url === 'string' ? normalizeStoreUrl(body.store_url) : null
|
||||
if (!storeUrl) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Ange butikens adress som en giltig https-URL.' },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
const conflict = await blockOrSupersedeExisting(auth)
|
||||
if (conflict) return conflict
|
||||
|
||||
// Persist the CSRF state BEFORE handing the user to the store: the
|
||||
// callback locates the row by oauth_state alone, so the row must exist
|
||||
// before the store can ever POST back with that state.
|
||||
const oauthState = crypto.randomUUID()
|
||||
const { data: created, error: insertError } = await auth.supabase
|
||||
.from('woocommerce_connections')
|
||||
.insert({
|
||||
company_id: auth.companyId,
|
||||
user_id: auth.userId,
|
||||
store_url: storeUrl,
|
||||
status: 'pending',
|
||||
oauth_state: oauthState,
|
||||
})
|
||||
.select('id')
|
||||
.single()
|
||||
|
||||
if (insertError || !created) {
|
||||
log.error('[woocommerce] Failed to stage pending connection', {
|
||||
message: insertError?.message,
|
||||
code: insertError?.code,
|
||||
companyId: auth.companyId,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'Kunde inte starta anslutningen. Försök igen.' },
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
|
||||
log.info('[woocommerce] Starting wc-auth handshake', {
|
||||
connection_id: created.id,
|
||||
company_id: auth.companyId,
|
||||
})
|
||||
return NextResponse.json({ url: buildAuthorizeUrl(storeUrl, oauthState) })
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/manual-connect',
|
||||
handler: async (request: Request, ctx?: ExtensionContext) => {
|
||||
const log = ctx?.log ?? console
|
||||
const auth = await requireUserAndCompany(ctx)
|
||||
if (auth instanceof NextResponse) return auth
|
||||
|
||||
const blocked = await guardConnectPreconditions(auth)
|
||||
if (blocked) return blocked
|
||||
|
||||
const rl = await checkRateLimit({
|
||||
prefix: 'woocommerce:connect',
|
||||
identifier: auth.userId,
|
||||
...RATE_LIMIT_CONNECT,
|
||||
})
|
||||
if (!rl.ok) return rl.response!
|
||||
|
||||
if (!isWooCommerceConfigured()) {
|
||||
return NextResponse.json({ error: NOT_CONFIGURED_MESSAGE }, { status: 503 })
|
||||
}
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as {
|
||||
store_url?: unknown
|
||||
consumer_key?: unknown
|
||||
consumer_secret?: unknown
|
||||
}
|
||||
const storeUrl =
|
||||
typeof body.store_url === 'string' ? normalizeStoreUrl(body.store_url) : null
|
||||
const consumerKey =
|
||||
typeof body.consumer_key === 'string' ? body.consumer_key.trim() : ''
|
||||
const consumerSecret =
|
||||
typeof body.consumer_secret === 'string' ? body.consumer_secret.trim() : ''
|
||||
if (!storeUrl) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Ange butikens adress som en giltig https-URL.' },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
if (!consumerKey || !consumerSecret) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Ange både konsumentnyckel och konsumenthemlighet.' },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
const conflict = await blockOrSupersedeExisting(auth)
|
||||
if (conflict) return conflict
|
||||
|
||||
// Verify before storing: a typo'd key must fail here, not at 03:45.
|
||||
let storeInfo
|
||||
try {
|
||||
storeInfo = await testConnectionAndFetchStoreInfo({
|
||||
storeUrl,
|
||||
consumerKey,
|
||||
consumerSecret,
|
||||
})
|
||||
} catch (probeError) {
|
||||
log.warn('[woocommerce] Manual credential probe failed', {
|
||||
companyId: auth.companyId,
|
||||
message: probeError instanceof Error ? probeError.message : String(probeError),
|
||||
})
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'Kunde inte ansluta till butiken med de angivna nycklarna. Kontrollera adressen och att nyckeln har läsbehörighet.',
|
||||
},
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
const { data: created, error: insertError } = await auth.supabase
|
||||
.from('woocommerce_connections')
|
||||
.insert({
|
||||
company_id: auth.companyId,
|
||||
user_id: auth.userId,
|
||||
store_url: storeUrl,
|
||||
store_name: storeInfo.name,
|
||||
currency: storeInfo.currency,
|
||||
prices_include_tax: storeInfo.prices_include_tax,
|
||||
wc_version: storeInfo.wc_version,
|
||||
consumer_key_encrypted: encryptCredential(consumerKey),
|
||||
consumer_secret_encrypted: encryptCredential(consumerSecret),
|
||||
status: 'active',
|
||||
connected_at: new Date().toISOString(),
|
||||
transaction_sync_enabled: true,
|
||||
})
|
||||
.select('id, store_url')
|
||||
.single()
|
||||
|
||||
if (insertError || !created) {
|
||||
const isConflict = insertError?.code === '23505'
|
||||
log.error('[woocommerce] Failed to create manual connection', {
|
||||
message: insertError?.message,
|
||||
code: insertError?.code,
|
||||
companyId: auth.companyId,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: isConflict
|
||||
? 'Butiken är redan ansluten till ett företag.'
|
||||
: 'Kunde inte spara anslutningen. Försök igen.',
|
||||
},
|
||||
{ status: isConflict ? 409 : 500 },
|
||||
)
|
||||
}
|
||||
|
||||
if (ctx?.emit) {
|
||||
try {
|
||||
await ctx.emit({
|
||||
type: 'woocommerce.connected',
|
||||
payload: {
|
||||
connectionId: created.id,
|
||||
storeUrl: created.store_url,
|
||||
userId: auth.userId,
|
||||
companyId: auth.companyId,
|
||||
},
|
||||
})
|
||||
} catch {
|
||||
// Audit event failure must not block the connect itself.
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, connection_id: created.id })
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/sync',
|
||||
handler: async (_request: Request, ctx?: ExtensionContext) => {
|
||||
const log = ctx?.log ?? console
|
||||
const auth = await requireUserAndCompany(ctx)
|
||||
if (auth instanceof NextResponse) return auth
|
||||
|
||||
const capabilityBlocked = await requireCapability(
|
||||
auth.supabase,
|
||||
auth.companyId,
|
||||
CAPABILITY.woocommerce_sync,
|
||||
)
|
||||
if (capabilityBlocked) return capabilityBlocked
|
||||
|
||||
const rl = await checkRateLimit({
|
||||
prefix: 'woocommerce:sync',
|
||||
identifier: auth.userId,
|
||||
...RATE_LIMIT_SYNC,
|
||||
})
|
||||
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
|
||||
.from('woocommerce_connections')
|
||||
.select('*')
|
||||
.eq('company_id', auth.companyId)
|
||||
.eq('status', 'active')
|
||||
.maybeSingle()
|
||||
|
||||
if (!connection) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Ingen ansluten WooCommerce-butik.' },
|
||||
{ status: 404 },
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const serviceClient = createServiceClientNoCookies()
|
||||
// Bounded like the cron: without a deadline a huge first sync against
|
||||
// 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 })
|
||||
} 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.' },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/transaction-sync',
|
||||
handler: async (request: Request, ctx?: ExtensionContext) => {
|
||||
const auth = await requireUserAndCompany(ctx)
|
||||
if (auth instanceof NextResponse) return auth
|
||||
|
||||
const capabilityBlocked = await requireCapability(
|
||||
auth.supabase,
|
||||
auth.companyId,
|
||||
CAPABILITY.woocommerce_sync,
|
||||
)
|
||||
if (capabilityBlocked) return capabilityBlocked
|
||||
|
||||
const rl = await checkRateLimit({
|
||||
prefix: 'woocommerce:transaction-sync-toggle',
|
||||
identifier: auth.userId,
|
||||
...RATE_LIMIT_SYNC,
|
||||
})
|
||||
if (!rl.ok) return rl.response!
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as { enabled?: unknown }
|
||||
if (typeof body.enabled !== 'boolean') {
|
||||
return NextResponse.json({ error: 'enabled (boolean) krävs.' }, { status: 400 })
|
||||
}
|
||||
|
||||
const { data: updated, error: updateError } = await auth.supabase
|
||||
.from('woocommerce_connections')
|
||||
.update({ transaction_sync_enabled: body.enabled })
|
||||
.eq('company_id', auth.companyId)
|
||||
.eq('status', 'active')
|
||||
.select('id')
|
||||
|
||||
if (updateError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Kunde inte spara inställningen. Försök igen.' },
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
if (!updated || updated.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Ingen ansluten WooCommerce-butik.' },
|
||||
{ status: 404 },
|
||||
)
|
||||
}
|
||||
return NextResponse.json({ success: true, enabled: body.enabled })
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'DELETE',
|
||||
path: '/disconnect',
|
||||
handler: async (request: Request, ctx?: ExtensionContext) => {
|
||||
const log = ctx?.log ?? console
|
||||
const auth = await requireUserAndCompany(ctx)
|
||||
if (auth instanceof NextResponse) return auth
|
||||
|
||||
const rl = await checkRateLimit({
|
||||
prefix: 'woocommerce:disconnect',
|
||||
identifier: auth.userId,
|
||||
...RATE_LIMIT_DISCONNECT,
|
||||
})
|
||||
if (!rl.ok) return rl.response!
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as { connection_id?: string }
|
||||
const base = auth.supabase
|
||||
.from('woocommerce_connections')
|
||||
.select('id, status, store_url')
|
||||
.eq('company_id', auth.companyId)
|
||||
const query = body.connection_id
|
||||
? base.eq('id', body.connection_id).limit(1)
|
||||
: base.neq('status', 'revoked').order('created_at', { ascending: false }).limit(1)
|
||||
const { data: rows, error: findError } = await query
|
||||
const connection = rows?.[0]
|
||||
|
||||
if (findError || !connection) {
|
||||
return NextResponse.json({ error: 'Connection not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// There is no remote revoke API: the consumer key lives in the store's
|
||||
// wp-admin and only the merchant can delete it there. We drop our copy
|
||||
// of the credentials outright (nothing reads them after revoke, and a
|
||||
// reconnect inserts a fresh row); the audit row keeps store_url and the
|
||||
// connect/disconnect timestamps. The panel tells the user to remove the
|
||||
// key in WooCommerce as well.
|
||||
const { error: updateError } = await auth.supabase
|
||||
.from('woocommerce_connections')
|
||||
.update({
|
||||
status: 'revoked',
|
||||
oauth_state: null,
|
||||
consumer_key_encrypted: null,
|
||||
consumer_secret_encrypted: null,
|
||||
disconnected_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', connection.id)
|
||||
.eq('company_id', auth.companyId)
|
||||
|
||||
if (updateError) {
|
||||
log.error('[woocommerce] Failed to mark connection revoked', {
|
||||
message: updateError.message,
|
||||
connection_id: connection.id,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'Kunde inte koppla från. Försök igen.' },
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
|
||||
if (ctx?.emit) {
|
||||
try {
|
||||
await ctx.emit({
|
||||
type: 'woocommerce.disconnected',
|
||||
payload: {
|
||||
connectionId: connection.id,
|
||||
storeUrl: connection.store_url ?? null,
|
||||
reason: 'user',
|
||||
userId: auth.userId,
|
||||
companyId: auth.companyId,
|
||||
},
|
||||
})
|
||||
} catch {
|
||||
// Audit event failure must not block the disconnect itself.
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,506 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useLocale, useTranslations } from 'next-intl'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
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 {
|
||||
wooRequest,
|
||||
syncSummary,
|
||||
WOO_CONNECT_TIMEOUT_MS,
|
||||
WOO_SYNC_TIMEOUT_MS,
|
||||
type WooSyncPayload,
|
||||
} from '../lib/settings-actions'
|
||||
import type { WooCommerceStatusResponse } from '../types'
|
||||
|
||||
type ConnectionInfo = NonNullable<WooCommerceStatusResponse['connection']>
|
||||
|
||||
const STATUS_VARIANT: Record<ConnectionInfo['status'], 'success' | 'secondary' | 'destructive' | 'warning'> = {
|
||||
active: 'success',
|
||||
pending: 'secondary',
|
||||
revoked: 'warning',
|
||||
error: 'destructive',
|
||||
}
|
||||
|
||||
export default function WooCommerceSettingsPanel() {
|
||||
const t = useTranslations('woocommerce')
|
||||
const tCommon = useTranslations('common')
|
||||
const locale = useLocale() as ErrorLocale
|
||||
const { toast } = useToast()
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { formatDateLong } = useFormat()
|
||||
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadFailed, setLoadFailed] = useState(false)
|
||||
const [configured, setConfigured] = useState(false)
|
||||
const [connection, setConnection] = useState<ConnectionInfo | null>(null)
|
||||
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)
|
||||
|
||||
const failureCopy = { timeout: t('action_timeout'), network: t('action_network') }
|
||||
|
||||
const loadStatus = useCallback(async () => {
|
||||
// A failed status read must never render as "not configured" (see the
|
||||
// Stripe panel: that copy sends the user to an administrator for nothing).
|
||||
const result = await wooRequest<WooCommerceStatusResponse>({
|
||||
url: '/api/extensions/ext/woocommerce/status',
|
||||
method: 'GET',
|
||||
locale,
|
||||
})
|
||||
setLoading(false)
|
||||
if (!result.ok || !result.data) {
|
||||
setLoadFailed(true)
|
||||
return
|
||||
}
|
||||
setLoadFailed(false)
|
||||
setConfigured(result.data.configured)
|
||||
setConnection(result.data.connection)
|
||||
}, [locale])
|
||||
|
||||
useEffect(() => {
|
||||
void loadStatus()
|
||||
}, [loadStatus])
|
||||
|
||||
function retryLoadStatus() {
|
||||
setLoading(true)
|
||||
void loadStatus()
|
||||
}
|
||||
|
||||
// Consume the one-shot handshake bounce-back params off the render path,
|
||||
// mirroring the Stripe/banking sections.
|
||||
useEffect(() => {
|
||||
const connected = searchParams.get('woocommerce_connected')
|
||||
const error = searchParams.get('woocommerce_error')
|
||||
if (!connected && !error) return
|
||||
|
||||
let cancelled = false
|
||||
queueMicrotask(() => {
|
||||
if (cancelled) return
|
||||
if (connected) {
|
||||
toast({ title: t('connected_toast_title'), description: t('connected_toast_description') })
|
||||
} else if (error) {
|
||||
const message = error === 'denied' ? t('error_denied') : t('error_generic')
|
||||
toast({ title: t('connect_failed_title'), description: message, variant: 'destructive' })
|
||||
}
|
||||
router.replace('/import?mode=woocommerce')
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [searchParams, router, toast, t])
|
||||
|
||||
async function handleConnect() {
|
||||
if (connecting) return
|
||||
setConnecting(true)
|
||||
try {
|
||||
const result = await wooRequest<{ url?: string }>({
|
||||
url: '/api/extensions/ext/woocommerce/connect',
|
||||
body: { store_url: storeUrl },
|
||||
locale,
|
||||
timeoutMs: WOO_CONNECT_TIMEOUT_MS,
|
||||
})
|
||||
if (!result.ok || !result.data?.url) {
|
||||
toast({
|
||||
title: t('connect_failed_title'),
|
||||
description: result.ok ? t('error_generic') : failureDescription(result, failureCopy),
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
window.location.href = result.data.url
|
||||
} finally {
|
||||
setConnecting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleManualConnect() {
|
||||
if (connecting) return
|
||||
setConnecting(true)
|
||||
try {
|
||||
const result = await wooRequest({
|
||||
url: '/api/extensions/ext/woocommerce/manual-connect',
|
||||
body: {
|
||||
store_url: storeUrl,
|
||||
consumer_key: consumerKey,
|
||||
consumer_secret: consumerSecret,
|
||||
},
|
||||
locale,
|
||||
timeoutMs: WOO_CONNECT_TIMEOUT_MS,
|
||||
})
|
||||
if (!result.ok) {
|
||||
toast({
|
||||
title: t('connect_failed_title'),
|
||||
description: failureDescription(result, failureCopy),
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
toast({ title: t('connected_toast_title'), description: t('connected_toast_description') })
|
||||
setConsumerKey('')
|
||||
setConsumerSecret('')
|
||||
setManualMode(false)
|
||||
await loadStatus()
|
||||
} finally {
|
||||
setConnecting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSyncNow() {
|
||||
if (syncing) return
|
||||
setSyncing(true)
|
||||
try {
|
||||
const result = await wooRequest<WooSyncPayload>({
|
||||
url: '/api/extensions/ext/woocommerce/sync',
|
||||
locale,
|
||||
timeoutMs: WOO_SYNC_TIMEOUT_MS,
|
||||
})
|
||||
if (!result.ok) {
|
||||
toast({
|
||||
title: t('sync_failed_title'),
|
||||
description: failureDescription(result, failureCopy),
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
const summary = syncSummary(result.data)
|
||||
if (summary.reason === 'revoked') {
|
||||
toast({
|
||||
title: t('sync_failed_title'),
|
||||
description: t('sync_revoked'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
} else if (summary.reason === 'partial') {
|
||||
toast({ title: t('sync_partial_title'), description: t('sync_partial', summary.values) })
|
||||
} else if (summary.reason === 'empty') {
|
||||
toast({ title: t('sync_done_title'), description: t('sync_done_empty') })
|
||||
} else if (summary.reason === 'errors') {
|
||||
toast({ title: t('sync_done_title'), description: t('sync_done_feed_errors', summary.values) })
|
||||
} else if (summary.reason === 'feed') {
|
||||
toast({ title: t('sync_done_title'), description: t('sync_done_feed', summary.values) })
|
||||
} else {
|
||||
toast({ title: t('sync_done_title') })
|
||||
}
|
||||
await loadStatus()
|
||||
} finally {
|
||||
setSyncing(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleTransactionSync(enabled: boolean) {
|
||||
if (togglingTransactionSync) return
|
||||
setTogglingTransactionSync(true)
|
||||
try {
|
||||
const result = await wooRequest({
|
||||
url: '/api/extensions/ext/woocommerce/transaction-sync',
|
||||
body: { enabled },
|
||||
locale,
|
||||
})
|
||||
if (!result.ok) {
|
||||
toast({
|
||||
title: t('transaction_sync_toggle_failed'),
|
||||
description: failureDescription(result, failureCopy),
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
toast({
|
||||
title: enabled
|
||||
? t('transaction_sync_enabled_toast')
|
||||
: t('transaction_sync_disabled_toast'),
|
||||
})
|
||||
await loadStatus()
|
||||
} finally {
|
||||
setTogglingTransactionSync(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDisconnect() {
|
||||
if (!connection || disconnecting) return
|
||||
setDisconnecting(true)
|
||||
try {
|
||||
const result = await wooRequest({
|
||||
url: '/api/extensions/ext/woocommerce/disconnect',
|
||||
method: 'DELETE',
|
||||
body: { connection_id: connection.id },
|
||||
locale,
|
||||
})
|
||||
if (!result.ok) {
|
||||
toast({
|
||||
title: t('disconnect_failed_title'),
|
||||
description: failureDescription(result, failureCopy),
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
// 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)
|
||||
await loadStatus()
|
||||
} finally {
|
||||
setDisconnecting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="space-y-3 p-6">
|
||||
<Skeleton className="h-5 w-48" />
|
||||
<Skeleton className="h-4 w-72" />
|
||||
<Skeleton className="h-10 w-40" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (loadFailed) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t('title')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 pt-0">
|
||||
<p className="text-sm text-destructive">{t('load_failed')}</p>
|
||||
<Button variant="outline" size="sm" onClick={retryLoadStatus}>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
{tCommon('retry')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (!configured) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t('title')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<p className="text-sm text-muted-foreground">{t('not_configured')}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const isActive = connection?.status === 'active'
|
||||
const showConnectForm = !connection || !isActive
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t('title')}</CardTitle>
|
||||
</CardHeader>
|
||||
<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>
|
||||
</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>
|
||||
)}
|
||||
</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" />
|
||||
) : (
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{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>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
<Switch
|
||||
checked={connection.transaction_sync_enabled}
|
||||
onCheckedChange={handleToggleTransactionSync}
|
||||
disabled={togglingTransactionSync}
|
||||
aria-label={t('transaction_sync_title')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { Extension } from '@/lib/extensions/types'
|
||||
import { woocommerceApiRoutes } from './api-routes'
|
||||
|
||||
/**
|
||||
* WooCommerce extension
|
||||
*
|
||||
* Connects a company's WooCommerce store via the wc-auth key handshake (or
|
||||
* manual key entry) and imports the store's paid orders and refunds into the
|
||||
* transactions inbox as a bank-style feed on the 1680 cash account. Feed-only
|
||||
* (same doctrine as the Stripe feed, decision 2026-08-06): nothing is
|
||||
* auto-booked, and payment-gateway fees/payouts are out of scope: core wc/v3
|
||||
* does not expose them.
|
||||
*
|
||||
* Required environment variables:
|
||||
* - WOOCOMMERCE_CREDENTIALS_ENCRYPTION_KEY (at-rest key for consumer key/secret)
|
||||
*/
|
||||
export const woocommerceExtension: Extension = {
|
||||
id: 'woocommerce',
|
||||
name: 'WooCommerce',
|
||||
version: '1.0.0',
|
||||
sector: 'general',
|
||||
|
||||
settingsPanel: {
|
||||
label: 'WooCommerce',
|
||||
path: '/import?mode=woocommerce',
|
||||
},
|
||||
|
||||
apiRoutes: woocommerceApiRoutes,
|
||||
}
|
||||
|
||||
export default woocommerceExtension
|
||||
@@ -0,0 +1,319 @@
|
||||
import type { WooOrder, WooRefund, WooStoreInfo } from '../types'
|
||||
|
||||
/**
|
||||
* Minimal WooCommerce REST API (wc/v3) client for the order feed.
|
||||
*
|
||||
* Auth is HTTP Basic (consumer key as username, secret as password) over
|
||||
* HTTPS only. Some hosts (Apache CGI, security plugins) strip the
|
||||
* Authorization header, so a 401 is retried once with the documented
|
||||
* query-string credential fallback; that fallback is why plain-http stores
|
||||
* are refused outright (keys in a cleartext URL are a credentials leak).
|
||||
*
|
||||
* Typical WooCommerce hosts are slow shared PHP boxes: requests run
|
||||
* sequentially, pages are capped at 100 rows, and 429/5xx responses get a
|
||||
* short exponential backoff before the error is surfaced.
|
||||
*/
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 30_000
|
||||
const RETRYABLE_STATUS = new Set([429, 502, 503, 504])
|
||||
const RETRY_DELAYS_MS = [1_000, 3_000]
|
||||
/** wc/v3 hard maximum for per_page. */
|
||||
export const WC_PAGE_SIZE = 100
|
||||
|
||||
export interface WooCredentials {
|
||||
storeUrl: string
|
||||
consumerKey: string
|
||||
consumerSecret: string
|
||||
}
|
||||
|
||||
export class WooCommerceApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
/** HTTP status, or 0 for network-level failures. */
|
||||
readonly status: number,
|
||||
/** WooCommerce error code (e.g. woocommerce_rest_cannot_view), if any. */
|
||||
readonly wooCode: string | null = null,
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'WooCommerceApiError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an API error means the credentials themselves are dead (key deleted
|
||||
* or demoted in wp-admin), as opposed to a transient failure. Used to flip a
|
||||
* connection to status 'revoked' so the UI offers a reconnect instead of the
|
||||
* cron retrying forever.
|
||||
*/
|
||||
export function isRevokedCredentialsError(error: unknown): boolean {
|
||||
if (!(error instanceof WooCommerceApiError)) return false
|
||||
return error.status === 401 || error.status === 403
|
||||
}
|
||||
|
||||
/**
|
||||
* Hostnames the server must never fetch: the store URL is user input that we
|
||||
* probe server-side, so loopback/link-local/private ranges and internal
|
||||
* naming conventions are refused outright (SSRF guard). Hostname-level only:
|
||||
* a public DNS name resolving to a private address is not caught here, which
|
||||
* matches the app's other outbound-URL surfaces.
|
||||
*/
|
||||
function isDisallowedHost(hostname: string): boolean {
|
||||
const h = hostname.toLowerCase()
|
||||
if (h === 'localhost' || h.endsWith('.localhost')) return true
|
||||
if (h.endsWith('.local') || h.endsWith('.internal')) return true
|
||||
// IPv6 literals (URL.hostname strips the brackets): never a real store.
|
||||
if (h.includes(':')) return true
|
||||
const v4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(h)
|
||||
if (v4) {
|
||||
const a = Number(v4[1])
|
||||
const b = Number(v4[2])
|
||||
if (a === 0 || a === 10 || a === 127) return true
|
||||
if (a === 169 && b === 254) return true
|
||||
if (a === 172 && b >= 16 && b <= 31) return true
|
||||
if (a === 192 && b === 168) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize and validate a user-entered store URL to an https origin plus
|
||||
* optional subdirectory path (WordPress installs under a path are common),
|
||||
* lowercased host, no trailing slash, no query/fragment/credentials, and no
|
||||
* private/internal hosts. Returns null for anything invalid, including
|
||||
* plain http.
|
||||
*/
|
||||
export function normalizeStoreUrl(input: string): string | null {
|
||||
const trimmed = input.trim()
|
||||
if (!trimmed) return null
|
||||
const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(withScheme)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (url.protocol !== 'https:') return null
|
||||
if (url.username || url.password || url.search || url.hash) return null
|
||||
if (isDisallowedHost(url.hostname)) return null
|
||||
const path = url.pathname.replace(/\/+$/, '')
|
||||
return `https://${url.host.toLowerCase()}${path}`
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function buildUrl(
|
||||
creds: WooCredentials,
|
||||
path: string,
|
||||
params: Record<string, string>,
|
||||
credentialsInQuery: boolean,
|
||||
): string {
|
||||
const url = new URL(`${creds.storeUrl}/wp-json/wc/v3${path}`)
|
||||
for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value)
|
||||
if (credentialsInQuery) {
|
||||
url.searchParams.set('consumer_key', creds.consumerKey)
|
||||
url.searchParams.set('consumer_secret', creds.consumerSecret)
|
||||
}
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
async function requestOnce(
|
||||
creds: WooCredentials,
|
||||
path: string,
|
||||
params: Record<string, string>,
|
||||
credentialsInQuery: boolean,
|
||||
): Promise<Response> {
|
||||
const headers: Record<string, string> = { Accept: 'application/json' }
|
||||
if (!credentialsInQuery) {
|
||||
const basic = Buffer.from(`${creds.consumerKey}:${creds.consumerSecret}`).toString('base64')
|
||||
headers.Authorization = `Basic ${basic}`
|
||||
}
|
||||
return fetch(buildUrl(creds, path, params, credentialsInQuery), {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
})
|
||||
}
|
||||
|
||||
async function parseError(response: Response): Promise<WooCommerceApiError> {
|
||||
let wooCode: string | null = null
|
||||
let detail = ''
|
||||
try {
|
||||
const body = (await response.json()) as { code?: string; message?: string }
|
||||
wooCode = body.code ?? null
|
||||
detail = body.message ?? ''
|
||||
} catch {
|
||||
// Non-JSON error body (host error page); the status is enough.
|
||||
}
|
||||
return new WooCommerceApiError(
|
||||
`WooCommerce API ${response.status}${detail ? `: ${detail}` : ''}`,
|
||||
response.status,
|
||||
wooCode,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* GET a wc/v3 path. Retries the header-stripped-auth case (401 → query-string
|
||||
* credentials) once, and 429/5xx with a short backoff.
|
||||
*/
|
||||
export async function wcGet<T>(
|
||||
creds: WooCredentials,
|
||||
path: string,
|
||||
params: Record<string, string> = {},
|
||||
): Promise<T> {
|
||||
let credentialsInQuery = false
|
||||
let lastError: unknown
|
||||
for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt++) {
|
||||
let response: Response
|
||||
try {
|
||||
response = await requestOnce(creds, path, params, credentialsInQuery)
|
||||
} catch (err) {
|
||||
// Network/timeout errors: retry on the same backoff schedule.
|
||||
lastError = new WooCommerceApiError(
|
||||
`WooCommerce request failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
0,
|
||||
)
|
||||
if (attempt < RETRY_DELAYS_MS.length) {
|
||||
await sleep(RETRY_DELAYS_MS[attempt])
|
||||
continue
|
||||
}
|
||||
throw lastError
|
||||
}
|
||||
|
||||
if (response.ok) return (await response.json()) as T
|
||||
|
||||
if (response.status === 401 && !credentialsInQuery) {
|
||||
// Host may be stripping the Authorization header; the documented
|
||||
// fallback is credentials in the query string (HTTPS enforced upstream).
|
||||
credentialsInQuery = true
|
||||
lastError = await parseError(response)
|
||||
continue
|
||||
}
|
||||
if (RETRYABLE_STATUS.has(response.status) && attempt < RETRY_DELAYS_MS.length) {
|
||||
lastError = await parseError(response)
|
||||
await sleep(RETRY_DELAYS_MS[attempt])
|
||||
continue
|
||||
}
|
||||
throw await parseError(response)
|
||||
}
|
||||
throw lastError instanceof Error
|
||||
? lastError
|
||||
: new WooCommerceApiError('WooCommerce request failed', 0)
|
||||
}
|
||||
|
||||
export interface ListOrdersOptions {
|
||||
/** ISO timestamp; interpreted as UTC (dates_are_gmt is always sent). */
|
||||
modifiedAfter: string
|
||||
page: number
|
||||
}
|
||||
|
||||
/**
|
||||
* One page of orders modified after the cursor, oldest-modified first so the
|
||||
* caller's cursor advances chronologically. Requires WooCommerce 5.8+
|
||||
* (modified_after); older stores fail with a woocommerce_rest_invalid_param
|
||||
* style error surfaced to the connection's error state.
|
||||
*/
|
||||
export async function listOrdersPage(
|
||||
creds: WooCredentials,
|
||||
options: ListOrdersOptions,
|
||||
): Promise<WooOrder[]> {
|
||||
return wcGet<WooOrder[]>(creds, '/orders', {
|
||||
modified_after: options.modifiedAfter,
|
||||
dates_are_gmt: 'true',
|
||||
status: 'any',
|
||||
orderby: 'modified',
|
||||
order: 'asc',
|
||||
per_page: String(WC_PAGE_SIZE),
|
||||
page: String(options.page),
|
||||
})
|
||||
}
|
||||
|
||||
/** Hard cap on refund pages per order; a real order never approaches this. */
|
||||
const MAX_REFUND_PAGES = 10
|
||||
|
||||
/**
|
||||
* All refunds of one order. Terminates on an EMPTY batch, not a short one
|
||||
* (hosts may cap per_page below our request, same as the order pagination),
|
||||
* dedupes by id so a host that ignores `page` cannot loop forever, and caps
|
||||
* total pages as a final backstop.
|
||||
*/
|
||||
export async function listOrderRefunds(
|
||||
creds: WooCredentials,
|
||||
orderId: number,
|
||||
): Promise<WooRefund[]> {
|
||||
const refunds: WooRefund[] = []
|
||||
const seen = new Set<number>()
|
||||
for (let page = 1; page <= MAX_REFUND_PAGES; page++) {
|
||||
const batch = await wcGet<WooRefund[]>(creds, `/orders/${orderId}/refunds`, {
|
||||
per_page: String(WC_PAGE_SIZE),
|
||||
page: String(page),
|
||||
})
|
||||
if (batch.length === 0) return refunds
|
||||
const fresh = batch.filter((r) => !seen.has(r.id))
|
||||
if (fresh.length === 0) return refunds
|
||||
for (const refund of fresh) seen.add(refund.id)
|
||||
refunds.push(...fresh)
|
||||
}
|
||||
// Cap exhausted with data still flowing: returning the partial list would
|
||||
// let the sync advance its cursor past refunds it never saw. Throwing
|
||||
// routes into the caller's refund-failure path instead (order held, cursor
|
||||
// capped, retried next run).
|
||||
throw new WooCommerceApiError(
|
||||
`Refund pagination cap exceeded for order ${orderId}`,
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify credentials and read store metadata. The one-order probe is the
|
||||
* authoritative credential check (it exercises the read scope the feed
|
||||
* needs); title and settings lookups are best-effort extras.
|
||||
*/
|
||||
export async function testConnectionAndFetchStoreInfo(
|
||||
creds: WooCredentials,
|
||||
): Promise<WooStoreInfo> {
|
||||
await wcGet<unknown[]>(creds, '/orders', { per_page: '1' })
|
||||
|
||||
const info: WooStoreInfo = {
|
||||
name: null,
|
||||
currency: null,
|
||||
prices_include_tax: null,
|
||||
wc_version: null,
|
||||
}
|
||||
|
||||
try {
|
||||
const settings = await wcGet<Array<{ id: string; value: unknown }>>(
|
||||
creds,
|
||||
'/settings/general',
|
||||
)
|
||||
const currency = settings.find((s) => s.id === 'woocommerce_currency')?.value
|
||||
if (typeof currency === 'string' && currency) info.currency = currency.toUpperCase()
|
||||
const pricesIncludeTax = settings.find((s) => s.id === 'woocommerce_prices_include_tax')?.value
|
||||
if (typeof pricesIncludeTax === 'string') info.prices_include_tax = pricesIncludeTax === 'yes'
|
||||
} catch {
|
||||
// Settings need broader permissions on some setups; the feed works without.
|
||||
}
|
||||
|
||||
try {
|
||||
const status = await wcGet<{ environment?: { version?: string } }>(creds, '/system_status')
|
||||
if (status.environment?.version) info.wc_version = status.environment.version
|
||||
} catch {
|
||||
// system_status is admin-capability data and often blocked; optional.
|
||||
}
|
||||
|
||||
try {
|
||||
// The WP REST index is public and carries the site title.
|
||||
const response = await fetch(`${creds.storeUrl}/wp-json/`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
})
|
||||
if (response.ok) {
|
||||
const body = (await response.json()) as { name?: string }
|
||||
if (body.name) info.name = body.name
|
||||
}
|
||||
} catch {
|
||||
// Cosmetic only.
|
||||
}
|
||||
|
||||
return info
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { WooCredentials } from './api-client'
|
||||
import { decryptCredential } from './credentials'
|
||||
import type { WooCommerceConnection } from '../types'
|
||||
|
||||
/**
|
||||
* WooCommerce "Auth Endpoint" handshake helpers.
|
||||
*
|
||||
* The merchant's browser is sent to {store}/wc-auth/v1/authorize; after they
|
||||
* approve, WooCommerce POSTs the generated consumer key/secret server-to-
|
||||
* server to our callback_url and redirects the browser to return_url. Our
|
||||
* oauth_state UUID rides in the handshake's user_id parameter and comes back
|
||||
* in both places, tying callback and return to the pending connection row.
|
||||
*
|
||||
* There is no signature on the callback POST, so possession of the
|
||||
* single-use state is the CSRF defense, and authenticity is proven by
|
||||
* probing the STORED store_url with the received keys before activation: a
|
||||
* forged POST would need working read credentials for the exact store the
|
||||
* user asked to connect.
|
||||
*/
|
||||
|
||||
const APP_NAME = 'Accounted'
|
||||
|
||||
export function buildAuthorizeUrl(storeUrl: string, state: string): string {
|
||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL
|
||||
if (!baseUrl) throw new Error('NEXT_PUBLIC_APP_URL is not configured')
|
||||
const params = new URLSearchParams({
|
||||
app_name: APP_NAME,
|
||||
// Read-only: the feed never writes to the store.
|
||||
scope: 'read',
|
||||
user_id: state,
|
||||
return_url: `${baseUrl}/api/extensions/woocommerce/return`,
|
||||
callback_url: `${baseUrl}/api/extensions/woocommerce/callback`,
|
||||
})
|
||||
return `${storeUrl}/wc-auth/v1/authorize?${params.toString()}`
|
||||
}
|
||||
|
||||
/** Decrypted API credentials for an active connection. */
|
||||
export function credentialsOf(
|
||||
connection: Pick<
|
||||
WooCommerceConnection,
|
||||
'store_url' | 'consumer_key_encrypted' | 'consumer_secret_encrypted'
|
||||
>,
|
||||
): WooCredentials {
|
||||
if (!connection.consumer_key_encrypted || !connection.consumer_secret_encrypted) {
|
||||
throw new Error('Connection has no stored credentials')
|
||||
}
|
||||
return {
|
||||
storeUrl: connection.store_url,
|
||||
consumerKey: decryptCredential(connection.consumer_key_encrypted),
|
||||
consumerSecret: decryptCredential(connection.consumer_secret_encrypted),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import crypto from 'crypto'
|
||||
|
||||
/**
|
||||
* At-rest encryption for WooCommerce consumer key/secret.
|
||||
*
|
||||
* AES-256-GCM with a dedicated env key, mirroring the Skatteverket token
|
||||
* store (extensions/general/skatteverket/lib/token-store.ts): 12-byte IV,
|
||||
* 16-byte auth tag, layout iv|tag|ciphertext, base64url encoded. The key is
|
||||
* deployment-wide (not per-tenant); what makes rows useless off-server is
|
||||
* that WOOCOMMERCE_CREDENTIALS_ENCRYPTION_KEY never leaves the environment.
|
||||
*/
|
||||
|
||||
const ALGORITHM = 'aes-256-gcm'
|
||||
|
||||
/** Whether the integration is configured on this deployment. */
|
||||
export function isWooCommerceConfigured(): boolean {
|
||||
return Boolean(process.env.WOOCOMMERCE_CREDENTIALS_ENCRYPTION_KEY)
|
||||
}
|
||||
|
||||
function getEncryptionKey(): Buffer {
|
||||
const key = process.env.WOOCOMMERCE_CREDENTIALS_ENCRYPTION_KEY
|
||||
if (!key) throw new Error('WOOCOMMERCE_CREDENTIALS_ENCRYPTION_KEY is required')
|
||||
return crypto.createHash('sha256').update(key).digest()
|
||||
}
|
||||
|
||||
export function encryptCredential(plaintext: string): string {
|
||||
const key = getEncryptionKey()
|
||||
const iv = crypto.randomBytes(12)
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, key, iv)
|
||||
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
|
||||
const tag = cipher.getAuthTag()
|
||||
return Buffer.concat([iv, tag, encrypted]).toString('base64url')
|
||||
}
|
||||
|
||||
export function decryptCredential(ciphertext: string): string {
|
||||
const key = getEncryptionKey()
|
||||
const combined = Buffer.from(ciphertext, 'base64url')
|
||||
const iv = combined.subarray(0, 12)
|
||||
const tag = combined.subarray(12, 28)
|
||||
const encrypted = combined.subarray(28)
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv)
|
||||
decipher.setAuthTag(tag)
|
||||
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8')
|
||||
}
|
||||
@@ -0,0 +1,593 @@
|
||||
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 { createLogger, type Logger } from '@/lib/logger'
|
||||
import type { RawTransaction } from '@/types'
|
||||
import {
|
||||
listOrdersPage,
|
||||
listOrderRefunds,
|
||||
isRevokedCredentialsError,
|
||||
WC_PAGE_SIZE,
|
||||
type WooCredentials,
|
||||
} from './api-client'
|
||||
import { credentialsOf } from './connect'
|
||||
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.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* Pagination is CURSOR-based, not offset-based: each request asks for the
|
||||
* oldest orders with date_modified strictly after the current cursor
|
||||
* (orderby=modified asc, page=1), and the cursor advances to the last row of
|
||||
* each processed page. Offset pages over a fixed window would silently skip
|
||||
* rows whenever an already-fetched order is modified mid-run (it re-sorts to
|
||||
* the end and shifts every later row one index down); with a moving cursor a
|
||||
* mid-run modification simply re-surfaces the order later in the same run.
|
||||
* The one case that still needs offsets is a run of >WC_PAGE_SIZE orders
|
||||
* sharing the same date_modified second (bulk edits, migrations): those are
|
||||
* paged through with an increasing page number at a FIXED cursor, because
|
||||
* modified_after is strictly exclusive and advancing it would skip the rest
|
||||
* of the tie. Ties that span a page boundary after cursor advancement are
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/** 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. */
|
||||
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. */
|
||||
const CURSOR_OVERLAP_MS = 24 * 60 * 60 * 1000
|
||||
/**
|
||||
* Safety cap on orders per run (matches the Stripe feed's MAX_TXNS_PER_RUN).
|
||||
* The real bound is the caller's deadline; hitting this cap is logged loudly
|
||||
* because a silent cap reads as "covered everything" when it did not. The
|
||||
* cursor resumes where a truncated run stopped.
|
||||
*/
|
||||
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.
|
||||
*
|
||||
* 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
|
||||
* row deduped.
|
||||
*/
|
||||
export function wooStoreScope(storeUrl: string): string {
|
||||
return storeUrl.replace(/^https:\/\//, '')
|
||||
}
|
||||
|
||||
export function wooOrderExternalId(storeScope: string, orderId: number): string {
|
||||
return `woo_${storeScope}_order_${orderId}`
|
||||
}
|
||||
|
||||
export function wooRefundExternalId(storeScope: string, refundId: number): string {
|
||||
return `woo_${storeScope}_refund_${refundId}`
|
||||
}
|
||||
|
||||
export interface WooCommerceSyncSummary {
|
||||
/** Orders listed from the store (all statuses in the window). */
|
||||
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
|
||||
errors: number
|
||||
/** Set when the caller's time budget ran out before all pages processed. */
|
||||
deadlineReached?: boolean
|
||||
/** Set when the store reported the credentials revoked (401/403). */
|
||||
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).
|
||||
*/
|
||||
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. */
|
||||
export function orderAmountUnparseable(order: Pick<WooOrder, 'total'>): boolean {
|
||||
return parseAmount(order.total) === null
|
||||
}
|
||||
|
||||
/** Date part of a wc/v3 _gmt timestamp ("2026-08-01T12:34:56", no zone suffix). */
|
||||
function isoDateOfGmt(timestamp: string): string {
|
||||
return timestamp.split('T')[0]
|
||||
}
|
||||
|
||||
/** wc/v3 _gmt timestamps lack a zone suffix; brand them UTC for timestamptz. */
|
||||
function gmtToIso(timestamp: string): string {
|
||||
return timestamp.endsWith('Z') ? timestamp : `${timestamp}Z`
|
||||
}
|
||||
|
||||
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'
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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()
|
||||
}
|
||||
return new Date(Date.now() - BACKFILL_DAYS * 86_400_000).toISOString()
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface PageRowsOutcome {
|
||||
rows: RawTransaction[]
|
||||
/**
|
||||
* date_modified (ms) of every order whose refund rows are incomplete this
|
||||
* run (fetch failed or skipped on deadline). The cursor must not advance
|
||||
* past these: the next run has to re-list them.
|
||||
*/
|
||||
incompleteModifiedMs: number[]
|
||||
hitDeadline: boolean
|
||||
}
|
||||
|
||||
/** Rows for one page of orders: gross rows plus refund rows where present. */
|
||||
async function buildPageRows(
|
||||
creds: WooCredentials,
|
||||
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)) {
|
||||
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
|
||||
|
||||
// Refund fetches are one request per refunded order against a slow host;
|
||||
// without this check a single mass-refund page could blow through the
|
||||
// function's maxDuration and the cursor would never persist.
|
||||
if (outcome.hitDeadline || (deadlineMs !== undefined && Date.now() >= deadlineMs)) {
|
||||
outcome.hitDeadline = true
|
||||
outcome.incompleteModifiedMs.push(gmtToMs(order.date_modified_gmt))
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const refunds = await listOrderRefunds(creds, order.id)
|
||||
summary.refundsFetched += refunds.length
|
||||
for (const refund of refunds) {
|
||||
if (parseAmount(refund.amount) === null) {
|
||||
summary.errors += 1
|
||||
log.warn('unparseable refund amount; row skipped', {
|
||||
orderId: order.id,
|
||||
refundId: refund.id,
|
||||
amount: refund.amount,
|
||||
})
|
||||
}
|
||||
push(mapRefund(storeScope, order, refund))
|
||||
}
|
||||
} catch (refundError) {
|
||||
// The order row still imports; the cursor is capped below this order's
|
||||
// date_modified so the next run re-lists it and retries the refunds.
|
||||
summary.errors += 1
|
||||
outcome.incompleteModifiedMs.push(gmtToMs(order.date_modified_gmt))
|
||||
log.warn('refund fetch failed; order held for retry next run', {
|
||||
orderId: order.id,
|
||||
message: refundError instanceof Error ? refundError.message : String(refundError),
|
||||
})
|
||||
}
|
||||
}
|
||||
return outcome
|
||||
}
|
||||
|
||||
export async function syncWooCommerceOrders(
|
||||
supabase: SupabaseClient,
|
||||
connection: WooCommerceConnection,
|
||||
log: Logger = defaultLog,
|
||||
/**
|
||||
* Absolute deadline (epoch ms) from the caller's time budget. Enforced
|
||||
* between pages AND between refund fetches inside a page: the cursor
|
||||
* advances only over fully-processed work, so the next run resumes exactly
|
||||
* where this one stopped.
|
||||
*/
|
||||
deadlineMs?: number,
|
||||
): Promise<WooCommerceSyncSummary> {
|
||||
const summary: WooCommerceSyncSummary = {
|
||||
fetched: 0,
|
||||
refundsFetched: 0,
|
||||
imported: 0,
|
||||
duplicates: 0,
|
||||
skippedLocked: 0,
|
||||
errors: 0,
|
||||
}
|
||||
if (
|
||||
connection.status !== 'active' ||
|
||||
!connection.consumer_key_encrypted ||
|
||||
!connection.consumer_secret_encrypted
|
||||
) {
|
||||
return summary
|
||||
}
|
||||
|
||||
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.
|
||||
let tiePage = 1
|
||||
let prevCursorMs = connection.last_order_synced_at
|
||||
? Date.parse(connection.last_order_synced_at)
|
||||
: 0
|
||||
// Earliest incomplete work this run; the persisted cursor never passes it.
|
||||
let failureFloorMs = Number.POSITIVE_INFINITY
|
||||
let accountEnsured = false
|
||||
|
||||
try {
|
||||
for (;;) {
|
||||
if (deadlineMs !== undefined && Date.now() >= deadlineMs) {
|
||||
summary.deadlineReached = true
|
||||
log.info('time budget exhausted; stopping order sync', {
|
||||
connectionId: connection.id,
|
||||
processed: summary.imported + summary.duplicates,
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
const orders = await listOrdersPage(creds, { modifiedAfter, page: tiePage })
|
||||
// Termination is an EMPTY page, not a short one: hosts and security
|
||||
// plugins may cap per_page below our request, and treating a short page
|
||||
// as the end would strand the cursor at the first page forever.
|
||||
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,
|
||||
storeScope,
|
||||
orders,
|
||||
lockThrough,
|
||||
summary,
|
||||
log,
|
||||
deadlineMs,
|
||||
)
|
||||
if (page.hitDeadline) summary.deadlineReached = true
|
||||
|
||||
const firstMs = gmtToMs(orders[0].date_modified_gmt)
|
||||
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(
|
||||
supabase,
|
||||
connection.company_id,
|
||||
connection.user_id,
|
||||
page.rows,
|
||||
{ settlementAccount: WOOCOMMERCE_LEDGER_ACCOUNT, skipAutoCategorization: true },
|
||||
)
|
||||
summary.imported += result.imported
|
||||
summary.duplicates += result.duplicates
|
||||
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.
|
||||
failureFloorMs = Math.min(failureFloorMs, firstMs - 1000)
|
||||
}
|
||||
}
|
||||
for (const ms of page.incompleteModifiedMs) {
|
||||
failureFloorMs = Math.min(failureFloorMs, ms - 1000)
|
||||
}
|
||||
|
||||
// Persist the cursor after each page: monotonic (never regresses below
|
||||
// the pre-run cursor) and capped by the failure floor. error_message is
|
||||
// cleared on progress so a resolved incident stops showing in the panel.
|
||||
const candidateMs = Math.min(lastMs, failureFloorMs)
|
||||
if (candidateMs > prevCursorMs) {
|
||||
const cursorIso = new Date(candidateMs).toISOString()
|
||||
await supabase
|
||||
.from('woocommerce_connections')
|
||||
.update({ last_order_synced_at: cursorIso, error_message: null })
|
||||
.eq('id', connection.id)
|
||||
connection.last_order_synced_at = cursorIso
|
||||
prevCursorMs = candidateMs
|
||||
}
|
||||
|
||||
if (summary.deadlineReached) break
|
||||
|
||||
// Advance. A full page entirely inside one date_modified second cannot
|
||||
// move the cursor (modified_after is strictly exclusive): page through
|
||||
// the tie by offset. Otherwise move the cursor to the page's last row;
|
||||
// tie rows cut off at the boundary are recovered by the next run's
|
||||
// overlap re-poll.
|
||||
if (orders.length >= WC_PAGE_SIZE && lastMs === firstMs) {
|
||||
tiePage += 1
|
||||
} else {
|
||||
modifiedAfter = new Date(lastMs).toISOString()
|
||||
tiePage = 1
|
||||
}
|
||||
|
||||
if (summary.fetched >= MAX_ORDERS_PER_RUN) {
|
||||
log.warn('order cap reached; remaining orders resume next run', {
|
||||
connectionId: connection.id,
|
||||
cap: MAX_ORDERS_PER_RUN,
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (isRevokedCredentialsError(err)) {
|
||||
// The key was deleted or demoted in wp-admin: flip the connection so
|
||||
// the UI offers a reconnect instead of the cron retrying forever.
|
||||
summary.revoked = true
|
||||
await supabase
|
||||
.from('woocommerce_connections')
|
||||
.update({
|
||||
status: 'revoked',
|
||||
error_message: 'Butiken avvisade API-nyckeln. Anslut butiken igen.',
|
||||
// The store already rejected these; keeping decryptable dead
|
||||
// credentials would be pure data retention (same as /disconnect).
|
||||
consumer_key_encrypted: null,
|
||||
consumer_secret_encrypted: null,
|
||||
disconnected_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', connection.id)
|
||||
.eq('status', 'active')
|
||||
log.warn('credentials revoked upstream; connection flipped to revoked', {
|
||||
connectionId: connection.id,
|
||||
})
|
||||
return summary
|
||||
}
|
||||
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,
|
||||
})
|
||||
return summary
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* The WooCommerce settings panel's server calls, each classified into exactly
|
||||
* one outcome. Same doctrine as the Stripe panel's settings-actions (see the
|
||||
* doc block there): never throw, one toast sentence per click, and the
|
||||
* classification lives outside the component because component logic has no
|
||||
* tests in this repo.
|
||||
*/
|
||||
|
||||
import { fetchWithTimeout, isTimeoutError } from '@/lib/http/fetch-with-timeout'
|
||||
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
|
||||
import type { ActionFailure } from '@/lib/browser/action-failure'
|
||||
|
||||
/**
|
||||
* Deadline for the quick calls (status, toggle, disconnect). Connect and
|
||||
* manual-connect probe the merchant's WooCommerce host (often a slow shared
|
||||
* PHP box, with retries), so they get a longer one.
|
||||
*/
|
||||
export const WOO_ACTION_TIMEOUT_MS = 15_000
|
||||
export const WOO_CONNECT_TIMEOUT_MS = 120_000
|
||||
|
||||
/**
|
||||
* Deadline for "Synka nu": the route's own ceiling (maxDuration 300 on the
|
||||
* extension dispatcher) plus margin, same reasoning as the Stripe panel. A
|
||||
* first sync backfills 90 days from a slow host and legitimately takes
|
||||
* minutes; the server keeps working and advances the cursor even if we
|
||||
* aborted, so aborting early would misreport a sync that landed.
|
||||
*/
|
||||
export const WOO_SYNC_TIMEOUT_MS = 310_000
|
||||
|
||||
export type WooRequestResult<T> =
|
||||
/** 2xx. `data` is null when the body was not readable JSON. */
|
||||
| { ok: true; data: T | null }
|
||||
| ActionFailure
|
||||
|
||||
export interface WooRequestOptions {
|
||||
url: string
|
||||
method?: 'GET' | 'POST' | 'DELETE'
|
||||
body?: unknown
|
||||
locale?: ErrorLocale
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
|
||||
/** The one sentence for a non-2xx; route copy wins over the generic map. */
|
||||
export function serverErrorMessage(
|
||||
body: unknown,
|
||||
status: number,
|
||||
locale: ErrorLocale,
|
||||
): string {
|
||||
if (isRecord(body)) {
|
||||
if (locale === 'en' && typeof body.error_en === 'string' && body.error_en.trim()) {
|
||||
return body.error_en.trim()
|
||||
}
|
||||
if (typeof body.error === 'string' && body.error.trim()) {
|
||||
return body.error.trim()
|
||||
}
|
||||
}
|
||||
return getErrorMessage(body, { statusCode: status, locale })
|
||||
}
|
||||
|
||||
/** Call one of the panel's endpoints and report exactly why it failed. */
|
||||
export async function wooRequest<T>({
|
||||
url,
|
||||
method = 'POST',
|
||||
body,
|
||||
locale = 'sv',
|
||||
timeoutMs = WOO_ACTION_TIMEOUT_MS,
|
||||
}: WooRequestOptions): Promise<WooRequestResult<T>> {
|
||||
try {
|
||||
const res = await fetchWithTimeout(
|
||||
url,
|
||||
body === undefined
|
||||
? { method }
|
||||
: {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
{ timeoutMs, description: `${method} ${url}` },
|
||||
)
|
||||
|
||||
const payload = await res.json().catch(() => null)
|
||||
|
||||
if (!res.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'server',
|
||||
status: res.status,
|
||||
message: serverErrorMessage(payload, res.status, locale),
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, data: payload as T | null }
|
||||
} catch (err) {
|
||||
if (isTimeoutError(err)) return { ok: false, reason: 'timeout' }
|
||||
return { ok: false, reason: 'network', message: getErrorMessage(err, { locale }) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Success body of POST /api/extensions/ext/woocommerce/sync. */
|
||||
export interface WooSyncPayload {
|
||||
success?: boolean
|
||||
/** `WooCommerceSyncSummary` from lib/order-sync.ts, over the wire. */
|
||||
transactions?: {
|
||||
fetched?: number
|
||||
refundsFetched?: number
|
||||
imported?: number
|
||||
duplicates?: number
|
||||
errors?: number
|
||||
revoked?: boolean
|
||||
deadlineReached?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
type SyncCounts = {
|
||||
fetched: number
|
||||
imported: number
|
||||
}
|
||||
|
||||
export type WooSyncSummary =
|
||||
/** The store rejected the credentials; the connection was flipped to revoked. */
|
||||
| { reason: 'revoked' }
|
||||
/** The window genuinely held nothing. A real answer, not a silent success. */
|
||||
| { reason: 'empty' }
|
||||
/**
|
||||
* The time budget ran out with orders still unfetched. Reported before the
|
||||
* count-based outcomes so a truncated run never reads as a complete one;
|
||||
* the cursor persisted, so pressing sync again continues where it stopped.
|
||||
* Carries the error count too: a truncated run can also have failed rows,
|
||||
* and dropping that number would repeat the silent-partial mistake.
|
||||
*/
|
||||
| { reason: 'partial'; values: SyncCounts & { errors: number } }
|
||||
/** Rows landed, and some rows did not. Both halves get said. */
|
||||
| { reason: 'errors'; values: SyncCounts & { errors: number } }
|
||||
/** Rows landed. */
|
||||
| { reason: 'feed'; values: SyncCounts }
|
||||
/** 2xx whose body could not be read: the sync ran, the counts are unknown. */
|
||||
| { reason: 'unknown' }
|
||||
|
||||
/** Turn the sync route's success body into the single sentence the user gets. */
|
||||
export function syncSummary(payload: WooSyncPayload | null): WooSyncSummary {
|
||||
const summary = payload?.transactions
|
||||
if (!summary) return { reason: 'unknown' }
|
||||
if (summary.revoked === true) return { reason: 'revoked' }
|
||||
if (typeof summary.fetched !== 'number') return { reason: 'unknown' }
|
||||
|
||||
const fetched = summary.fetched
|
||||
const imported = typeof summary.imported === 'number' ? summary.imported : 0
|
||||
const errors = typeof summary.errors === 'number' ? summary.errors : 0
|
||||
|
||||
if (summary.deadlineReached === true) {
|
||||
return { reason: 'partial', values: { fetched, imported, errors } }
|
||||
}
|
||||
if (fetched === 0) return { reason: 'empty' }
|
||||
if (errors > 0) return { reason: 'errors', values: { fetched, imported, errors } }
|
||||
return { reason: 'feed', values: { fetched, imported } }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"id": "woocommerce",
|
||||
"sector": "general",
|
||||
"exportName": "woocommerceExtension",
|
||||
"entryPoint": "@/extensions/general/woocommerce",
|
||||
"workspace": null,
|
||||
"requiredEnvVars": ["WOOCOMMERCE_CREDENTIALS_ENCRYPTION_KEY"],
|
||||
"optionalEnvVars": [],
|
||||
"npmDependencies": [],
|
||||
"definition": {
|
||||
"name": "WooCommerce",
|
||||
"category": "import",
|
||||
"icon": "ShoppingCart",
|
||||
"dataPattern": "manual",
|
||||
"hasOwnData": true,
|
||||
"description": "Hämta betalda ordrar och återbetalningar från din WooCommerce-butik till transaktionsinkorgen",
|
||||
"longDescription": "Anslut din WooCommerce-butik så hämtas betalda ordrar och återbetalningar automatiskt varje natt till transaktionsinkorgen, som ett bankflöde för butiken. Inget bokförs automatiskt: du bokför raderna själv precis som vanliga banktransaktioner."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/** Row shape of public.woocommerce_connections. */
|
||||
export interface WooCommerceConnection {
|
||||
id: string
|
||||
company_id: string
|
||||
user_id: string
|
||||
/** Normalized https origin (+ optional subdirectory path), no trailing slash. */
|
||||
store_url: string
|
||||
store_name: string | null
|
||||
consumer_key_encrypted: string | null
|
||||
consumer_secret_encrypted: string | null
|
||||
key_permissions: string | null
|
||||
status: 'pending' | 'active' | 'revoked' | 'error'
|
||||
oauth_state: string | null
|
||||
currency: string | null
|
||||
prices_include_tax: boolean | null
|
||||
wc_version: string | null
|
||||
/** Opt-in: nightly order-feed cron (the manual sync button ignores it). */
|
||||
transaction_sync_enabled: boolean
|
||||
/** Order-polling cursor (max date_modified_gmt processed). */
|
||||
last_order_synced_at: string | null
|
||||
error_message: string | null
|
||||
connected_at: string | null
|
||||
disconnected_at: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/** 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
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal wc/v3 order shape consumed by the feed. All money fields are strings
|
||||
* in the order's `currency`; every date has a `_gmt` twin and the feed only
|
||||
* ever reads the `_gmt` variants (store-local dates shift with DST).
|
||||
*/
|
||||
export interface WooOrder {
|
||||
id: number
|
||||
/** Display order number; usually the id, but plugins can renumber. */
|
||||
number: string
|
||||
status: string
|
||||
currency: string
|
||||
/** Grand total actually charged (gross, incl. tax and shipping). */
|
||||
total: string
|
||||
total_tax: string
|
||||
prices_include_tax: boolean
|
||||
date_created_gmt: string
|
||||
date_modified_gmt: string
|
||||
/** Set when payment completed; the feed's inclusion criterion and row date. */
|
||||
date_paid_gmt: string | null
|
||||
payment_method: string
|
||||
payment_method_title: string
|
||||
/** Gateway charge reference; join key for later gateway-side reconciliation. */
|
||||
transaction_id: string
|
||||
/** Summary of refunds against this order; totals are negative strings. */
|
||||
refunds: Array<{ id: number; reason: string; total: string }>
|
||||
}
|
||||
|
||||
/** Minimal 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
|
||||
}
|
||||
|
||||
/** Store metadata read at connect time. */
|
||||
export interface WooStoreInfo {
|
||||
/** WordPress site title from GET {store}/wp-json/ (public, unauthenticated). */
|
||||
name: string | null
|
||||
/** ISO 4217 store currency, when readable. */
|
||||
currency: string | null
|
||||
prices_include_tax: boolean | null
|
||||
wc_version: string | null
|
||||
}
|
||||
Reference in New Issue
Block a user