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:
Mattsson
2026-08-06 23:30:00 +02:00
committed by GitHub
parent cd344b6dbb
commit 707d597b2e
41 changed files with 4335 additions and 9 deletions
+5
View File
@@ -804,6 +804,11 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-06] The ROT/RUT payout button is hidden from the invoices header unless the company has an invoice with deduction_total > 0 or rot_rut_enabled is on in tax settings. ROT/RUT concerns only companies selling eligible work to consumers, and a payout can never precede the invoice that created the claim, so the derived signal cannot hide the action from someone who needs it. Read from the company_settings row the page already fetches for ore_rounding (no extra round trip); deliberately not scoped to the fiscal-year filter, since a begäran is claimed the year after payment. ?rot-rut=1 still opens the dialog, so the feature is hidden, not removed.
[2026-08-06] Supplier credit notes under kontantmetoden now reverse when the ORIGINAL was already booked (paid), not only under faktureringsmetoden: skipping left the expense and the 2641 ingaende moms deduction overstated with no accounting trace. Mirrors the customer-side creditNoteNeedsJournalEntry(). The v1 route's GDPR-minimised projection had to re-add registration_journal_entry_id/payment_journal_entry_id/paid_at/paid_amount: status alone misses a part-paid-but-booked original.
[2026-08-06] Kontantmetoden year-end cut-off (BFL 5 kap 2 §) books moms to the VILANDE accounts (2618/2628/2638 ut, 2648 in), never 2611/2641: vilande accounts are deliberately absent from ACCOUNT_RUTA/ACCOUNT_TO_BOX, so the moms stays out of the momsdeklaration until payment, which is what bokslutsmetoden requires. 2647 was considered and rejected: it is domestic omvand betalningsskyldighet, unrelated. Cut-off posts as two AGGREGATE verifikat reversed on day 1 of the next period, and deliberately does NOT set invoices.journal_entry_id: the payment flows route on that link, so per-invoice linking would send every new-year payment down the accrual clearing path against a receivable the vandning already removed, booking the settlement twice.
[2026-08-06] WooCommerce integration (extensions/general/woocommerce) is feed-only, orders + refunds: one positive inbox row per paid order (date_paid, gross total), one negative row per refund, nothing auto-booked, matching the Stripe feed doctrine of 2026-07-24. Gateway fees and payouts are deliberately out of scope: core wc/v3 exposes neither (they belong to the gateway; WooPayments has its own API and is a possible follow-up), so promising fee-level accuracy from the Woo API alone would be a lie. order.transaction_id is stored as the row reference for later gateway-side reconciliation.
[2026-08-06] WooCommerce feed clearing account is 1680 Andra kortfristiga fordringar, not 1686: 1686 (Fordringar for kontokort och kuponger) is the economically right BAS account for gateway receivables but the Stripe feed owns it and cash_accounts enforces UNIQUE(company_id, ledger_account); 1680 is the closest correct free account in the 168x group. Consequence accepted: a store using the Stripe gateway plus a connected Stripe feed shows the same money in both feeds on different accounts, and the dedup account guard correctly does not merge them; panel copy positions Woo as the order feed and Stripe as the money feed.
[2026-08-06] WooCommerce v1 has no webhooks: delivery runs on WP-Cron (fires only on site traffic), auto-disables after 5 consecutive failures, and has no refund topic, so it cannot be the backbone of an accounting feed. Nightly modified_after polling with a 24h cursor overlap + manual sync instead; webhooks can be added later as a poke-then-refetch accelerator.
[2026-08-06] wc-auth callback authenticity: WooCommerce signs nothing on the handshake POST, so possession of the single-use oauth_state is the CSRF defense and activation additionally requires the received keys to pass a live probe against the STORED store_url. Per-tenant consumer key/secret are AES-256-GCM encrypted (WOOCOMMERCE_CREDENTIALS_ENCRYPTION_KEY, skatteverket token-store pattern). external_id scope is the store host, not the connection id, so disconnect/reconnect of the same store never re-imports history.
[2026-08-06] WooCommerce sync redesigned after skeptic review refuted the first version: pagination is cursor-based (modified_after advances per page; offset pages only within a same-second date_modified tie, since modified_after is strictly exclusive), termination is an empty page (hosts may cap per_page), the persisted cursor never advances past failed refund fetches, ingest errors, or deadline-skipped work (failure floor = earliest affected order's date_modified - 1s), the deadline is checked between refund fetches inside a page, and rows dated on/before bookkeeping_locked_through are dropped at map time on EVERY run because modified_after selects on the wrong date dimension (a refund bumps date_modified long after date_paid). Cash-account currency falls back to the first order's real currency and a creation failure writes error_message on the connection so the panel cannot show a healthy store that never syncs. normalizeStoreUrl refuses localhost/private-IP/.local/.internal hosts (hostname-level SSRF guard).
[2026-08-06] Recurring invoice cadence (user request) is a generic interval_months SMALLINT 1-12 (default 1) rather than a cadence enum: the UI offers only the four presets (1/3/6/12), while API/MCP accept any 1-12 (e.g. every 2 months), and existing rows stay monthly via the default. Missed-run roll-forward and edits/reactivation of interval>1 schedules anchor on the schedule's own next_run_date month grid (rollNextRunDateForward), so a quarterly Jan/Apr/Jul/Oct schedule missed in an outage rolls Jan 15 -> Apr 15, never Feb 15; monthly (interval 1) keeps its pinned today-anchored recompute semantics unchanged. Changing interval alone never touches next_run_date: the new cadence applies from the next run, so an edit can never pull a send earlier (a 3->1 change therefore waits out the current gap; visible via Nasta korning).
[2026-08-06] Prompt clarifications render from a structured summary (lib/agent-context/chat-clarifications.ts), never off the raw channel_context blob. A WhatsApp "nej" stores representation with participants:[] and purpose:null and denied:true, so branching on `!purpose` reads a settled denial as a half answer: the shipped renderer emitted "syfte SAKNAS: fråga bara efter syftet" about a meal the user had just said was not representation. `denied` and the genuine half answer (participants named, purpose missing, which BFL 5 kap 6-7 § does want completed) are now separate states. Also: the photo caption no longer reaches the prompt, for the reason already written down in channel-context-notes.ts (nobody was asked for it, nobody reviewed it), and free text passes through flattenMemoryContent because promptTemplate output is seeded as a user message and wrapToolResult only wraps tool results.
[2026-08-06] Unmatched underlag are PROPOSED to the assistant, never auto-linked. WhatsApp intake writes neither invoice_inbox_items.matched_transaction_id nor transactions.document_id, so a chat-captured receipt is invisible to every lookup and #1425's backfill-by-document_id has nothing to backfill. Scoring unmatched items at read time (lib/agent-context/underlag-candidates.ts, reusing core-receipt-matcher) closes that with no migration and no link written by a machine, preserving the human confirm step; setting matched_transaction_id at intake above a confidence bar remains the open alternative and is a founder call. An uncomparable cross-currency amount disqualifies a candidate outright, because the matcher drops the amount signal there and date + merchant alone score 1.0.
+34 -3
View File
@@ -20,7 +20,7 @@ import {
} from '@/components/ui/dialog'
import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { ArrowLeft, CreditCard, Landmark, Loader2, ChevronRight, Download, AlertTriangle } from 'lucide-react'
import { ArrowLeft, CreditCard, Landmark, Loader2, ChevronRight, Download, AlertTriangle, ShoppingCart } from 'lucide-react'
import { cn, formatDate } from '@/lib/utils'
import { createClient } from '@/lib/supabase/client'
import { useCompany, useCapability } from '@/contexts/CompanyContext'
@@ -1955,11 +1955,15 @@ const BankingPanel = getSettingsPanel('enable-banking')
// PSD2 bank connection above.
const StripePanel = getSettingsPanel('stripe')
// And for the WooCommerce order feed: the store's paid orders and refunds are
// an import source in the same category as the Stripe feed above.
const WooCommercePanel = getSettingsPanel('woocommerce')
// ============================================================
// Import Page with Selection Cards
// ============================================================
type ImportMode = null | 'psd2' | 'stripe' | 'bank' | 'sie' | 'csv_data' | 'migration'
type ImportMode = null | 'psd2' | 'stripe' | 'woocommerce' | 'bank' | 'sie' | 'csv_data' | 'migration'
export default function ImportPage() {
const { isSandbox } = useCompany()
@@ -1991,7 +1995,7 @@ export default function ImportPage() {
// Manual file-import modes (bank file, CSV/Excel, SIE) stay reachable.
const allowedModes = isSandbox
? ['bank', 'sie', 'csv_data']
: ['psd2', 'stripe', 'bank', 'sie', 'csv_data', 'migration']
: ['psd2', 'stripe', 'woocommerce', 'bank', 'sie', 'csv_data', 'migration']
if (!isSandbox && searchParams.get('migration')) {
setMode('migration')
} else {
@@ -2037,6 +2041,9 @@ export default function ImportPage() {
const hasStripeExtension = ENABLED_EXTENSION_IDS.has('stripe')
// Stripe is enabled everywhere (hosted + self-hosted); only the sandbox blocks it.
const stripeDisabled = isSandbox
const hasWooCommerceExtension = ENABLED_EXTENSION_IDS.has('woocommerce')
// Same doctrine as Stripe: external credentials never leave the sandbox.
const woocommerceDisabled = isSandbox
return (
<div className="space-y-8">
@@ -2108,6 +2115,15 @@ export default function ImportPage() {
onClick={() => setMode('stripe')}
/>
)}
{hasWooCommerceExtension && (
<ImportRow
title={t('woocommerce_title')}
sub={t('woocommerce_description')}
chips={<LogoChip src="/logos/woocommerce.svg" name="WooCommerce" />}
disabled={woocommerceDisabled}
onClick={() => setMode('woocommerce')}
/>
)}
{hasMigrationExtension && (
<ImportRow
title={t('migration_title')}
@@ -2259,6 +2275,21 @@ export default function ImportPage() {
</Card>
)
)}
{mode === 'woocommerce' && (
hasWooCommerceExtension && WooCommercePanel ? (
<WooCommercePanel />
) : (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<ShoppingCart className="mb-4 h-10 w-10 text-muted-foreground/40" />
<p className="mb-1 font-medium">{t('woocommerce_not_enabled_title')}</p>
<p className="max-w-md text-sm text-muted-foreground">
{t('woocommerce_not_enabled_description')}
</p>
</CardContent>
</Card>
)
)}
{mode === 'bank' && <BankFileImportWizard />}
{mode === 'sie' && <SIEImportWizard />}
{mode === 'csv_data' && <CSVDataImportWizard />}
@@ -0,0 +1,164 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
vi.mock('@/lib/supabase/server', () => ({
createServiceClient: vi.fn(),
}))
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
vi.mock('@/lib/events/bus', () => ({ eventBus: { emit: vi.fn() } }))
vi.mock('@/lib/extensions/loader', () => ({ loadExtensions: vi.fn() }))
vi.mock('@/lib/extensions/registry', () => ({ extensionRegistry: { get: vi.fn() } }))
vi.mock('@/extensions/general/woocommerce/lib/api-client', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@/extensions/general/woocommerce/lib/api-client')>()
return { ...actual, testConnectionAndFetchStoreInfo: vi.fn() }
})
import { POST } from '../callback/route'
import { createServiceClient } from '@/lib/supabase/server'
import { eventBus } from '@/lib/events/bus'
import { extensionRegistry } from '@/lib/extensions/registry'
import { testConnectionAndFetchStoreInfo } from '@/extensions/general/woocommerce/lib/api-client'
import { decryptCredential } from '@/extensions/general/woocommerce/lib/credentials'
import { createQueuedMockSupabase } from '@/tests/helpers'
const STATE = '123e4567-e89b-12d3-a456-426614174000'
function makeCallbackRequest(body: unknown): Request {
return new Request('https://test.local/api/extensions/woocommerce/callback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: typeof body === 'string' ? body : JSON.stringify(body),
})
}
const VALID_BODY = {
key_id: 1,
user_id: STATE,
consumer_key: 'ck_new',
consumer_secret: 'cs_new',
key_permissions: 'read',
}
describe('POST /api/extensions/woocommerce/callback', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.stubEnv('WOOCOMMERCE_CREDENTIALS_ENCRYPTION_KEY', 'test-key')
vi.mocked(extensionRegistry.get).mockReturnValue(
{ id: 'woocommerce' } as ReturnType<typeof extensionRegistry.get>,
)
})
afterEach(() => {
vi.unstubAllEnvs()
})
it('refuses with 503 when the extension is disabled', async () => {
vi.mocked(extensionRegistry.get).mockReturnValue(undefined)
const res = await POST(makeCallbackRequest(VALID_BODY))
expect(res.status).toBe(503)
const body = await res.json()
expect(body.code).toBe('EXTENSION_DISABLED')
})
it('rejects a non-JSON body with 400', async () => {
const res = await POST(makeCallbackRequest('not json'))
expect(res.status).toBe(400)
})
it('rejects a missing or non-UUID state with 400', async () => {
const res = await POST(
makeCallbackRequest({ ...VALID_BODY, user_id: 'not-a-uuid' }),
)
expect(res.status).toBe(400)
const res2 = await POST(makeCallbackRequest({ user_id: STATE }))
expect(res2.status).toBe(400)
})
it('returns 404 for an unknown or already-consumed state', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
vi.mocked(createServiceClient).mockResolvedValue(
supabase as unknown as Awaited<ReturnType<typeof createServiceClient>>,
)
enqueue({ data: null, error: { message: 'no rows', code: 'PGRST116' } })
const res = await POST(makeCallbackRequest(VALID_BODY))
expect(res.status).toBe(404)
})
it('marks the row error and returns 502 when the credential probe fails', async () => {
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
vi.mocked(createServiceClient).mockResolvedValue(
supabase as unknown as Awaited<ReturnType<typeof createServiceClient>>,
)
enqueue({
data: {
id: 'conn-1',
company_id: 'company-1',
user_id: 'user-1',
store_url: 'https://shop.example.se',
},
})
enqueue({ data: null }) // markError update
vi.mocked(testConnectionAndFetchStoreInfo).mockRejectedValue(new Error('403'))
const res = await POST(makeCallbackRequest(VALID_BODY))
expect(res.status).toBe(502)
const errorUpdate = findCall('woocommerce_connections', 'update')?.[0] as Record<
string,
unknown
>
expect(errorUpdate.status).toBe('error')
// The probe ran against the STORED store_url, not anything the caller sent.
expect(vi.mocked(testConnectionAndFetchStoreInfo).mock.calls[0][0]).toMatchObject({
storeUrl: 'https://shop.example.se',
consumerKey: 'ck_new',
})
})
it('encrypts the keys, activates the row and emits the audit event', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
vi.mocked(createServiceClient).mockResolvedValue(
supabase as unknown as Awaited<ReturnType<typeof createServiceClient>>,
)
enqueue({
data: {
id: 'conn-1',
company_id: 'company-1',
user_id: 'user-1',
store_url: 'https://shop.example.se',
},
})
enqueue({
data: {
id: 'conn-1',
company_id: 'company-1',
user_id: 'user-1',
store_url: 'https://shop.example.se',
},
}) // activation update
vi.mocked(testConnectionAndFetchStoreInfo).mockResolvedValue({
name: 'Testbutiken',
currency: 'SEK',
prices_include_tax: true,
wc_version: '9.9.5',
})
const res = await POST(makeCallbackRequest(VALID_BODY))
expect(res.status).toBe(200)
const updates = findCalls('woocommerce_connections', 'update')
const activation = updates[0][0] as Record<string, string | boolean | null>
expect(activation.status).toBe('active')
expect(activation.transaction_sync_enabled).toBe(true)
expect(activation.oauth_state).toBeNull()
expect(activation.store_name).toBe('Testbutiken')
// Secrets never stored in plaintext, and they decrypt back.
expect(String(activation.consumer_key_encrypted)).not.toContain('ck_new')
expect(decryptCredential(String(activation.consumer_key_encrypted))).toBe('ck_new')
expect(decryptCredential(String(activation.consumer_secret_encrypted))).toBe('cs_new')
expect(eventBus.emit).toHaveBeenCalledWith(
expect.objectContaining({ type: 'woocommerce.connected' }),
)
})
})
@@ -0,0 +1,184 @@
import { NextResponse } from 'next/server'
import { createServiceClient } from '@/lib/supabase/server'
import { ensureInitialized } from '@/lib/init'
import { eventBus } from '@/lib/events/bus'
import { loadExtensions } from '@/lib/extensions/loader'
import { extensionRegistry } from '@/lib/extensions/registry'
import { createLogger } from '@/lib/logger'
import {
encryptCredential,
isWooCommerceConfigured,
} from '@/extensions/general/woocommerce/lib/credentials'
import { testConnectionAndFetchStoreInfo } from '@/extensions/general/woocommerce/lib/api-client'
// This route emits woocommerce.connected (audit trail). ensureInitialized()
// must run at module load so the event_log handler has subscribed before the
// first emit on a cold instance.
ensureInitialized()
const log = createLogger('woocommerce/callback')
// The credential probe talks to an arbitrary (often slow) WooCommerce host.
export const maxDuration = 60
/**
* POST /api/extensions/woocommerce/callback
*
* Server-to-server delivery of the wc-auth handshake result: WooCommerce
* POSTs { key_id, user_id, consumer_key, consumer_secret, key_permissions }
* here after the merchant approves. Must be a real Next.js route (not an
* extension dispatcher handler) because the store calls it directly,
* unauthenticated: the single-use oauth_state riding in user_id locates the
* pending row, and the received keys are verified against that row's stored
* store_url before anything is persisted.
*/
export async function POST(request: Request) {
loadExtensions()
if (!extensionRegistry.get('woocommerce')) {
return NextResponse.json(
{ error: 'WooCommerce extension is not enabled', code: 'EXTENSION_DISABLED' },
{ status: 503 },
)
}
// The registry does not check manifest requiredEnvVars, so this route can be
// live without the encryption key; without this guard encryptCredential()
// would throw AFTER the probe, escaping the markError path entirely.
if (!isWooCommerceConfigured()) {
return NextResponse.json(
{ error: 'WooCommerce integration is not configured', code: 'NOT_CONFIGURED' },
{ status: 503 },
)
}
let body: {
user_id?: unknown
consumer_key?: unknown
consumer_secret?: unknown
key_permissions?: unknown
}
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
}
const state = typeof body.user_id === 'string' ? body.user_id : null
const consumerKey = typeof body.consumer_key === 'string' ? body.consumer_key : null
const consumerSecret = typeof body.consumer_secret === 'string' ? body.consumer_secret : null
const keyPermissions =
typeof body.key_permissions === 'string' ? body.key_permissions : null
// The state is a UUID we generated; reject anything else before it reaches
// the DB (the column is typed uuid and would error opaquely).
const isUuid =
state !== null &&
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(state)
if (!isUuid || !consumerKey || !consumerSecret) {
return NextResponse.json({ error: 'Missing parameters' }, { status: 400 })
}
const supabase = await createServiceClient()
const { data: pending, error: findError } = await supabase
.from('woocommerce_connections')
.select('id, company_id, user_id, store_url')
.eq('oauth_state', state)
.eq('status', 'pending')
.single()
if (findError || !pending) {
log.warn('no pending connection for handshake state', {
hasRow: Boolean(pending),
code: findError?.code,
})
return NextResponse.json({ error: 'Unknown or expired state' }, { status: 404 })
}
const markError = (message: string) =>
supabase
.from('woocommerce_connections')
.update({ status: 'error', error_message: message, oauth_state: null })
.eq('id', pending.id)
.eq('status', 'pending')
// Authenticity check: the keys must actually work against the store URL the
// user asked to connect. A forged callback with someone else's (or made-up)
// keys fails here and never gets stored.
let storeInfo
try {
storeInfo = await testConnectionAndFetchStoreInfo({
storeUrl: pending.store_url,
consumerKey,
consumerSecret,
})
} catch (probeError) {
log.error('credential probe failed during handshake', {
connectionId: pending.id,
message: probeError instanceof Error ? probeError.message : String(probeError),
})
await markError('Nycklarna kunde inte verifieras mot butiken.')
return NextResponse.json({ error: 'Credential verification failed' }, { status: 502 })
}
const { data: activated, error: updateError } = await supabase
.from('woocommerce_connections')
.update({
consumer_key_encrypted: encryptCredential(consumerKey),
consumer_secret_encrypted: encryptCredential(consumerSecret),
key_permissions: keyPermissions,
store_name: storeInfo.name,
currency: storeInfo.currency,
prices_include_tax: storeInfo.prices_include_tax,
wc_version: storeInfo.wc_version,
status: 'active',
connected_at: new Date().toISOString(),
error_message: null,
oauth_state: null, // Clear to prevent replay
// Feed-only product: connecting the store means fetching its orders, so
// the nightly feed starts on by default; the panel toggle is the opt-out.
transaction_sync_enabled: true,
})
.eq('id', pending.id)
.eq('status', 'pending')
.select('id, company_id, user_id, store_url')
.single()
if (updateError || !activated) {
// 23505 = a partial unique index: the store is already actively connected
// (to this or another company), or the company connected in a parallel tab.
const isConflict = updateError?.code === '23505'
log.error('failed to activate connection', {
connectionId: pending.id,
code: updateError?.code,
message: updateError?.message,
})
await markError(
isConflict
? 'Butiken är redan ansluten till ett företag.'
: 'Anslutningen kunde inte slutföras.',
)
return NextResponse.json(
{ error: isConflict ? 'Store already connected' : 'Activation failed' },
{ status: isConflict ? 409 : 500 },
)
}
try {
await eventBus.emit({
type: 'woocommerce.connected',
payload: {
connectionId: activated.id,
storeUrl: activated.store_url,
userId: activated.user_id,
companyId: activated.company_id,
},
})
} catch (emitError) {
// Non-fatal: the DB state (source of truth) is already committed.
log.error('failed to emit woocommerce.connected', {
connectionId: activated.id,
message: emitError instanceof Error ? emitError.message : String(emitError),
})
}
return NextResponse.json({ success: true })
}
@@ -0,0 +1,138 @@
import { createClient } from '@supabase/supabase-js'
import { NextResponse } from 'next/server'
import { withCronContext } from '@/lib/api/with-cron-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { hasCapability } from '@/lib/entitlements/has-capability'
import { CAPABILITY } from '@/lib/entitlements/keys'
import { loadExtensions } from '@/lib/extensions/loader'
import { extensionRegistry } from '@/lib/extensions/registry'
import { isWooCommerceConfigured } from '@/extensions/general/woocommerce/lib/credentials'
import { syncWooCommerceOrders } from '@/extensions/general/woocommerce/lib/order-sync'
import type { WooCommerceConnection } from '@/extensions/general/woocommerce/types'
export const maxDuration = 300
/**
* GET /api/extensions/woocommerce/orders/cron
* Nightly order sync for connections that opted in (transaction_sync_enabled):
* imports each connected store's paid orders and refunds into the
* transactions inbox as a bank-style feed on the 1680 cash account.
*
* Read-only against the stores, and it never posts to the journal: rows land
* unbooked; booking stays a human decision. Idempotent via the
* (company_id, external_id) unique index, so overlapping windows and re-runs
* are no-ops. Emits no events, so no ensureInitialized() is needed.
*/
export const GET = withCronContext('cron.woocommerce_order_sync', async (_request, ctx) => {
// Physical routes under app/api/extensions/<id>/ compile into EVERY build,
// including the core-with-zero-extensions one: the registry (generated from
// extensions.config.json) is what actually switches an extension on. A
// scheduled-but-disabled cron must fail visibly (503) instead of quietly
// doing the work anyway.
loadExtensions()
if (!extensionRegistry.get('woocommerce')) {
ctx.log.warn('woocommerce extension is not enabled; cron refused')
return NextResponse.json(
{ error: 'WooCommerce extension is not enabled', code: 'EXTENSION_DISABLED' },
{ status: 503 },
)
}
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY
if (!supabaseUrl || !supabaseServiceKey) {
return errorResponseFromCode('INTERNAL_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { reason: 'Missing Supabase configuration' },
})
}
if (!isWooCommerceConfigured()) {
return NextResponse.json({ message: 'WooCommerce not configured', processed: 0 })
}
const supabase = createClient(supabaseUrl, supabaseServiceKey)
const { data: connections, error: connError } = await supabase
.from('woocommerce_connections')
.select('*')
.eq('status', 'active')
.eq('transaction_sync_enabled', true)
.order('last_order_synced_at', { ascending: true, nullsFirst: true })
.limit(50)
if (connError) {
ctx.log.error('failed to fetch woocommerce connections', connError, {
message: connError.message,
code: connError.code,
})
return errorResponse(connError, ctx.log, { requestId: ctx.requestId })
}
if (!connections || connections.length === 0) {
return NextResponse.json({
message: 'No connections with transaction sync enabled',
processed: 0,
})
}
const startTime = Date.now()
const TIME_BUDGET_MS = 240_000 // leave a minute of margin inside maxDuration
// Shared with syncWooCommerceOrders: it stops between pages and persists
// its cursor, so a truncated connection resumes next night.
const deadlineMs = startTime + TIME_BUDGET_MS
const results: Array<{
connectionId: string
imported: number
duplicates: number
status: 'synced' | 'revoked' | 'error'
}> = []
for (const connection of connections as WooCommerceConnection[]) {
if (Date.now() >= deadlineMs) {
ctx.log.info('time budget reached', { processedSoFar: results.length })
break
}
if (!(await hasCapability(supabase, connection.company_id, CAPABILITY.woocommerce_sync))) {
ctx.log.info('skip: capability not entitled', { companyId: connection.company_id })
continue
}
try {
const summary = await syncWooCommerceOrders(supabase, connection, ctx.log, deadlineMs)
if (summary.deadlineReached) {
ctx.log.info('connection stopped early on time budget; remaining rows resume next run', {
connectionId: connection.id,
})
}
results.push({
connectionId: connection.id,
imported: summary.imported,
duplicates: summary.duplicates,
status: summary.revoked ? 'revoked' : 'synced',
})
} catch (error) {
ctx.log.error('woocommerce order sync failed for connection', error as Error, {
connectionId: connection.id,
companyId: connection.company_id,
})
results.push({
connectionId: connection.id,
imported: 0,
duplicates: 0,
status: 'error',
})
}
}
const totalImported = results.reduce((acc, r) => acc + r.imported, 0)
ctx.log.info('woocommerce order sync summary', {
processed: results.length,
totalImported,
failed: results.filter((r) => r.status === 'error').length,
})
return NextResponse.json({ processed: results.length, imported: totalImported, results })
})
@@ -0,0 +1,62 @@
import { NextResponse } from 'next/server'
import { createServiceClient } from '@/lib/supabase/server'
import { loadExtensions } from '@/lib/extensions/loader'
import { extensionRegistry } from '@/lib/extensions/registry'
import { createLogger } from '@/lib/logger'
const log = createLogger('woocommerce/return')
/**
* GET /api/extensions/woocommerce/return
*
* Browser leg of the wc-auth handshake: WooCommerce redirects the merchant
* here with ?success=1|0&user_id=<our oauth_state>. The credentials arrive on
* the separate server-to-server callback (usually before this redirect, but
* ordering is not guaranteed), so on success this route only sends the user
* back to the import page; the panel polls /status until the row is active.
*/
export async function GET(request: Request) {
loadExtensions()
if (!extensionRegistry.get('woocommerce')) {
return NextResponse.json(
{ error: 'WooCommerce extension is not enabled', code: 'EXTENSION_DISABLED' },
{ status: 503 },
)
}
const { searchParams } = new URL(request.url)
const success = searchParams.get('success')
const state = searchParams.get('user_id')
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
// The WooCommerce surface lives on the import page; the base already has a
// query, so appended params below must use '&'.
const returnUrl = `${baseUrl}/import?mode=woocommerce`
if (success === '1') {
return NextResponse.redirect(`${returnUrl}&woocommerce_connected=true`)
}
// Denied (or malformed): close out the pending row so its state can never
// complete a late callback, then surface the denial to the panel.
if (state) {
try {
const supabase = await createServiceClient()
await supabase
.from('woocommerce_connections')
.update({
status: 'error',
error_message: 'Anslutningen nekades i butiken.',
oauth_state: null,
})
.eq('oauth_state', state)
.eq('status', 'pending')
} catch (cleanupError) {
log.error('failed to clean up denied connection', {
message: cleanupError instanceof Error ? cleanupError.message : String(cleanupError),
})
}
}
return NextResponse.redirect(`${returnUrl}&woocommerce_error=denied`)
}
+1
View File
@@ -29,6 +29,7 @@
0 0 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/tax-deadlines/cron
0 5 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/enable-banking/sync/cron
30 3 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/stripe/transactions/cron
45 3 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/woocommerce/orders/cron
0 3 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/documents/verify/cron
0 4 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/sandbox/cleanup/cron
0 2 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/events/cleanup/cron
+1
View File
@@ -29,6 +29,7 @@
0 0 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/tax-deadlines/cron
0 5 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/enable-banking/sync/cron
30 3 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/stripe/transactions/cron
45 3 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/woocommerce/orders/cron
0 3 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/documents/verify/cron
0 4 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/sandbox/cleanup/cron
0 2 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/events/cleanup/cron
+1 -1
View File
@@ -1 +1 @@
{"$schema":"./extensions.schema.json","extensions":["enable-banking","email","arcim-migration","tic","mcp-server","cloud-backup","skatteverket","invoice-inbox","document-extraction","stripe","whatsapp-inbox"]}
{"$schema":"./extensions.schema.json","extensions":["enable-banking","email","arcim-migration","tic","mcp-server","cloud-backup","skatteverket","invoice-inbox","document-extraction","stripe","whatsapp-inbox","woocommerce"]}
+2 -1
View File
@@ -31,7 +31,8 @@
"skatteverket",
"cloud-backup",
"document-extraction",
"whatsapp-inbox"
"whatsapp-inbox",
"woocommerce"
]
},
"description": "Extension IDs to enable. Each ID must match a manifest.json in the extensions/ directory."
@@ -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>
)
}
+31
View File
@@ -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."
}
}
+89
View File
@@ -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
}
+3
View File
@@ -30,6 +30,8 @@ export const CAPABILITY = {
bolagsverket: 'bolagsverket',
/** Stripe Connect: auto payment links on invoices + payment/payout sync. */
stripe_payments: 'stripe_payments',
/** WooCommerce store sync: orders/refunds imported as a transaction feed. */
woocommerce_sync: 'woocommerce_sync',
} as const
export type CapabilityKey = (typeof CAPABILITY)[keyof typeof CAPABILITY]
@@ -55,6 +57,7 @@ export const PAID_CAPABILITIES: readonly CapabilityKey[] = [
CAPABILITY.skatteverket,
CAPABILITY.email_send,
CAPABILITY.stripe_payments,
CAPABILITY.woocommerce_sync,
] as const
/**
+4
View File
@@ -78,6 +78,10 @@ export type CoreEvent =
// land in event_log for the audit trail, mirroring bank_connection.*.
| { type: 'stripe.connected'; payload: { connectionId: string; stripeAccountId: string; livemode: boolean; userId: string; companyId: string } }
| { type: 'stripe.disconnected'; payload: { connectionId: string; stripeAccountId: string | null; reason: 'user' | 'revoked_upstream'; userId: string; companyId: string } }
// WooCommerce store lifecycle: same audit doctrine as stripe.* (a third
// party's API credentials are granted/dropped).
| { type: 'woocommerce.connected'; payload: { connectionId: string; storeUrl: string; userId: string; companyId: string } }
| { type: 'woocommerce.disconnected'; payload: { connectionId: string; storeUrl: string | null; reason: 'user' | 'revoked_upstream'; userId: string; companyId: string } }
// Periods
| { type: 'period.locked'; payload: { period: FiscalPeriod; userId: string; companyId: string } }
| { type: 'period.unlocked'; payload: { period: FiscalPeriod; userId: string; companyId: string } }
+3 -3
View File
@@ -48,8 +48,8 @@ describe('sectors registry', () => {
expect(SECTORS.length).toBe(1)
})
it('should have 15 total extensions', () => {
expect(getAllExtensions().length).toBe(15)
it('should have 16 total extensions', () => {
expect(getAllExtensions().length).toBe(16)
})
it('should have unique slugs within each sector', () => {
@@ -94,7 +94,7 @@ describe('sectors registry', () => {
it('getExtensionsBySector returns extensions for a sector', () => {
const extensions = getExtensionsBySector('general')
expect(extensions.length).toBe(15)
expect(extensions.length).toBe(16)
})
it('all extensions have required fields', () => {
+1
View File
@@ -12,4 +12,5 @@ export const ENABLED_EXTENSION_IDS: ReadonlySet<string> = new Set([
'document-extraction',
'stripe',
'whatsapp-inbox',
'woocommerce',
])
+2
View File
@@ -11,6 +11,7 @@ import { invoiceInboxExtension } from '@/extensions/general/invoice-inbox'
import { documentExtractionExtension } from '@/extensions/general/document-extraction'
import { stripeExtension } from '@/extensions/general/stripe'
import { whatsappInboxExtension } from '@/extensions/general/whatsapp-inbox'
import { woocommerceExtension } from '@/extensions/general/woocommerce'
export const FIRST_PARTY_EXTENSIONS: Extension[] = [
enableBankingExtension,
@@ -24,4 +25,5 @@ export const FIRST_PARTY_EXTENSIONS: Extension[] = [
documentExtractionExtension,
stripeExtension,
whatsappInboxExtension,
woocommerceExtension,
]
+11
View File
@@ -147,5 +147,16 @@ export const EXTENSION_DEFINITIONS: Record<string, ExtensionDefinition[]> = {
],
"hasOwnData": true
},
{
"slug": "woocommerce",
"name": "WooCommerce",
"sector": "general",
"category": "import",
"icon": "ShoppingCart",
"dataPattern": "manual",
"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.",
"hasOwnData": true
},
],
}
@@ -19,6 +19,9 @@ const SETTINGS_PANELS: Record<string, ComponentType> = {
stripe: dynamic(
() => import('@/extensions/general/stripe/components/StripeSettingsPanel')
),
woocommerce: dynamic(
() => import('@/extensions/general/woocommerce/components/WooCommerceSettingsPanel')
),
}
/**
+1
View File
@@ -1014,6 +1014,7 @@ export const ARCHIVE_EXCLUDED_TABLES: Record<string, string> = {
whatsapp_conversations:
'WhatsApp bot conversation state (company_id is only a which-company pin); receipts live in document_attachments',
webhooks: 'automation config with signing secrets',
woocommerce_connections: 'WooCommerce connection state (encrypted API secrets)',
}
/** Max parent ids per `IN (...)` chunk: keeps the PostgREST URL well under limits. */
@@ -115,7 +115,7 @@ describe('applySubscriptionState', () => {
const grantUpsert = calls.find((c) => c.table === 'capability_grants')
expect(grantUpsert?.op).toBe('upsert')
const rows = grantUpsert?.payload as Array<{ capability_key: string; source: string }>
expect(rows.map((r) => r.capability_key).sort()).toEqual(['ai', 'bank_sync', 'email_send', 'skatteverket', 'stripe_payments'])
expect(rows.map((r) => r.capability_key).sort()).toEqual(['ai', 'bank_sync', 'email_send', 'skatteverket', 'stripe_payments', 'woocommerce_sync'])
expect(rows.every((r) => r.source === 'stripe')).toBe(true)
})
+57
View File
@@ -409,6 +409,59 @@
"transaction_sync_toggle_failed": "Could not save the setting. Please try again.",
"sync_done_transactions": "{imported} transaction(s) imported, {linked} linked to vouchers."
},
"woocommerce": {
"title": "WooCommerce",
"description": "Connect your WooCommerce store to fetch paid orders and refunds into the transaction inbox, as a bank-style feed for the store. You book the rows from the inbox as usual.",
"not_configured": "The WooCommerce integration is not configured on this installation. Contact your administrator.",
"load_failed": "Could not read the WooCommerce status. Check your connection and try again.",
"action_timeout": "The action took too long. Reload the page to see whether it went through.",
"action_network": "No contact with the server. Check your connection and try again.",
"store_url_label": "Store address",
"connect": "Connect store",
"connecting": "Connecting…",
"connect_hint": "You are sent to your store to approve the connection with read access. The keys are stored encrypted and you can revoke them at any time, here or in WooCommerce.",
"manual_toggle": "Enter API keys manually",
"manual_hint": "Create a read-only API key in WooCommerce under Settings, Advanced, REST API and paste the keys here.",
"consumer_key_label": "Consumer key",
"consumer_secret_label": "Consumer secret",
"manual_connect": "Connect with keys",
"disconnect": "Disconnect",
"disconnect_confirm": "Yes, disconnect",
"cancel": "Cancel",
"status_active": "Connected",
"status_pending": "Pending",
"status_revoked": "Disconnected",
"status_error": "Error",
"connected_since": "Connected {date}",
"unnamed_store": "WooCommerce store",
"pending_note": "Waiting for the store to send the keys. Finish the approval in the store if you have not already.",
"connected_toast_title": "WooCommerce connected",
"connected_toast_description": "The store's paid orders and refunds are now fetched every night.",
"disconnected_toast_title": "WooCommerce disconnected",
"disconnected_toast_description": "Also remove the API key in the store's WooCommerce settings.",
"connect_failed_title": "Connection failed",
"disconnect_failed_title": "Disconnect failed",
"error_generic": "Something went wrong. Please try again.",
"error_denied": "The connection was denied in the store.",
"sync_now": "Sync now",
"syncing": "Syncing…",
"sync_done_title": "Sync complete",
"sync_done_feed": "{fetched} order(s) fetched: {imported} new rows in the inbox.",
"sync_done_empty": "The store returned no orders for the period. Check that the right store is connected if you expected orders.",
"sync_done_feed_errors": "{fetched} order(s) fetched: {imported} new rows in the inbox. {errors} row(s) could not be imported: sync again.",
"sync_partial_title": "Sync paused",
"sync_partial": "{fetched} order(s) fetched so far: {imported} new rows in the inbox.{errors, plural, =0 {} other { # row(s) could not be imported.}} Not all orders were fetched in time: sync again to continue where it stopped.",
"sync_failed_title": "Sync failed",
"sync_revoked": "The store rejected the API key, so no orders could be fetched. Connect the store again.",
"transaction_sync_title": "Orders from WooCommerce",
"transaction_sync_description": "Fetch the store's paid orders and refunds into the transaction inbox every night, as a bank-style feed for the store. You book the rows from the inbox as usual.",
"transaction_sync_backfill_note": "The first sync fetches up to 90 days of history, but never before the bookkeeping lock.",
"transaction_sync_last_synced": "Last synced {date}",
"transaction_sync_never_synced": "Not synced yet",
"transaction_sync_enabled_toast": "Order sync enabled. History is fetched on the next sync.",
"transaction_sync_disabled_toast": "Order sync disabled.",
"transaction_sync_toggle_failed": "Could not save the setting. Please try again."
},
"settings_modal": {
"title": "Settings",
"description": "Manage your company and account"
@@ -6356,6 +6409,10 @@
"stripe_description": "Connect your company's Stripe account to fetch payments, fees, and payouts continuously and book them against the Stripe balance.",
"stripe_not_enabled_title": "The Stripe extension is not enabled",
"stripe_not_enabled_description": "Enable the Stripe extension to connect your Stripe account and sync transactions automatically.",
"woocommerce_title": "WooCommerce",
"woocommerce_description": "Connect your WooCommerce store to fetch paid orders and refunds into the transaction inbox.",
"woocommerce_not_enabled_title": "The WooCommerce extension is not enabled",
"woocommerce_not_enabled_description": "Enable the WooCommerce extension to connect your store and fetch orders automatically.",
"migration_title": "Import from another system",
"migration_description": "Nothing changes in your existing system.",
"bankfile_title": "Bank file",
+57
View File
@@ -409,6 +409,59 @@
"transaction_sync_toggle_failed": "Kunde inte spara inställningen. Försök igen.",
"sync_done_transactions": "{imported} transaktion(er) importerade, {linked} länkade till verifikat."
},
"woocommerce": {
"title": "WooCommerce",
"description": "Koppla din WooCommerce-butik så hämtas betalda ordrar och återbetalningar till transaktionsinkorgen, som ett bankflöde för butiken. Du bokför raderna som vanligt från inkorgen.",
"not_configured": "WooCommerce-integrationen är inte konfigurerad på den här installationen. Kontakta administratören.",
"load_failed": "Kunde inte läsa WooCommerce-statusen. Kontrollera din uppkoppling och försök igen.",
"action_timeout": "Åtgärden tog för lång tid. Ladda om sidan för att se om den gick igenom.",
"action_network": "Ingen kontakt med servern. Kontrollera din uppkoppling och försök igen.",
"store_url_label": "Butikens adress",
"connect": "Anslut butik",
"connecting": "Ansluter…",
"connect_hint": "Du skickas till din butik för att godkänna kopplingen med läsbehörighet. Nycklarna lagras krypterade och du kan när som helst återkalla dem här eller i WooCommerce.",
"manual_toggle": "Ange API-nycklar manuellt",
"manual_hint": "Skapa en API-nyckel med läsbehörighet i WooCommerce under Inställningar, Avancerat, REST-API och klistra in nycklarna här.",
"consumer_key_label": "Konsumentnyckel (consumer key)",
"consumer_secret_label": "Konsumenthemlighet (consumer secret)",
"manual_connect": "Anslut med nycklar",
"disconnect": "Koppla från",
"disconnect_confirm": "Ja, koppla från",
"cancel": "Avbryt",
"status_active": "Ansluten",
"status_pending": "Väntar",
"status_revoked": "Frånkopplad",
"status_error": "Fel",
"connected_since": "Ansluten {date}",
"unnamed_store": "WooCommerce-butik",
"pending_note": "Väntar på att butiken ska skicka nycklarna. Slutför godkännandet i butiken om du inte redan gjort det.",
"connected_toast_title": "WooCommerce anslutet",
"connected_toast_description": "Butikens betalda ordrar och återbetalningar hämtas nu varje natt.",
"disconnected_toast_title": "WooCommerce frånkopplat",
"disconnected_toast_description": "Ta även bort API-nyckeln i butikens WooCommerce-inställningar.",
"connect_failed_title": "Anslutningen misslyckades",
"disconnect_failed_title": "Frånkopplingen misslyckades",
"error_generic": "Något gick fel. Försök igen.",
"error_denied": "Anslutningen nekades i butiken.",
"sync_now": "Synka nu",
"syncing": "Synkar…",
"sync_done_title": "Synkronisering klar",
"sync_done_feed": "{fetched} order/ordrar hämtade: {imported} nya rader i inkorgen.",
"sync_done_empty": "Butiken returnerade inga ordrar för perioden. Kontrollera att rätt butik är ansluten om du väntade dig ordrar.",
"sync_done_feed_errors": "{fetched} order/ordrar hämtade: {imported} nya rader i inkorgen. {errors} rad(er) kunde inte importeras: synka igen.",
"sync_partial_title": "Synkroniseringen pausades",
"sync_partial": "{fetched} order/ordrar hämtade hittills: {imported} nya rader i inkorgen.{errors, plural, =0 {} other { # rad(er) kunde inte importeras.}} Alla ordrar hann inte hämtas: synka igen för att fortsätta där det stannade.",
"sync_failed_title": "Synkroniseringen misslyckades",
"sync_revoked": "Butiken avvisade API-nyckeln, så inga ordrar kunde hämtas. Anslut butiken igen.",
"transaction_sync_title": "Ordrar från WooCommerce",
"transaction_sync_description": "Hämta butikens betalda ordrar och återbetalningar till transaktionsinkorgen varje natt, som ett bankflöde för butiken. Du bokför raderna som vanligt från inkorgen.",
"transaction_sync_backfill_note": "Vid första synkningen hämtas upp till 90 dagars historik, dock inte före bokföringslåset.",
"transaction_sync_last_synced": "Senast synkad {date}",
"transaction_sync_never_synced": "Inte synkad ännu",
"transaction_sync_enabled_toast": "Ordersynk aktiverad. Historiken hämtas vid nästa synkning.",
"transaction_sync_disabled_toast": "Ordersynk avaktiverad.",
"transaction_sync_toggle_failed": "Kunde inte spara inställningen. Försök igen."
},
"settings_modal": {
"title": "Inställningar",
"description": "Hantera ditt företag och konto"
@@ -6356,6 +6409,10 @@
"stripe_description": "Koppla företagets Stripe-konto så hämtas betalningar, avgifter och utbetalningar löpande och bokförs mot Stripe-saldot.",
"stripe_not_enabled_title": "Stripe-tillägget är inte aktiverat",
"stripe_not_enabled_description": "Aktivera tillägget Stripe för att koppla ditt Stripe-konto och synka transaktioner automatiskt.",
"woocommerce_title": "WooCommerce",
"woocommerce_description": "Koppla din WooCommerce-butik så hämtas betalda ordrar och återbetalningar till transaktionsinkorgen.",
"woocommerce_not_enabled_title": "WooCommerce-tillägget är inte aktiverat",
"woocommerce_not_enabled_description": "Aktivera tillägget WooCommerce för att koppla din butik och hämta ordrar automatiskt.",
"migration_title": "Hämta från annat system",
"migration_description": "Inget ändras i ditt befintliga system.",
"bankfile_title": "Bankfil",
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="WooCommerce">
<rect width="32" height="32" rx="7" fill="#7f54b3"/>
<text x="16" y="21.5" text-anchor="middle" font-family="Arial, Helvetica, sans-serif" font-size="15" font-weight="bold" fill="#ffffff">W</text>
</svg>

After

Width:  |  Height:  |  Size: 305 B

@@ -0,0 +1,99 @@
-- WooCommerce store connections: per-company WooCommerce REST API credentials
-- for the order/refund transaction feed (extensions/general/woocommerce).
--
-- Unlike Stripe Connect there is no platform account: WooCommerce hands each
-- connected app a per-store consumer key/secret (via the /wc-auth/v1/authorize
-- handshake or manual key entry). Both are secrets, so they are stored
-- AES-256-GCM encrypted with a dedicated server-side key
-- (WOOCOMMERCE_CREDENTIALS_ENCRYPTION_KEY), never in plaintext. The encrypted
-- blobs are useless without that env key, mirroring the Skatteverket token
-- store.
--
-- Modeled on stripe_connections (20260712100000): same status lifecycle, same
-- member-scoped RLS, no DELETE policy (connections are revoked, never deleted,
-- for audit). Like stripe_connections there is no write_audit_log trigger:
-- this is connection state, not accounting data, and audit-logging rows that
-- carry encrypted credentials would copy secret ciphertext into audit_log.
create table public.woocommerce_connections (
id uuid primary key default gen_random_uuid(),
company_id uuid not null references public.companies(id) on delete cascade,
user_id uuid not null references auth.users(id) on delete cascade,
-- Normalized https origin (optionally with a subdirectory path for
-- WordPress installs under a path), no trailing slash. Set at connect
-- start; the wc-auth callback and every API call use this stored URL, so a
-- forged callback cannot redirect the integration to another store.
store_url text not null,
-- Store display name (WordPress site title) for the settings panel.
store_name text,
-- AES-256-GCM encrypted consumer key/secret (ck_... / cs_...).
-- NULL while the wc-auth round-trip is pending.
consumer_key_encrypted text,
consumer_secret_encrypted text,
-- Permission level WooCommerce granted the key ('read' expected).
key_permissions text,
status text not null default 'pending'
check (status in ('pending', 'active', 'revoked', 'error')),
-- Single-use CSRF token for the wc-auth round-trip; passed as the handshake
-- user_id correlation parameter and cleared on activation.
oauth_state uuid,
-- Store settings read at connect time; drive the feed's cash account.
currency text,
prices_include_tax boolean,
wc_version text,
-- Opt-in for the nightly order feed cron (the manual sync button ignores it).
transaction_sync_enabled boolean not null default false,
-- Order-polling cursor: max date_modified_gmt processed. Re-polled with a
-- 24h overlap; (company_id, external_id) dedup makes overlaps no-ops.
last_order_synced_at timestamptz,
error_message text,
connected_at timestamptz,
disconnected_at timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
-- One active connection per company.
create unique index woocommerce_connections_one_active_per_company
on public.woocommerce_connections (company_id) where (status = 'active');
-- A store may be actively connected to at most one company: two companies
-- importing the same order stream would double-book it.
create unique index woocommerce_connections_store_active_uniq
on public.woocommerce_connections (store_url) where (status = 'active');
create index idx_woocommerce_connections_company_id
on public.woocommerce_connections (company_id);
create index idx_woocommerce_connections_oauth_state
on public.woocommerce_connections (oauth_state) where (oauth_state is not null);
alter table public.woocommerce_connections enable row level security;
-- Members read their company's connection. Insert/update are member-scoped so
-- the connect/disconnect routes can run on the user's cookie session; the
-- wc-auth callback and the sync cron use the service role (bypasses RLS).
-- No DELETE policy: connections are revoked (status flip), never deleted.
create policy "members read woocommerce_connections"
on public.woocommerce_connections for select
using (company_id in (select public.user_company_ids()));
create policy "members insert woocommerce_connections"
on public.woocommerce_connections for insert
with check (
company_id in (select public.user_company_ids())
and user_id = auth.uid()
);
create policy "members update woocommerce_connections"
on public.woocommerce_connections for update
using (company_id in (select public.user_company_ids()))
with check (company_id in (select public.user_company_ids()));
create trigger set_updated_at_woocommerce_connections
before update on public.woocommerce_connections
for each row execute function public.update_updated_at_column();
comment on table public.woocommerce_connections is
'WooCommerce store connections per company. Consumer key/secret stored AES-256-GCM encrypted; decryption requires the server-side WOOCOMMERCE_CREDENTIALS_ENCRYPTION_KEY.';
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,29 @@
-- Backfill capability_grants for the new 'woocommerce_sync' capability.
--
-- woocommerce_sync joins PAID_CAPABILITIES, but existing companies' grants
-- were written by the billing webhook / trial seeding BEFORE this key existed,
-- and are only refreshed on the next subscription event. Without a backfill,
-- every current payer and trialer would see the WooCommerce integration as not
-- entitled until their next webhook. Mirror each existing bank_sync grant (the
-- same sibling used by the stripe_payments backfill, 20260712100100) with
-- identical scope, source and expiry, so entitlement state stays exactly
-- aligned.
--
-- Idempotent: the (scope, key, source) unique index makes re-runs no-ops.
insert into public.capability_grants
(company_id, team_id, capability_key, source, granted_at, expires_at, metadata)
select
g.company_id,
g.team_id,
'woocommerce_sync',
g.source,
g.granted_at,
g.expires_at,
jsonb_build_object(
'backfilled_from', 'bank_sync',
'backfill_migration', '20260806170100'
)
from public.capability_grants g
where g.capability_key = 'bank_sync'
on conflict (company_id, team_id, capability_key, source) do nothing;
+131
View File
@@ -0,0 +1,131 @@
import { describe, it, expect } from 'vitest'
import { getPool, withUserContext } from './setup'
import { randomUUID } from 'crypto'
import { seedCompany } from './fixtures'
// Committed (pool) inserts persist across pg-real runs, and the store_url
// partial unique index is global: fixed URLs would collide with rows left by
// a previous run before the assertion under test is ever reached.
const uniqueStore = (label: string) => 'https://' + label + '-' + randomUUID() + '.example.se'
/**
* Covers migration 20260806170000_woocommerce_connections:
* 1. RLS: members insert and read their own company's connection,
* non-members see nothing and cannot insert for a foreign company.
* 2. One ACTIVE connection per company (partial unique index).
* 3. One store actively connected to at most one company.
* 4. No DELETE policy: a member DELETE silently affects zero rows.
*/
describe('woocommerce_connections RLS', () => {
it('a member can insert and read their company connection', async () => {
const { userId, companyId } = await seedCompany()
await withUserContext(userId, async (client) => {
const inserted = await client.query(
`INSERT INTO public.woocommerce_connections
(company_id, user_id, store_url, status, oauth_state)
VALUES ($1, $2, 'https://shop.example.se', 'pending', gen_random_uuid())
RETURNING id`,
[companyId, userId],
)
expect(inserted.rows).toHaveLength(1)
const read = await client.query(
`SELECT status, store_url FROM public.woocommerce_connections WHERE company_id = $1`,
[companyId],
)
expect(read.rows).toEqual([
{ status: 'pending', store_url: 'https://shop.example.se' },
])
})
})
it('a non-member sees nothing and cannot insert for a foreign company', async () => {
const { userId: ownerId, companyId } = await seedCompany()
await getPool().query(
`INSERT INTO public.woocommerce_connections (company_id, user_id, store_url, status)
VALUES ($1, $2, $3, 'active')`,
[companyId, ownerId, uniqueStore('foreign')],
)
const { userId: outsiderId } = await seedCompany() // member of a DIFFERENT company
await withUserContext(outsiderId, async (client) => {
const read = await client.query(
`SELECT id FROM public.woocommerce_connections WHERE company_id = $1`,
[companyId],
)
expect(read.rows).toHaveLength(0)
await expect(
client.query(
`INSERT INTO public.woocommerce_connections (company_id, user_id, store_url, status)
VALUES ($1, $2, 'https://intruder.example.se', 'pending')`,
[companyId, outsiderId],
),
).rejects.toThrow(/row-level security/i)
})
})
it('only one ACTIVE connection per company is allowed', async () => {
const { userId, companyId } = await seedCompany()
await getPool().query(
`INSERT INTO public.woocommerce_connections (company_id, user_id, store_url, status)
VALUES ($1, $2, $3, 'active')`,
[companyId, userId, uniqueStore('store-one')],
)
await expect(
getPool().query(
`INSERT INTO public.woocommerce_connections (company_id, user_id, store_url, status)
VALUES ($1, $2, $3, 'active')`,
[companyId, userId, uniqueStore('store-two')],
),
).rejects.toMatchObject({ code: '23505' }) // unique_violation
})
it('a store may be actively connected to at most one company', async () => {
const { userId: userA, companyId: companyA } = await seedCompany()
const { userId: userB, companyId: companyB } = await seedCompany()
const sharedUrl = uniqueStore('shared')
await getPool().query(
`INSERT INTO public.woocommerce_connections (company_id, user_id, store_url, status)
VALUES ($1, $2, $3, 'active')`,
[companyA, userA, sharedUrl],
)
await expect(
getPool().query(
`INSERT INTO public.woocommerce_connections (company_id, user_id, store_url, status)
VALUES ($1, $2, $3, 'active')`,
[companyB, userB, sharedUrl],
),
).rejects.toMatchObject({ code: '23505' })
// A revoked row for the same store is fine (history is kept).
const revoked = await getPool().query(
`INSERT INTO public.woocommerce_connections (company_id, user_id, store_url, status)
VALUES ($1, $2, $3, 'revoked') RETURNING id`,
[companyB, userB, sharedUrl],
)
expect(revoked.rows).toHaveLength(1)
})
it('members cannot DELETE (no DELETE policy; revoke is a status flip)', async () => {
const { userId, companyId } = await seedCompany()
const { rows } = await getPool().query(
`INSERT INTO public.woocommerce_connections (company_id, user_id, store_url, status)
VALUES ($1, $2, $3, 'active') RETURNING id`,
[companyId, userId, uniqueStore('keep')],
)
await withUserContext(userId, async (client) => {
const del = await client.query(
`DELETE FROM public.woocommerce_connections WHERE id = $1`,
[rows[0].id],
)
expect(del.rowCount).toBe(0)
})
const still = await getPool().query(
`SELECT id FROM public.woocommerce_connections WHERE id = $1`,
[rows[0].id],
)
expect(still.rows).toHaveLength(1)
})
})
+4
View File
@@ -25,6 +25,10 @@
"path": "/api/extensions/stripe/transactions/cron",
"schedule": "30 3 * * *"
},
{
"path": "/api/extensions/woocommerce/orders/cron",
"schedule": "45 3 * * *"
},
{
"path": "/api/documents/verify/cron",
"schedule": "0 3 * * *"