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
+3
View File
@@ -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
/**
+4
View File
@@ -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 } }
+3 -3
View File
@@ -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
View File
@@ -12,4 +12,5 @@ export const ENABLED_EXTENSION_IDS: ReadonlySet<string> = new Set([
'document-extraction',
'stripe',
'whatsapp-inbox',
'woocommerce',
])
+2
View File
@@ -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
View File
@@ -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')
),
}
/**
+1
View File
@@ -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)
})