707d597b2e
* 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>
100 lines
5.1 KiB
SQL
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';
|