feat(woocommerce): store order/refund feed extension (#1442)

* feat(woocommerce): store order/refund feed extension

Connect a WooCommerce store via the wc-auth key handshake (manual key
fallback) with per-store consumer key/secret AES-256-GCM encrypted at rest,
and import paid orders and refunds into the transactions inbox as a
bank-style feed on the 1680 cash account. Feed-only: nothing auto-books,
gateway fees/payouts are out of scope (core wc/v3 does not expose them).

Sync is cursor-paginated on modified_after (offset pages only inside
same-second date_modified ties), terminates on an empty page, holds the
cursor below failed refund fetches / ingest errors / deadline-skipped work,
checks the time budget between refund fetches, and drops rows dated on or
before bookkeeping_locked_through on every run. Nightly cron gated on the
extension registry + new paid capability woocommerce_sync (backfilled to
existing bank_sync grant holders).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(migrations): move woocommerce migrations past main's 20260806090000

origin/main gained 20260806090000_recurring_schedule_interval_months while
this branch was in flight; identical version timestamps abort the Supabase
apply, so the two new migrations move to 20260806170000/20260806170100.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(woocommerce): resolve CodeRabbit review findings

- callback 503s early when WOOCOMMERCE_CREDENTIALS_ENCRYPTION_KEY is
  unset: encryptCredential would otherwise throw after the probe and
  strand the pending row without error_message
- disconnect and upstream-revoke clear the encrypted consumer key/secret:
  nothing reads them after revoke and keeping decryptable dead
  credentials is unnecessary retention
- manual sync gets a 240s time budget and the panel reports a truncated
  run as 'partial, sync again' instead of a normal completion
- listOrderRefunds terminates on an empty batch (hosts may cap per_page),
  dedupes by id against hosts that ignore page, and caps total pages
- unparseable money strings count as errors and log instead of being
  silently identical to a zero total
- pg test uses per-run unique store URLs so committed rows cannot hit
  the store_url partial unique index across pg-real runs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(woocommerce): resolve CodeRabbit cycle-2 findings

- listOrderRefunds throws when the page cap is exhausted with data still
  flowing, instead of returning a silently partial list the sync cursor
  would advance past; the error routes into the existing held-cursor
  refund-retry path
- partial sync results keep the row-error count, and the partial toast
  string surfaces it (ICU plural, hidden at zero) in both locales

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: retrigger CI after dropped push event

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-06 23:30:00 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent cd344b6dbb
commit 707d597b2e
41 changed files with 4335 additions and 9 deletions
+131
View File
@@ -0,0 +1,131 @@
import { describe, it, expect } from 'vitest'
import { getPool, withUserContext } from './setup'
import { randomUUID } from 'crypto'
import { seedCompany } from './fixtures'
// Committed (pool) inserts persist across pg-real runs, and the store_url
// partial unique index is global: fixed URLs would collide with rows left by
// a previous run before the assertion under test is ever reached.
const uniqueStore = (label: string) => 'https://' + label + '-' + randomUUID() + '.example.se'
/**
* Covers migration 20260806170000_woocommerce_connections:
* 1. RLS: members insert and read their own company's connection,
* non-members see nothing and cannot insert for a foreign company.
* 2. One ACTIVE connection per company (partial unique index).
* 3. One store actively connected to at most one company.
* 4. No DELETE policy: a member DELETE silently affects zero rows.
*/
describe('woocommerce_connections RLS', () => {
it('a member can insert and read their company connection', async () => {
const { userId, companyId } = await seedCompany()
await withUserContext(userId, async (client) => {
const inserted = await client.query(
`INSERT INTO public.woocommerce_connections
(company_id, user_id, store_url, status, oauth_state)
VALUES ($1, $2, 'https://shop.example.se', 'pending', gen_random_uuid())
RETURNING id`,
[companyId, userId],
)
expect(inserted.rows).toHaveLength(1)
const read = await client.query(
`SELECT status, store_url FROM public.woocommerce_connections WHERE company_id = $1`,
[companyId],
)
expect(read.rows).toEqual([
{ status: 'pending', store_url: 'https://shop.example.se' },
])
})
})
it('a non-member sees nothing and cannot insert for a foreign company', async () => {
const { userId: ownerId, companyId } = await seedCompany()
await getPool().query(
`INSERT INTO public.woocommerce_connections (company_id, user_id, store_url, status)
VALUES ($1, $2, $3, 'active')`,
[companyId, ownerId, uniqueStore('foreign')],
)
const { userId: outsiderId } = await seedCompany() // member of a DIFFERENT company
await withUserContext(outsiderId, async (client) => {
const read = await client.query(
`SELECT id FROM public.woocommerce_connections WHERE company_id = $1`,
[companyId],
)
expect(read.rows).toHaveLength(0)
await expect(
client.query(
`INSERT INTO public.woocommerce_connections (company_id, user_id, store_url, status)
VALUES ($1, $2, 'https://intruder.example.se', 'pending')`,
[companyId, outsiderId],
),
).rejects.toThrow(/row-level security/i)
})
})
it('only one ACTIVE connection per company is allowed', async () => {
const { userId, companyId } = await seedCompany()
await getPool().query(
`INSERT INTO public.woocommerce_connections (company_id, user_id, store_url, status)
VALUES ($1, $2, $3, 'active')`,
[companyId, userId, uniqueStore('store-one')],
)
await expect(
getPool().query(
`INSERT INTO public.woocommerce_connections (company_id, user_id, store_url, status)
VALUES ($1, $2, $3, 'active')`,
[companyId, userId, uniqueStore('store-two')],
),
).rejects.toMatchObject({ code: '23505' }) // unique_violation
})
it('a store may be actively connected to at most one company', async () => {
const { userId: userA, companyId: companyA } = await seedCompany()
const { userId: userB, companyId: companyB } = await seedCompany()
const sharedUrl = uniqueStore('shared')
await getPool().query(
`INSERT INTO public.woocommerce_connections (company_id, user_id, store_url, status)
VALUES ($1, $2, $3, 'active')`,
[companyA, userA, sharedUrl],
)
await expect(
getPool().query(
`INSERT INTO public.woocommerce_connections (company_id, user_id, store_url, status)
VALUES ($1, $2, $3, 'active')`,
[companyB, userB, sharedUrl],
),
).rejects.toMatchObject({ code: '23505' })
// A revoked row for the same store is fine (history is kept).
const revoked = await getPool().query(
`INSERT INTO public.woocommerce_connections (company_id, user_id, store_url, status)
VALUES ($1, $2, $3, 'revoked') RETURNING id`,
[companyB, userB, sharedUrl],
)
expect(revoked.rows).toHaveLength(1)
})
it('members cannot DELETE (no DELETE policy; revoke is a status flip)', async () => {
const { userId, companyId } = await seedCompany()
const { rows } = await getPool().query(
`INSERT INTO public.woocommerce_connections (company_id, user_id, store_url, status)
VALUES ($1, $2, $3, 'active') RETURNING id`,
[companyId, userId, uniqueStore('keep')],
)
await withUserContext(userId, async (client) => {
const del = await client.query(
`DELETE FROM public.woocommerce_connections WHERE id = $1`,
[rows[0].id],
)
expect(del.rowCount).toBe(0)
})
const still = await getPool().query(
`SELECT id FROM public.woocommerce_connections WHERE id = $1`,
[rows[0].id],
)
expect(still.rows).toHaveLength(1)
})
})