Files
accounted/supabase/migrations/20260806170000_woocommerce_connections.sql
Mattsson 707d597b2e 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>
2026-08-06 23:30:00 +02:00

100 lines
5.1 KiB
SQL

-- 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';