feat(woocommerce): store order/refund feed extension (#1442)
* feat(woocommerce): store order/refund feed extension Connect a WooCommerce store via the wc-auth key handshake (manual key fallback) with per-store consumer key/secret AES-256-GCM encrypted at rest, and import paid orders and refunds into the transactions inbox as a bank-style feed on the 1680 cash account. Feed-only: nothing auto-books, gateway fees/payouts are out of scope (core wc/v3 does not expose them). Sync is cursor-paginated on modified_after (offset pages only inside same-second date_modified ties), terminates on an empty page, holds the cursor below failed refund fetches / ingest errors / deadline-skipped work, checks the time budget between refund fetches, and drops rows dated on or before bookkeeping_locked_through on every run. Nightly cron gated on the extension registry + new paid capability woocommerce_sync (backfilled to existing bank_sync grant holders). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(migrations): move woocommerce migrations past main's 20260806090000 origin/main gained 20260806090000_recurring_schedule_interval_months while this branch was in flight; identical version timestamps abort the Supabase apply, so the two new migrations move to 20260806170000/20260806170100. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(woocommerce): resolve CodeRabbit review findings - callback 503s early when WOOCOMMERCE_CREDENTIALS_ENCRYPTION_KEY is unset: encryptCredential would otherwise throw after the probe and strand the pending row without error_message - disconnect and upstream-revoke clear the encrypted consumer key/secret: nothing reads them after revoke and keeping decryptable dead credentials is unnecessary retention - manual sync gets a 240s time budget and the panel reports a truncated run as 'partial, sync again' instead of a normal completion - listOrderRefunds terminates on an empty batch (hosts may cap per_page), dedupes by id against hosts that ignore page, and caps total pages - unparseable money strings count as errors and log instead of being silently identical to a zero total - pg test uses per-run unique store URLs so committed rows cannot hit the store_url partial unique index across pg-real runs Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(woocommerce): resolve CodeRabbit cycle-2 findings - listOrderRefunds throws when the page cap is exhausted with data still flowing, instead of returning a silently partial list the sync cursor would advance past; the error routes into the existing held-cursor refund-retry path - partial sync results keep the row-error count, and the partial toast string surfaces it (ICU plural, hidden at zero) in both locales Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: retrigger CI after dropped push event Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
cd344b6dbb
commit
707d597b2e
@@ -0,0 +1,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;
|
||||
Reference in New Issue
Block a user