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:
@@ -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.
|
||||
|
||||
@@ -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`)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 @@
|
||||
{"$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"]}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { Extension } from '@/lib/extensions/types'
|
||||
import { woocommerceApiRoutes } from './api-routes'
|
||||
|
||||
/**
|
||||
* WooCommerce extension
|
||||
*
|
||||
* Connects a company's WooCommerce store via the wc-auth key handshake (or
|
||||
* manual key entry) and imports the store's paid orders and refunds into the
|
||||
* transactions inbox as a bank-style feed on the 1680 cash account. Feed-only
|
||||
* (same doctrine as the Stripe feed, decision 2026-08-06): nothing is
|
||||
* auto-booked, and payment-gateway fees/payouts are out of scope: core wc/v3
|
||||
* does not expose them.
|
||||
*
|
||||
* Required environment variables:
|
||||
* - WOOCOMMERCE_CREDENTIALS_ENCRYPTION_KEY (at-rest key for consumer key/secret)
|
||||
*/
|
||||
export const woocommerceExtension: Extension = {
|
||||
id: 'woocommerce',
|
||||
name: 'WooCommerce',
|
||||
version: '1.0.0',
|
||||
sector: 'general',
|
||||
|
||||
settingsPanel: {
|
||||
label: 'WooCommerce',
|
||||
path: '/import?mode=woocommerce',
|
||||
},
|
||||
|
||||
apiRoutes: woocommerceApiRoutes,
|
||||
}
|
||||
|
||||
export default woocommerceExtension
|
||||
@@ -0,0 +1,319 @@
|
||||
import type { WooOrder, WooRefund, WooStoreInfo } from '../types'
|
||||
|
||||
/**
|
||||
* Minimal WooCommerce REST API (wc/v3) client for the order feed.
|
||||
*
|
||||
* Auth is HTTP Basic (consumer key as username, secret as password) over
|
||||
* HTTPS only. Some hosts (Apache CGI, security plugins) strip the
|
||||
* Authorization header, so a 401 is retried once with the documented
|
||||
* query-string credential fallback; that fallback is why plain-http stores
|
||||
* are refused outright (keys in a cleartext URL are a credentials leak).
|
||||
*
|
||||
* Typical WooCommerce hosts are slow shared PHP boxes: requests run
|
||||
* sequentially, pages are capped at 100 rows, and 429/5xx responses get a
|
||||
* short exponential backoff before the error is surfaced.
|
||||
*/
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 30_000
|
||||
const RETRYABLE_STATUS = new Set([429, 502, 503, 504])
|
||||
const RETRY_DELAYS_MS = [1_000, 3_000]
|
||||
/** wc/v3 hard maximum for per_page. */
|
||||
export const WC_PAGE_SIZE = 100
|
||||
|
||||
export interface WooCredentials {
|
||||
storeUrl: string
|
||||
consumerKey: string
|
||||
consumerSecret: string
|
||||
}
|
||||
|
||||
export class WooCommerceApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
/** HTTP status, or 0 for network-level failures. */
|
||||
readonly status: number,
|
||||
/** WooCommerce error code (e.g. woocommerce_rest_cannot_view), if any. */
|
||||
readonly wooCode: string | null = null,
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'WooCommerceApiError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an API error means the credentials themselves are dead (key deleted
|
||||
* or demoted in wp-admin), as opposed to a transient failure. Used to flip a
|
||||
* connection to status 'revoked' so the UI offers a reconnect instead of the
|
||||
* cron retrying forever.
|
||||
*/
|
||||
export function isRevokedCredentialsError(error: unknown): boolean {
|
||||
if (!(error instanceof WooCommerceApiError)) return false
|
||||
return error.status === 401 || error.status === 403
|
||||
}
|
||||
|
||||
/**
|
||||
* Hostnames the server must never fetch: the store URL is user input that we
|
||||
* probe server-side, so loopback/link-local/private ranges and internal
|
||||
* naming conventions are refused outright (SSRF guard). Hostname-level only:
|
||||
* a public DNS name resolving to a private address is not caught here, which
|
||||
* matches the app's other outbound-URL surfaces.
|
||||
*/
|
||||
function isDisallowedHost(hostname: string): boolean {
|
||||
const h = hostname.toLowerCase()
|
||||
if (h === 'localhost' || h.endsWith('.localhost')) return true
|
||||
if (h.endsWith('.local') || h.endsWith('.internal')) return true
|
||||
// IPv6 literals (URL.hostname strips the brackets): never a real store.
|
||||
if (h.includes(':')) return true
|
||||
const v4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(h)
|
||||
if (v4) {
|
||||
const a = Number(v4[1])
|
||||
const b = Number(v4[2])
|
||||
if (a === 0 || a === 10 || a === 127) return true
|
||||
if (a === 169 && b === 254) return true
|
||||
if (a === 172 && b >= 16 && b <= 31) return true
|
||||
if (a === 192 && b === 168) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize and validate a user-entered store URL to an https origin plus
|
||||
* optional subdirectory path (WordPress installs under a path are common),
|
||||
* lowercased host, no trailing slash, no query/fragment/credentials, and no
|
||||
* private/internal hosts. Returns null for anything invalid, including
|
||||
* plain http.
|
||||
*/
|
||||
export function normalizeStoreUrl(input: string): string | null {
|
||||
const trimmed = input.trim()
|
||||
if (!trimmed) return null
|
||||
const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(withScheme)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (url.protocol !== 'https:') return null
|
||||
if (url.username || url.password || url.search || url.hash) return null
|
||||
if (isDisallowedHost(url.hostname)) return null
|
||||
const path = url.pathname.replace(/\/+$/, '')
|
||||
return `https://${url.host.toLowerCase()}${path}`
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function buildUrl(
|
||||
creds: WooCredentials,
|
||||
path: string,
|
||||
params: Record<string, string>,
|
||||
credentialsInQuery: boolean,
|
||||
): string {
|
||||
const url = new URL(`${creds.storeUrl}/wp-json/wc/v3${path}`)
|
||||
for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value)
|
||||
if (credentialsInQuery) {
|
||||
url.searchParams.set('consumer_key', creds.consumerKey)
|
||||
url.searchParams.set('consumer_secret', creds.consumerSecret)
|
||||
}
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
async function requestOnce(
|
||||
creds: WooCredentials,
|
||||
path: string,
|
||||
params: Record<string, string>,
|
||||
credentialsInQuery: boolean,
|
||||
): Promise<Response> {
|
||||
const headers: Record<string, string> = { Accept: 'application/json' }
|
||||
if (!credentialsInQuery) {
|
||||
const basic = Buffer.from(`${creds.consumerKey}:${creds.consumerSecret}`).toString('base64')
|
||||
headers.Authorization = `Basic ${basic}`
|
||||
}
|
||||
return fetch(buildUrl(creds, path, params, credentialsInQuery), {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
})
|
||||
}
|
||||
|
||||
async function parseError(response: Response): Promise<WooCommerceApiError> {
|
||||
let wooCode: string | null = null
|
||||
let detail = ''
|
||||
try {
|
||||
const body = (await response.json()) as { code?: string; message?: string }
|
||||
wooCode = body.code ?? null
|
||||
detail = body.message ?? ''
|
||||
} catch {
|
||||
// Non-JSON error body (host error page); the status is enough.
|
||||
}
|
||||
return new WooCommerceApiError(
|
||||
`WooCommerce API ${response.status}${detail ? `: ${detail}` : ''}`,
|
||||
response.status,
|
||||
wooCode,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* GET a wc/v3 path. Retries the header-stripped-auth case (401 → query-string
|
||||
* credentials) once, and 429/5xx with a short backoff.
|
||||
*/
|
||||
export async function wcGet<T>(
|
||||
creds: WooCredentials,
|
||||
path: string,
|
||||
params: Record<string, string> = {},
|
||||
): Promise<T> {
|
||||
let credentialsInQuery = false
|
||||
let lastError: unknown
|
||||
for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt++) {
|
||||
let response: Response
|
||||
try {
|
||||
response = await requestOnce(creds, path, params, credentialsInQuery)
|
||||
} catch (err) {
|
||||
// Network/timeout errors: retry on the same backoff schedule.
|
||||
lastError = new WooCommerceApiError(
|
||||
`WooCommerce request failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
0,
|
||||
)
|
||||
if (attempt < RETRY_DELAYS_MS.length) {
|
||||
await sleep(RETRY_DELAYS_MS[attempt])
|
||||
continue
|
||||
}
|
||||
throw lastError
|
||||
}
|
||||
|
||||
if (response.ok) return (await response.json()) as T
|
||||
|
||||
if (response.status === 401 && !credentialsInQuery) {
|
||||
// Host may be stripping the Authorization header; the documented
|
||||
// fallback is credentials in the query string (HTTPS enforced upstream).
|
||||
credentialsInQuery = true
|
||||
lastError = await parseError(response)
|
||||
continue
|
||||
}
|
||||
if (RETRYABLE_STATUS.has(response.status) && attempt < RETRY_DELAYS_MS.length) {
|
||||
lastError = await parseError(response)
|
||||
await sleep(RETRY_DELAYS_MS[attempt])
|
||||
continue
|
||||
}
|
||||
throw await parseError(response)
|
||||
}
|
||||
throw lastError instanceof Error
|
||||
? lastError
|
||||
: new WooCommerceApiError('WooCommerce request failed', 0)
|
||||
}
|
||||
|
||||
export interface ListOrdersOptions {
|
||||
/** ISO timestamp; interpreted as UTC (dates_are_gmt is always sent). */
|
||||
modifiedAfter: string
|
||||
page: number
|
||||
}
|
||||
|
||||
/**
|
||||
* One page of orders modified after the cursor, oldest-modified first so the
|
||||
* caller's cursor advances chronologically. Requires WooCommerce 5.8+
|
||||
* (modified_after); older stores fail with a woocommerce_rest_invalid_param
|
||||
* style error surfaced to the connection's error state.
|
||||
*/
|
||||
export async function listOrdersPage(
|
||||
creds: WooCredentials,
|
||||
options: ListOrdersOptions,
|
||||
): Promise<WooOrder[]> {
|
||||
return wcGet<WooOrder[]>(creds, '/orders', {
|
||||
modified_after: options.modifiedAfter,
|
||||
dates_are_gmt: 'true',
|
||||
status: 'any',
|
||||
orderby: 'modified',
|
||||
order: 'asc',
|
||||
per_page: String(WC_PAGE_SIZE),
|
||||
page: String(options.page),
|
||||
})
|
||||
}
|
||||
|
||||
/** Hard cap on refund pages per order; a real order never approaches this. */
|
||||
const MAX_REFUND_PAGES = 10
|
||||
|
||||
/**
|
||||
* All refunds of one order. Terminates on an EMPTY batch, not a short one
|
||||
* (hosts may cap per_page below our request, same as the order pagination),
|
||||
* dedupes by id so a host that ignores `page` cannot loop forever, and caps
|
||||
* total pages as a final backstop.
|
||||
*/
|
||||
export async function listOrderRefunds(
|
||||
creds: WooCredentials,
|
||||
orderId: number,
|
||||
): Promise<WooRefund[]> {
|
||||
const refunds: WooRefund[] = []
|
||||
const seen = new Set<number>()
|
||||
for (let page = 1; page <= MAX_REFUND_PAGES; page++) {
|
||||
const batch = await wcGet<WooRefund[]>(creds, `/orders/${orderId}/refunds`, {
|
||||
per_page: String(WC_PAGE_SIZE),
|
||||
page: String(page),
|
||||
})
|
||||
if (batch.length === 0) return refunds
|
||||
const fresh = batch.filter((r) => !seen.has(r.id))
|
||||
if (fresh.length === 0) return refunds
|
||||
for (const refund of fresh) seen.add(refund.id)
|
||||
refunds.push(...fresh)
|
||||
}
|
||||
// Cap exhausted with data still flowing: returning the partial list would
|
||||
// let the sync advance its cursor past refunds it never saw. Throwing
|
||||
// routes into the caller's refund-failure path instead (order held, cursor
|
||||
// capped, retried next run).
|
||||
throw new WooCommerceApiError(
|
||||
`Refund pagination cap exceeded for order ${orderId}`,
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify credentials and read store metadata. The one-order probe is the
|
||||
* authoritative credential check (it exercises the read scope the feed
|
||||
* needs); title and settings lookups are best-effort extras.
|
||||
*/
|
||||
export async function testConnectionAndFetchStoreInfo(
|
||||
creds: WooCredentials,
|
||||
): Promise<WooStoreInfo> {
|
||||
await wcGet<unknown[]>(creds, '/orders', { per_page: '1' })
|
||||
|
||||
const info: WooStoreInfo = {
|
||||
name: null,
|
||||
currency: null,
|
||||
prices_include_tax: null,
|
||||
wc_version: null,
|
||||
}
|
||||
|
||||
try {
|
||||
const settings = await wcGet<Array<{ id: string; value: unknown }>>(
|
||||
creds,
|
||||
'/settings/general',
|
||||
)
|
||||
const currency = settings.find((s) => s.id === 'woocommerce_currency')?.value
|
||||
if (typeof currency === 'string' && currency) info.currency = currency.toUpperCase()
|
||||
const pricesIncludeTax = settings.find((s) => s.id === 'woocommerce_prices_include_tax')?.value
|
||||
if (typeof pricesIncludeTax === 'string') info.prices_include_tax = pricesIncludeTax === 'yes'
|
||||
} catch {
|
||||
// Settings need broader permissions on some setups; the feed works without.
|
||||
}
|
||||
|
||||
try {
|
||||
const status = await wcGet<{ environment?: { version?: string } }>(creds, '/system_status')
|
||||
if (status.environment?.version) info.wc_version = status.environment.version
|
||||
} catch {
|
||||
// system_status is admin-capability data and often blocked; optional.
|
||||
}
|
||||
|
||||
try {
|
||||
// The WP REST index is public and carries the site title.
|
||||
const response = await fetch(`${creds.storeUrl}/wp-json/`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
})
|
||||
if (response.ok) {
|
||||
const body = (await response.json()) as { name?: string }
|
||||
if (body.name) info.name = body.name
|
||||
}
|
||||
} catch {
|
||||
// Cosmetic only.
|
||||
}
|
||||
|
||||
return info
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { WooCredentials } from './api-client'
|
||||
import { decryptCredential } from './credentials'
|
||||
import type { WooCommerceConnection } from '../types'
|
||||
|
||||
/**
|
||||
* WooCommerce "Auth Endpoint" handshake helpers.
|
||||
*
|
||||
* The merchant's browser is sent to {store}/wc-auth/v1/authorize; after they
|
||||
* approve, WooCommerce POSTs the generated consumer key/secret server-to-
|
||||
* server to our callback_url and redirects the browser to return_url. Our
|
||||
* oauth_state UUID rides in the handshake's user_id parameter and comes back
|
||||
* in both places, tying callback and return to the pending connection row.
|
||||
*
|
||||
* There is no signature on the callback POST, so possession of the
|
||||
* single-use state is the CSRF defense, and authenticity is proven by
|
||||
* probing the STORED store_url with the received keys before activation: a
|
||||
* forged POST would need working read credentials for the exact store the
|
||||
* user asked to connect.
|
||||
*/
|
||||
|
||||
const APP_NAME = 'Accounted'
|
||||
|
||||
export function buildAuthorizeUrl(storeUrl: string, state: string): string {
|
||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL
|
||||
if (!baseUrl) throw new Error('NEXT_PUBLIC_APP_URL is not configured')
|
||||
const params = new URLSearchParams({
|
||||
app_name: APP_NAME,
|
||||
// Read-only: the feed never writes to the store.
|
||||
scope: 'read',
|
||||
user_id: state,
|
||||
return_url: `${baseUrl}/api/extensions/woocommerce/return`,
|
||||
callback_url: `${baseUrl}/api/extensions/woocommerce/callback`,
|
||||
})
|
||||
return `${storeUrl}/wc-auth/v1/authorize?${params.toString()}`
|
||||
}
|
||||
|
||||
/** Decrypted API credentials for an active connection. */
|
||||
export function credentialsOf(
|
||||
connection: Pick<
|
||||
WooCommerceConnection,
|
||||
'store_url' | 'consumer_key_encrypted' | 'consumer_secret_encrypted'
|
||||
>,
|
||||
): WooCredentials {
|
||||
if (!connection.consumer_key_encrypted || !connection.consumer_secret_encrypted) {
|
||||
throw new Error('Connection has no stored credentials')
|
||||
}
|
||||
return {
|
||||
storeUrl: connection.store_url,
|
||||
consumerKey: decryptCredential(connection.consumer_key_encrypted),
|
||||
consumerSecret: decryptCredential(connection.consumer_secret_encrypted),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import crypto from 'crypto'
|
||||
|
||||
/**
|
||||
* At-rest encryption for WooCommerce consumer key/secret.
|
||||
*
|
||||
* AES-256-GCM with a dedicated env key, mirroring the Skatteverket token
|
||||
* store (extensions/general/skatteverket/lib/token-store.ts): 12-byte IV,
|
||||
* 16-byte auth tag, layout iv|tag|ciphertext, base64url encoded. The key is
|
||||
* deployment-wide (not per-tenant); what makes rows useless off-server is
|
||||
* that WOOCOMMERCE_CREDENTIALS_ENCRYPTION_KEY never leaves the environment.
|
||||
*/
|
||||
|
||||
const ALGORITHM = 'aes-256-gcm'
|
||||
|
||||
/** Whether the integration is configured on this deployment. */
|
||||
export function isWooCommerceConfigured(): boolean {
|
||||
return Boolean(process.env.WOOCOMMERCE_CREDENTIALS_ENCRYPTION_KEY)
|
||||
}
|
||||
|
||||
function getEncryptionKey(): Buffer {
|
||||
const key = process.env.WOOCOMMERCE_CREDENTIALS_ENCRYPTION_KEY
|
||||
if (!key) throw new Error('WOOCOMMERCE_CREDENTIALS_ENCRYPTION_KEY is required')
|
||||
return crypto.createHash('sha256').update(key).digest()
|
||||
}
|
||||
|
||||
export function encryptCredential(plaintext: string): string {
|
||||
const key = getEncryptionKey()
|
||||
const iv = crypto.randomBytes(12)
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, key, iv)
|
||||
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
|
||||
const tag = cipher.getAuthTag()
|
||||
return Buffer.concat([iv, tag, encrypted]).toString('base64url')
|
||||
}
|
||||
|
||||
export function decryptCredential(ciphertext: string): string {
|
||||
const key = getEncryptionKey()
|
||||
const combined = Buffer.from(ciphertext, 'base64url')
|
||||
const iv = combined.subarray(0, 12)
|
||||
const tag = combined.subarray(12, 28)
|
||||
const encrypted = combined.subarray(28)
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv)
|
||||
decipher.setAuthTag(tag)
|
||||
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8')
|
||||
}
|
||||
@@ -0,0 +1,593 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { ingestTransactions } from '@/lib/transactions/ingest'
|
||||
import { ensureManualCashAccount } from '@/lib/cash-accounts/service'
|
||||
import { syncMappedAccounts } from '@/lib/import/account-sync'
|
||||
import { createLogger, type Logger } from '@/lib/logger'
|
||||
import type { RawTransaction } from '@/types'
|
||||
import {
|
||||
listOrdersPage,
|
||||
listOrderRefunds,
|
||||
isRevokedCredentialsError,
|
||||
WC_PAGE_SIZE,
|
||||
type WooCredentials,
|
||||
} from './api-client'
|
||||
import { credentialsOf } from './connect'
|
||||
import type { WooCommerceConnection, WooOrder, WooRefund } from '../types'
|
||||
|
||||
const defaultLog = createLogger('woocommerce/order-sync')
|
||||
|
||||
/**
|
||||
* WooCommerce order sync: the store's paid orders and refunds treated as a
|
||||
* bank-style feed.
|
||||
*
|
||||
* The store becomes a cash account on ledger 1680 (Andra kortfristiga
|
||||
* fordringar: money the payment gateways owe the merchant), and orders land
|
||||
* in the transactions inbox exactly like PSD2 bank rows: deduped on
|
||||
* external_id, bound to the cash account so booking settles against 1680, and
|
||||
* categorized/booked by the user through the normal flows. Nothing here
|
||||
* auto-books. 1686 (Fordringar för kontokort och kuponger) would be the
|
||||
* closest BAS account but is owned by the Stripe feed, and cash_accounts
|
||||
* enforces one account per ledger per company.
|
||||
*
|
||||
* Row model: a paid order produces one positive row for its gross total; each
|
||||
* refund produces one negative row. Payment-processor fees never appear:
|
||||
* core wc/v3 does not expose them (they belong to the gateway, e.g. the
|
||||
* Stripe feed for Stripe-gateway stores). order.transaction_id rides along as
|
||||
* the row reference for later gateway-side reconciliation.
|
||||
*
|
||||
* Pagination is CURSOR-based, not offset-based: each request asks for the
|
||||
* oldest orders with date_modified strictly after the current cursor
|
||||
* (orderby=modified asc, page=1), and the cursor advances to the last row of
|
||||
* each processed page. Offset pages over a fixed window would silently skip
|
||||
* rows whenever an already-fetched order is modified mid-run (it re-sorts to
|
||||
* the end and shifts every later row one index down); with a moving cursor a
|
||||
* mid-run modification simply re-surfaces the order later in the same run.
|
||||
* The one case that still needs offsets is a run of >WC_PAGE_SIZE orders
|
||||
* sharing the same date_modified second (bulk edits, migrations): those are
|
||||
* paged through with an increasing page number at a FIXED cursor, because
|
||||
* modified_after is strictly exclusive and advancing it would skip the rest
|
||||
* of the tie. Ties that span a page boundary after cursor advancement are
|
||||
* picked up by the next run's overlap re-poll.
|
||||
*
|
||||
* Cursor: woocommerce_connections.last_order_synced_at, re-polled with a 24h
|
||||
* overlap. It never advances past failed work: a page with refund-fetch
|
||||
* failures, ingest errors, or deadline-skipped refunds caps the persisted
|
||||
* cursor just below the earliest affected order's date_modified, so the next
|
||||
* run re-lists exactly the orders whose rows are incomplete (re-seen complete
|
||||
* rows collide on (company_id, external_id) and are skipped). First run
|
||||
* fetches BACKFILL_DAYS back.
|
||||
*
|
||||
* Lock-date guard: modified_after selects on date_modified, but rows are
|
||||
* dated by date_paid / refund date_created, which can be arbitrarily older
|
||||
* (a refund or edit bumps date_modified long after payment). Rows dated on or
|
||||
* before company_settings.bookkeeping_locked_through are therefore dropped at
|
||||
* map time on EVERY run: the enforce_company_lock_date trigger makes them
|
||||
* permanently unbookable, and feed rows are undeletable by design, so
|
||||
* importing them would create permanent inbox noise. Dropped rows are counted
|
||||
* in skipped_locked and logged.
|
||||
*/
|
||||
|
||||
/** BAS ledger account for the WooCommerce store cash account. */
|
||||
export const WOOCOMMERCE_LEDGER_ACCOUNT = '1680'
|
||||
/** BAS 2026 name for 1680; used when creating the chart account. */
|
||||
const WOOCOMMERCE_LEDGER_ACCOUNT_NAME = 'Andra kortfristiga fordringar'
|
||||
/** transactions.import_source for WooCommerce feed rows. */
|
||||
export const WOOCOMMERCE_IMPORT_SOURCE = 'woocommerce'
|
||||
/** First-run backfill window (matches the Enable Banking convention). */
|
||||
export const BACKFILL_DAYS = 90
|
||||
/** Cursor re-poll overlap; external_id dedup makes duplicates no-ops. */
|
||||
const CURSOR_OVERLAP_MS = 24 * 60 * 60 * 1000
|
||||
/**
|
||||
* Safety cap on orders per run (matches the Stripe feed's MAX_TXNS_PER_RUN).
|
||||
* The real bound is the caller's deadline; hitting this cap is logged loudly
|
||||
* because a silent cap reads as "covered everything" when it did not. The
|
||||
* cursor resumes where a truncated run stopped.
|
||||
*/
|
||||
const MAX_ORDERS_PER_RUN = 10_000
|
||||
|
||||
/**
|
||||
* ⚠️ STORED-KEY FORMATS. These are persisted to transactions.external_id and
|
||||
* dedup compares stored ids byte-for-byte, exactly like the Stripe and Enable
|
||||
* Banking schemes. Changing a template silently orphans every prior row and
|
||||
* re-imports the whole feed on the next sync. Locked by the frozen-format
|
||||
* test in order-sync.test.ts; any change MUST ship a coordinated backfill.
|
||||
*
|
||||
* The scope is the store's normalized host(+path), NOT the connection id, so
|
||||
* a disconnect/reconnect of the same store keeps every previously imported
|
||||
* row deduped.
|
||||
*/
|
||||
export function wooStoreScope(storeUrl: string): string {
|
||||
return storeUrl.replace(/^https:\/\//, '')
|
||||
}
|
||||
|
||||
export function wooOrderExternalId(storeScope: string, orderId: number): string {
|
||||
return `woo_${storeScope}_order_${orderId}`
|
||||
}
|
||||
|
||||
export function wooRefundExternalId(storeScope: string, refundId: number): string {
|
||||
return `woo_${storeScope}_refund_${refundId}`
|
||||
}
|
||||
|
||||
export interface WooCommerceSyncSummary {
|
||||
/** Orders listed from the store (all statuses in the window). */
|
||||
fetched: number
|
||||
/** Refund objects fetched for refunded orders in the window. */
|
||||
refundsFetched: number
|
||||
/** New inbox rows inserted. */
|
||||
imported: number
|
||||
/** Rows skipped by external_id / content dedup. */
|
||||
duplicates: number
|
||||
/** Rows dropped because they are dated on/before the bookkeeping lock. */
|
||||
skippedLocked: number
|
||||
errors: number
|
||||
/** Set when the caller's time budget ran out before all pages processed. */
|
||||
deadlineReached?: boolean
|
||||
/** Set when the store reported the credentials revoked (401/403). */
|
||||
revoked?: boolean
|
||||
}
|
||||
|
||||
const round = (n: number) => Math.round(n * 100) / 100
|
||||
|
||||
/**
|
||||
* Money fields arrive as strings; unparseable input returns null so callers
|
||||
* can tell a corrupt total (counted + logged in buildPageRows) from a
|
||||
* legitimate zero (silently skipped).
|
||||
*/
|
||||
function parseAmount(value: string): number | null {
|
||||
const parsed = Number.parseFloat(value)
|
||||
return Number.isFinite(parsed) ? round(parsed) : null
|
||||
}
|
||||
|
||||
/** Whether a qualifying order's total cannot be read as money. */
|
||||
export function orderAmountUnparseable(order: Pick<WooOrder, 'total'>): boolean {
|
||||
return parseAmount(order.total) === null
|
||||
}
|
||||
|
||||
/** Date part of a wc/v3 _gmt timestamp ("2026-08-01T12:34:56", no zone suffix). */
|
||||
function isoDateOfGmt(timestamp: string): string {
|
||||
return timestamp.split('T')[0]
|
||||
}
|
||||
|
||||
/** wc/v3 _gmt timestamps lack a zone suffix; brand them UTC for timestamptz. */
|
||||
function gmtToIso(timestamp: string): string {
|
||||
return timestamp.endsWith('Z') ? timestamp : `${timestamp}Z`
|
||||
}
|
||||
|
||||
function gmtToMs(timestamp: string): number {
|
||||
return Date.parse(gmtToIso(timestamp))
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an order belongs in the feed: it must have been paid (date_paid is
|
||||
* the revenue signal; pending/failed/cancelled-before-payment orders never
|
||||
* carry one) and not be trashed. Status 'refunded' stays IN: a fully refunded
|
||||
* order was still paid, and its refunds land as separate negative rows so the
|
||||
* pair nets to zero instead of the gross silently disappearing.
|
||||
*/
|
||||
export function orderQualifies(order: Pick<WooOrder, 'status' | 'date_paid_gmt'>): boolean {
|
||||
return Boolean(order.date_paid_gmt) && order.status !== 'trash'
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a paid order to its gross feed row. Dates use date_paid (when the money
|
||||
* event happened), not date_created: booked entries, invoice matching, and
|
||||
* month boundaries all want the payment date. Descriptions are deterministic
|
||||
* from immutable data (order numbers never change) because the content-dedup
|
||||
* bridge keys off them.
|
||||
*/
|
||||
export function mapOrder(storeScope: string, order: WooOrder): RawTransaction[] {
|
||||
if (!orderQualifies(order)) return []
|
||||
const amount = parseAmount(order.total)
|
||||
if (amount === null || amount === 0) return []
|
||||
return [
|
||||
{
|
||||
date: isoDateOfGmt(order.date_paid_gmt!),
|
||||
description: `WooCommerce-order #${order.number}`,
|
||||
amount,
|
||||
currency: order.currency.toUpperCase(),
|
||||
external_id: wooOrderExternalId(storeScope, order.id),
|
||||
import_source: WOOCOMMERCE_IMPORT_SOURCE,
|
||||
reference: order.transaction_id || null,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/** Map one refund of a paid order to its negative feed row. */
|
||||
export function mapRefund(
|
||||
storeScope: string,
|
||||
order: Pick<WooOrder, 'number' | 'currency'>,
|
||||
refund: WooRefund,
|
||||
): RawTransaction[] {
|
||||
const amount = parseAmount(refund.amount)
|
||||
if (amount === null || amount === 0) return []
|
||||
return [
|
||||
{
|
||||
date: isoDateOfGmt(refund.date_created_gmt),
|
||||
description: `WooCommerce-återbetalning order #${order.number}`,
|
||||
amount: -amount,
|
||||
currency: order.currency.toUpperCase(),
|
||||
external_id: wooRefundExternalId(storeScope, refund.id),
|
||||
import_source: WOOCOMMERCE_IMPORT_SOURCE,
|
||||
reference: null,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/** Company lock date (YYYY-MM-DD) or null; read once per run. */
|
||||
async function fetchLockThrough(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
): Promise<string | null> {
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('bookkeeping_locked_through')
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
return (
|
||||
(settings as { bookkeeping_locked_through?: string | null } | null)
|
||||
?.bookkeeping_locked_through ?? null
|
||||
)
|
||||
}
|
||||
|
||||
/** Whether a feed-row date is on/before the lock date (=> never bookable). */
|
||||
export function rowBehindLock(rowDate: string, lockThrough: string | null): boolean {
|
||||
return lockThrough !== null && rowDate <= lockThrough
|
||||
}
|
||||
|
||||
/**
|
||||
* Window start (ISO, UTC) for the first modified_after list call. With a
|
||||
* cursor: cursor minus the 24h overlap. First run: BACKFILL_DAYS back. (The
|
||||
* lock date no longer floors the window: it selects on date_modified while
|
||||
* rows are dated by date_paid, so the real guard is rowBehindLock at map
|
||||
* time, applied on every run.)
|
||||
*/
|
||||
function resolveWindowStartIso(connection: WooCommerceConnection): string {
|
||||
if (connection.last_order_synced_at) {
|
||||
const cursorMs = Date.parse(connection.last_order_synced_at)
|
||||
return new Date(Math.max(0, cursorMs - CURSOR_OVERLAP_MS)).toISOString()
|
||||
}
|
||||
return new Date(Date.now() - BACKFILL_DAYS * 86_400_000).toISOString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Make sure the store cash account exists (ledger 1680, source manual so a
|
||||
* later remap/promotion follows the normal cash-account rules) and, on the
|
||||
* first run, that 1680 exists in the chart of accounts: the booking dialog
|
||||
* and AccountPicker only list chart accounts.
|
||||
*
|
||||
* Currency comes from the store settings read at connect time, falling back
|
||||
* to the first fetched order's real currency (settings/general is blocked on
|
||||
* some hardened stores, and guessing SEK for an EUR store would poison the
|
||||
* account). A conflict with an existing 1680 cash account throws; the caller
|
||||
* surfaces that on the connection so the panel shows why nothing syncs.
|
||||
*/
|
||||
async function ensureStoreAccount(
|
||||
supabase: SupabaseClient,
|
||||
connection: WooCommerceConnection,
|
||||
fallbackCurrency: string | undefined,
|
||||
firstRun: boolean,
|
||||
log: Logger,
|
||||
): Promise<void> {
|
||||
const currency =
|
||||
connection.currency?.toUpperCase() || fallbackCurrency?.toUpperCase() || 'SEK'
|
||||
try {
|
||||
await ensureManualCashAccount(
|
||||
supabase,
|
||||
connection.company_id,
|
||||
WOOCOMMERCE_LEDGER_ACCOUNT,
|
||||
currency,
|
||||
'WooCommerce-saldo',
|
||||
)
|
||||
} catch (accountError) {
|
||||
// Typically a currency conflict with an existing 1680 cash account. Made
|
||||
// visible on the connection: without this the panel shows a healthy
|
||||
// "Ansluten" store that silently never syncs.
|
||||
await supabase
|
||||
.from('woocommerce_connections')
|
||||
.update({
|
||||
error_message:
|
||||
'Kassakontot för butiken (1680) kunde inte skapas. Kontrollera att befintligt konto 1680 har samma valuta som butiken.',
|
||||
})
|
||||
.eq('id', connection.id)
|
||||
throw accountError
|
||||
}
|
||||
if (firstRun) {
|
||||
const sync = await syncMappedAccounts(
|
||||
supabase,
|
||||
connection.company_id,
|
||||
connection.user_id,
|
||||
[
|
||||
{
|
||||
sourceAccount: WOOCOMMERCE_LEDGER_ACCOUNT,
|
||||
sourceName: WOOCOMMERCE_LEDGER_ACCOUNT_NAME,
|
||||
targetAccount: WOOCOMMERCE_LEDGER_ACCOUNT,
|
||||
targetName: WOOCOMMERCE_LEDGER_ACCOUNT_NAME,
|
||||
confidence: 1,
|
||||
matchType: 'exact',
|
||||
isOverride: false,
|
||||
},
|
||||
],
|
||||
false,
|
||||
)
|
||||
if (sync.error) {
|
||||
// Rows still import and bind to the cash account; only the chart
|
||||
// listing is affected (the account can be added manually), so this is
|
||||
// deliberately non-fatal.
|
||||
log.warn('chart sync for 1680 failed', {
|
||||
companyId: connection.company_id,
|
||||
error: sync.error,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface PageRowsOutcome {
|
||||
rows: RawTransaction[]
|
||||
/**
|
||||
* date_modified (ms) of every order whose refund rows are incomplete this
|
||||
* run (fetch failed or skipped on deadline). The cursor must not advance
|
||||
* past these: the next run has to re-list them.
|
||||
*/
|
||||
incompleteModifiedMs: number[]
|
||||
hitDeadline: boolean
|
||||
}
|
||||
|
||||
/** Rows for one page of orders: gross rows plus refund rows where present. */
|
||||
async function buildPageRows(
|
||||
creds: WooCredentials,
|
||||
storeScope: string,
|
||||
orders: WooOrder[],
|
||||
lockThrough: string | null,
|
||||
summary: WooCommerceSyncSummary,
|
||||
log: Logger,
|
||||
deadlineMs?: number,
|
||||
): Promise<PageRowsOutcome> {
|
||||
const outcome: PageRowsOutcome = { rows: [], incompleteModifiedMs: [], hitDeadline: false }
|
||||
|
||||
const push = (mapped: RawTransaction[]) => {
|
||||
for (const row of mapped) {
|
||||
if (rowBehindLock(row.date, lockThrough)) {
|
||||
summary.skippedLocked += 1
|
||||
continue
|
||||
}
|
||||
outcome.rows.push(row)
|
||||
}
|
||||
}
|
||||
|
||||
for (const order of orders) {
|
||||
// A corrupt total is counted and logged, never silently identical to a
|
||||
// zero-total order. Deliberately NOT held via the cursor: a permanently
|
||||
// corrupt total would stall the whole feed forever, where a skipped row
|
||||
// plus a loud error can be followed up.
|
||||
if (orderQualifies(order) && orderAmountUnparseable(order)) {
|
||||
summary.errors += 1
|
||||
log.warn('unparseable order total; row skipped', {
|
||||
orderId: order.id,
|
||||
total: order.total,
|
||||
})
|
||||
}
|
||||
push(mapOrder(storeScope, order))
|
||||
// Refunds only exist for qualifying (paid) orders: a refund row without
|
||||
// its gross counterpart would be an unexplainable negative in the inbox.
|
||||
if (!orderQualifies(order) || order.refunds.length === 0) continue
|
||||
|
||||
// Refund fetches are one request per refunded order against a slow host;
|
||||
// without this check a single mass-refund page could blow through the
|
||||
// function's maxDuration and the cursor would never persist.
|
||||
if (outcome.hitDeadline || (deadlineMs !== undefined && Date.now() >= deadlineMs)) {
|
||||
outcome.hitDeadline = true
|
||||
outcome.incompleteModifiedMs.push(gmtToMs(order.date_modified_gmt))
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const refunds = await listOrderRefunds(creds, order.id)
|
||||
summary.refundsFetched += refunds.length
|
||||
for (const refund of refunds) {
|
||||
if (parseAmount(refund.amount) === null) {
|
||||
summary.errors += 1
|
||||
log.warn('unparseable refund amount; row skipped', {
|
||||
orderId: order.id,
|
||||
refundId: refund.id,
|
||||
amount: refund.amount,
|
||||
})
|
||||
}
|
||||
push(mapRefund(storeScope, order, refund))
|
||||
}
|
||||
} catch (refundError) {
|
||||
// The order row still imports; the cursor is capped below this order's
|
||||
// date_modified so the next run re-lists it and retries the refunds.
|
||||
summary.errors += 1
|
||||
outcome.incompleteModifiedMs.push(gmtToMs(order.date_modified_gmt))
|
||||
log.warn('refund fetch failed; order held for retry next run', {
|
||||
orderId: order.id,
|
||||
message: refundError instanceof Error ? refundError.message : String(refundError),
|
||||
})
|
||||
}
|
||||
}
|
||||
return outcome
|
||||
}
|
||||
|
||||
export async function syncWooCommerceOrders(
|
||||
supabase: SupabaseClient,
|
||||
connection: WooCommerceConnection,
|
||||
log: Logger = defaultLog,
|
||||
/**
|
||||
* Absolute deadline (epoch ms) from the caller's time budget. Enforced
|
||||
* between pages AND between refund fetches inside a page: the cursor
|
||||
* advances only over fully-processed work, so the next run resumes exactly
|
||||
* where this one stopped.
|
||||
*/
|
||||
deadlineMs?: number,
|
||||
): Promise<WooCommerceSyncSummary> {
|
||||
const summary: WooCommerceSyncSummary = {
|
||||
fetched: 0,
|
||||
refundsFetched: 0,
|
||||
imported: 0,
|
||||
duplicates: 0,
|
||||
skippedLocked: 0,
|
||||
errors: 0,
|
||||
}
|
||||
if (
|
||||
connection.status !== 'active' ||
|
||||
!connection.consumer_key_encrypted ||
|
||||
!connection.consumer_secret_encrypted
|
||||
) {
|
||||
return summary
|
||||
}
|
||||
|
||||
const creds = credentialsOf(connection)
|
||||
const storeScope = wooStoreScope(connection.store_url)
|
||||
const firstRun = !connection.last_order_synced_at
|
||||
const lockThrough = await fetchLockThrough(supabase, connection.company_id)
|
||||
|
||||
let modifiedAfter = resolveWindowStartIso(connection)
|
||||
// Offset page within a same-timestamp tie only; 1 whenever the cursor moves.
|
||||
let tiePage = 1
|
||||
let prevCursorMs = connection.last_order_synced_at
|
||||
? Date.parse(connection.last_order_synced_at)
|
||||
: 0
|
||||
// Earliest incomplete work this run; the persisted cursor never passes it.
|
||||
let failureFloorMs = Number.POSITIVE_INFINITY
|
||||
let accountEnsured = false
|
||||
|
||||
try {
|
||||
for (;;) {
|
||||
if (deadlineMs !== undefined && Date.now() >= deadlineMs) {
|
||||
summary.deadlineReached = true
|
||||
log.info('time budget exhausted; stopping order sync', {
|
||||
connectionId: connection.id,
|
||||
processed: summary.imported + summary.duplicates,
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
const orders = await listOrdersPage(creds, { modifiedAfter, page: tiePage })
|
||||
// Termination is an EMPTY page, not a short one: hosts and security
|
||||
// plugins may cap per_page below our request, and treating a short page
|
||||
// as the end would strand the cursor at the first page forever.
|
||||
if (orders.length === 0) break
|
||||
summary.fetched += orders.length
|
||||
|
||||
// Deferred until the window is known non-empty so a quiet store costs
|
||||
// one API call and zero DB writes; also gives us a real order currency
|
||||
// as the fallback for stores whose settings are unreadable.
|
||||
if (!accountEnsured) {
|
||||
await ensureStoreAccount(supabase, connection, orders[0].currency, firstRun, log)
|
||||
accountEnsured = true
|
||||
}
|
||||
|
||||
const page = await buildPageRows(
|
||||
creds,
|
||||
storeScope,
|
||||
orders,
|
||||
lockThrough,
|
||||
summary,
|
||||
log,
|
||||
deadlineMs,
|
||||
)
|
||||
if (page.hitDeadline) summary.deadlineReached = true
|
||||
|
||||
const firstMs = gmtToMs(orders[0].date_modified_gmt)
|
||||
const lastMs = gmtToMs(orders[orders.length - 1].date_modified_gmt)
|
||||
|
||||
if (page.rows.length > 0) {
|
||||
// Auto-categorization is skipped on purpose: booking WooCommerce
|
||||
// money is a human decision in the inbox (feed-only doctrine, same
|
||||
// as the Stripe feed). Invoice matching still runs (suggestions
|
||||
// only), and FX enrichment covers non-SEK stores.
|
||||
const result = await ingestTransactions(
|
||||
supabase,
|
||||
connection.company_id,
|
||||
connection.user_id,
|
||||
page.rows,
|
||||
{ settlementAccount: WOOCOMMERCE_LEDGER_ACCOUNT, skipAutoCategorization: true },
|
||||
)
|
||||
summary.imported += result.imported
|
||||
summary.duplicates += result.duplicates
|
||||
summary.errors += result.errors
|
||||
if (result.errors > 0) {
|
||||
// Failed inserts are dropped inside ingest; hold the cursor below
|
||||
// this page so the next run re-lists and retries it rather than
|
||||
// turning a transient DB error into permanently missing rows.
|
||||
failureFloorMs = Math.min(failureFloorMs, firstMs - 1000)
|
||||
}
|
||||
}
|
||||
for (const ms of page.incompleteModifiedMs) {
|
||||
failureFloorMs = Math.min(failureFloorMs, ms - 1000)
|
||||
}
|
||||
|
||||
// Persist the cursor after each page: monotonic (never regresses below
|
||||
// the pre-run cursor) and capped by the failure floor. error_message is
|
||||
// cleared on progress so a resolved incident stops showing in the panel.
|
||||
const candidateMs = Math.min(lastMs, failureFloorMs)
|
||||
if (candidateMs > prevCursorMs) {
|
||||
const cursorIso = new Date(candidateMs).toISOString()
|
||||
await supabase
|
||||
.from('woocommerce_connections')
|
||||
.update({ last_order_synced_at: cursorIso, error_message: null })
|
||||
.eq('id', connection.id)
|
||||
connection.last_order_synced_at = cursorIso
|
||||
prevCursorMs = candidateMs
|
||||
}
|
||||
|
||||
if (summary.deadlineReached) break
|
||||
|
||||
// Advance. A full page entirely inside one date_modified second cannot
|
||||
// move the cursor (modified_after is strictly exclusive): page through
|
||||
// the tie by offset. Otherwise move the cursor to the page's last row;
|
||||
// tie rows cut off at the boundary are recovered by the next run's
|
||||
// overlap re-poll.
|
||||
if (orders.length >= WC_PAGE_SIZE && lastMs === firstMs) {
|
||||
tiePage += 1
|
||||
} else {
|
||||
modifiedAfter = new Date(lastMs).toISOString()
|
||||
tiePage = 1
|
||||
}
|
||||
|
||||
if (summary.fetched >= MAX_ORDERS_PER_RUN) {
|
||||
log.warn('order cap reached; remaining orders resume next run', {
|
||||
connectionId: connection.id,
|
||||
cap: MAX_ORDERS_PER_RUN,
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (isRevokedCredentialsError(err)) {
|
||||
// The key was deleted or demoted in wp-admin: flip the connection so
|
||||
// the UI offers a reconnect instead of the cron retrying forever.
|
||||
summary.revoked = true
|
||||
await supabase
|
||||
.from('woocommerce_connections')
|
||||
.update({
|
||||
status: 'revoked',
|
||||
error_message: 'Butiken avvisade API-nyckeln. Anslut butiken igen.',
|
||||
// The store already rejected these; keeping decryptable dead
|
||||
// credentials would be pure data retention (same as /disconnect).
|
||||
consumer_key_encrypted: null,
|
||||
consumer_secret_encrypted: null,
|
||||
disconnected_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', connection.id)
|
||||
.eq('status', 'active')
|
||||
log.warn('credentials revoked upstream; connection flipped to revoked', {
|
||||
connectionId: connection.id,
|
||||
})
|
||||
return summary
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
if (summary.skippedLocked > 0) {
|
||||
log.info('rows behind the bookkeeping lock were skipped', {
|
||||
connectionId: connection.id,
|
||||
skippedLocked: summary.skippedLocked,
|
||||
})
|
||||
}
|
||||
log.info('woocommerce order sync done', {
|
||||
connectionId: connection.id,
|
||||
...summary,
|
||||
})
|
||||
return summary
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* The WooCommerce settings panel's server calls, each classified into exactly
|
||||
* one outcome. Same doctrine as the Stripe panel's settings-actions (see the
|
||||
* doc block there): never throw, one toast sentence per click, and the
|
||||
* classification lives outside the component because component logic has no
|
||||
* tests in this repo.
|
||||
*/
|
||||
|
||||
import { fetchWithTimeout, isTimeoutError } from '@/lib/http/fetch-with-timeout'
|
||||
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
|
||||
import type { ActionFailure } from '@/lib/browser/action-failure'
|
||||
|
||||
/**
|
||||
* Deadline for the quick calls (status, toggle, disconnect). Connect and
|
||||
* manual-connect probe the merchant's WooCommerce host (often a slow shared
|
||||
* PHP box, with retries), so they get a longer one.
|
||||
*/
|
||||
export const WOO_ACTION_TIMEOUT_MS = 15_000
|
||||
export const WOO_CONNECT_TIMEOUT_MS = 120_000
|
||||
|
||||
/**
|
||||
* Deadline for "Synka nu": the route's own ceiling (maxDuration 300 on the
|
||||
* extension dispatcher) plus margin, same reasoning as the Stripe panel. A
|
||||
* first sync backfills 90 days from a slow host and legitimately takes
|
||||
* minutes; the server keeps working and advances the cursor even if we
|
||||
* aborted, so aborting early would misreport a sync that landed.
|
||||
*/
|
||||
export const WOO_SYNC_TIMEOUT_MS = 310_000
|
||||
|
||||
export type WooRequestResult<T> =
|
||||
/** 2xx. `data` is null when the body was not readable JSON. */
|
||||
| { ok: true; data: T | null }
|
||||
| ActionFailure
|
||||
|
||||
export interface WooRequestOptions {
|
||||
url: string
|
||||
method?: 'GET' | 'POST' | 'DELETE'
|
||||
body?: unknown
|
||||
locale?: ErrorLocale
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
|
||||
/** The one sentence for a non-2xx; route copy wins over the generic map. */
|
||||
export function serverErrorMessage(
|
||||
body: unknown,
|
||||
status: number,
|
||||
locale: ErrorLocale,
|
||||
): string {
|
||||
if (isRecord(body)) {
|
||||
if (locale === 'en' && typeof body.error_en === 'string' && body.error_en.trim()) {
|
||||
return body.error_en.trim()
|
||||
}
|
||||
if (typeof body.error === 'string' && body.error.trim()) {
|
||||
return body.error.trim()
|
||||
}
|
||||
}
|
||||
return getErrorMessage(body, { statusCode: status, locale })
|
||||
}
|
||||
|
||||
/** Call one of the panel's endpoints and report exactly why it failed. */
|
||||
export async function wooRequest<T>({
|
||||
url,
|
||||
method = 'POST',
|
||||
body,
|
||||
locale = 'sv',
|
||||
timeoutMs = WOO_ACTION_TIMEOUT_MS,
|
||||
}: WooRequestOptions): Promise<WooRequestResult<T>> {
|
||||
try {
|
||||
const res = await fetchWithTimeout(
|
||||
url,
|
||||
body === undefined
|
||||
? { method }
|
||||
: {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
{ timeoutMs, description: `${method} ${url}` },
|
||||
)
|
||||
|
||||
const payload = await res.json().catch(() => null)
|
||||
|
||||
if (!res.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'server',
|
||||
status: res.status,
|
||||
message: serverErrorMessage(payload, res.status, locale),
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, data: payload as T | null }
|
||||
} catch (err) {
|
||||
if (isTimeoutError(err)) return { ok: false, reason: 'timeout' }
|
||||
return { ok: false, reason: 'network', message: getErrorMessage(err, { locale }) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Success body of POST /api/extensions/ext/woocommerce/sync. */
|
||||
export interface WooSyncPayload {
|
||||
success?: boolean
|
||||
/** `WooCommerceSyncSummary` from lib/order-sync.ts, over the wire. */
|
||||
transactions?: {
|
||||
fetched?: number
|
||||
refundsFetched?: number
|
||||
imported?: number
|
||||
duplicates?: number
|
||||
errors?: number
|
||||
revoked?: boolean
|
||||
deadlineReached?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
type SyncCounts = {
|
||||
fetched: number
|
||||
imported: number
|
||||
}
|
||||
|
||||
export type WooSyncSummary =
|
||||
/** The store rejected the credentials; the connection was flipped to revoked. */
|
||||
| { reason: 'revoked' }
|
||||
/** The window genuinely held nothing. A real answer, not a silent success. */
|
||||
| { reason: 'empty' }
|
||||
/**
|
||||
* The time budget ran out with orders still unfetched. Reported before the
|
||||
* count-based outcomes so a truncated run never reads as a complete one;
|
||||
* the cursor persisted, so pressing sync again continues where it stopped.
|
||||
* Carries the error count too: a truncated run can also have failed rows,
|
||||
* and dropping that number would repeat the silent-partial mistake.
|
||||
*/
|
||||
| { reason: 'partial'; values: SyncCounts & { errors: number } }
|
||||
/** Rows landed, and some rows did not. Both halves get said. */
|
||||
| { reason: 'errors'; values: SyncCounts & { errors: number } }
|
||||
/** Rows landed. */
|
||||
| { reason: 'feed'; values: SyncCounts }
|
||||
/** 2xx whose body could not be read: the sync ran, the counts are unknown. */
|
||||
| { reason: 'unknown' }
|
||||
|
||||
/** Turn the sync route's success body into the single sentence the user gets. */
|
||||
export function syncSummary(payload: WooSyncPayload | null): WooSyncSummary {
|
||||
const summary = payload?.transactions
|
||||
if (!summary) return { reason: 'unknown' }
|
||||
if (summary.revoked === true) return { reason: 'revoked' }
|
||||
if (typeof summary.fetched !== 'number') return { reason: 'unknown' }
|
||||
|
||||
const fetched = summary.fetched
|
||||
const imported = typeof summary.imported === 'number' ? summary.imported : 0
|
||||
const errors = typeof summary.errors === 'number' ? summary.errors : 0
|
||||
|
||||
if (summary.deadlineReached === true) {
|
||||
return { reason: 'partial', values: { fetched, imported, errors } }
|
||||
}
|
||||
if (fetched === 0) return { reason: 'empty' }
|
||||
if (errors > 0) return { reason: 'errors', values: { fetched, imported, errors } }
|
||||
return { reason: 'feed', values: { fetched, imported } }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"id": "woocommerce",
|
||||
"sector": "general",
|
||||
"exportName": "woocommerceExtension",
|
||||
"entryPoint": "@/extensions/general/woocommerce",
|
||||
"workspace": null,
|
||||
"requiredEnvVars": ["WOOCOMMERCE_CREDENTIALS_ENCRYPTION_KEY"],
|
||||
"optionalEnvVars": [],
|
||||
"npmDependencies": [],
|
||||
"definition": {
|
||||
"name": "WooCommerce",
|
||||
"category": "import",
|
||||
"icon": "ShoppingCart",
|
||||
"dataPattern": "manual",
|
||||
"hasOwnData": true,
|
||||
"description": "Hämta betalda ordrar och återbetalningar från din WooCommerce-butik till transaktionsinkorgen",
|
||||
"longDescription": "Anslut din WooCommerce-butik så hämtas betalda ordrar och återbetalningar automatiskt varje natt till transaktionsinkorgen, som ett bankflöde för butiken. Inget bokförs automatiskt: du bokför raderna själv precis som vanliga banktransaktioner."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/** Row shape of public.woocommerce_connections. */
|
||||
export interface WooCommerceConnection {
|
||||
id: string
|
||||
company_id: string
|
||||
user_id: string
|
||||
/** Normalized https origin (+ optional subdirectory path), no trailing slash. */
|
||||
store_url: string
|
||||
store_name: string | null
|
||||
consumer_key_encrypted: string | null
|
||||
consumer_secret_encrypted: string | null
|
||||
key_permissions: string | null
|
||||
status: 'pending' | 'active' | 'revoked' | 'error'
|
||||
oauth_state: string | null
|
||||
currency: string | null
|
||||
prices_include_tax: boolean | null
|
||||
wc_version: string | null
|
||||
/** Opt-in: nightly order-feed cron (the manual sync button ignores it). */
|
||||
transaction_sync_enabled: boolean
|
||||
/** Order-polling cursor (max date_modified_gmt processed). */
|
||||
last_order_synced_at: string | null
|
||||
error_message: string | null
|
||||
connected_at: string | null
|
||||
disconnected_at: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/** Status payload returned by GET /api/extensions/ext/woocommerce/status. */
|
||||
export interface WooCommerceStatusResponse {
|
||||
configured: boolean
|
||||
connection: Pick<
|
||||
WooCommerceConnection,
|
||||
| 'id'
|
||||
| 'status'
|
||||
| 'store_url'
|
||||
| 'store_name'
|
||||
| 'currency'
|
||||
| 'error_message'
|
||||
| 'connected_at'
|
||||
| 'transaction_sync_enabled'
|
||||
| 'last_order_synced_at'
|
||||
> | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal wc/v3 order shape consumed by the feed. All money fields are strings
|
||||
* in the order's `currency`; every date has a `_gmt` twin and the feed only
|
||||
* ever reads the `_gmt` variants (store-local dates shift with DST).
|
||||
*/
|
||||
export interface WooOrder {
|
||||
id: number
|
||||
/** Display order number; usually the id, but plugins can renumber. */
|
||||
number: string
|
||||
status: string
|
||||
currency: string
|
||||
/** Grand total actually charged (gross, incl. tax and shipping). */
|
||||
total: string
|
||||
total_tax: string
|
||||
prices_include_tax: boolean
|
||||
date_created_gmt: string
|
||||
date_modified_gmt: string
|
||||
/** Set when payment completed; the feed's inclusion criterion and row date. */
|
||||
date_paid_gmt: string | null
|
||||
payment_method: string
|
||||
payment_method_title: string
|
||||
/** Gateway charge reference; join key for later gateway-side reconciliation. */
|
||||
transaction_id: string
|
||||
/** Summary of refunds against this order; totals are negative strings. */
|
||||
refunds: Array<{ id: number; reason: string; total: string }>
|
||||
}
|
||||
|
||||
/** Minimal wc/v3 order-refund shape (GET /orders/{id}/refunds). */
|
||||
export interface WooRefund {
|
||||
id: number
|
||||
/** Refund amount as a positive string. */
|
||||
amount: string
|
||||
reason: string
|
||||
date_created_gmt: string
|
||||
}
|
||||
|
||||
/** Store metadata read at connect time. */
|
||||
export interface WooStoreInfo {
|
||||
/** WordPress site title from GET {store}/wp-json/ (public, unauthenticated). */
|
||||
name: string | null
|
||||
/** ISO 4217 store currency, when readable. */
|
||||
currency: string | null
|
||||
prices_include_tax: boolean | null
|
||||
wc_version: string | null
|
||||
}
|
||||
@@ -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
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 } }
|
||||
|
||||
@@ -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
@@ -12,4 +12,5 @@ export const ENABLED_EXTENSION_IDS: ReadonlySet<string> = new Set([
|
||||
'document-extraction',
|
||||
'stripe',
|
||||
'whatsapp-inbox',
|
||||
'woocommerce',
|
||||
])
|
||||
|
||||
+2
@@ -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
@@ -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')
|
||||
),
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 * * *"
|
||||
|
||||
Reference in New Issue
Block a user