Files
accounted/app/api/extensions/woocommerce/orders/cron/route.ts
T
MattssonandClaude Fable 5 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

139 lines
5.2 KiB
TypeScript

import { createClient } from '@supabase/supabase-js'
import { NextResponse } from 'next/server'
import { withCronContext } from '@/lib/api/with-cron-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { hasCapability } from '@/lib/entitlements/has-capability'
import { CAPABILITY } from '@/lib/entitlements/keys'
import { loadExtensions } from '@/lib/extensions/loader'
import { extensionRegistry } from '@/lib/extensions/registry'
import { isWooCommerceConfigured } from '@/extensions/general/woocommerce/lib/credentials'
import { syncWooCommerceOrders } from '@/extensions/general/woocommerce/lib/order-sync'
import type { WooCommerceConnection } from '@/extensions/general/woocommerce/types'
export const maxDuration = 300
/**
* GET /api/extensions/woocommerce/orders/cron
* Nightly order sync for connections that opted in (transaction_sync_enabled):
* imports each connected store's paid orders and refunds into the
* transactions inbox as a bank-style feed on the 1680 cash account.
*
* Read-only against the stores, and it never posts to the journal: rows land
* unbooked; booking stays a human decision. Idempotent via the
* (company_id, external_id) unique index, so overlapping windows and re-runs
* are no-ops. Emits no events, so no ensureInitialized() is needed.
*/
export const GET = withCronContext('cron.woocommerce_order_sync', async (_request, ctx) => {
// Physical routes under app/api/extensions/<id>/ compile into EVERY build,
// including the core-with-zero-extensions one: the registry (generated from
// extensions.config.json) is what actually switches an extension on. A
// scheduled-but-disabled cron must fail visibly (503) instead of quietly
// doing the work anyway.
loadExtensions()
if (!extensionRegistry.get('woocommerce')) {
ctx.log.warn('woocommerce extension is not enabled; cron refused')
return NextResponse.json(
{ error: 'WooCommerce extension is not enabled', code: 'EXTENSION_DISABLED' },
{ status: 503 },
)
}
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY
if (!supabaseUrl || !supabaseServiceKey) {
return errorResponseFromCode('INTERNAL_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { reason: 'Missing Supabase configuration' },
})
}
if (!isWooCommerceConfigured()) {
return NextResponse.json({ message: 'WooCommerce not configured', processed: 0 })
}
const supabase = createClient(supabaseUrl, supabaseServiceKey)
const { data: connections, error: connError } = await supabase
.from('woocommerce_connections')
.select('*')
.eq('status', 'active')
.eq('transaction_sync_enabled', true)
.order('last_order_synced_at', { ascending: true, nullsFirst: true })
.limit(50)
if (connError) {
ctx.log.error('failed to fetch woocommerce connections', connError, {
message: connError.message,
code: connError.code,
})
return errorResponse(connError, ctx.log, { requestId: ctx.requestId })
}
if (!connections || connections.length === 0) {
return NextResponse.json({
message: 'No connections with transaction sync enabled',
processed: 0,
})
}
const startTime = Date.now()
const TIME_BUDGET_MS = 240_000 // leave a minute of margin inside maxDuration
// Shared with syncWooCommerceOrders: it stops between pages and persists
// its cursor, so a truncated connection resumes next night.
const deadlineMs = startTime + TIME_BUDGET_MS
const results: Array<{
connectionId: string
imported: number
duplicates: number
status: 'synced' | 'revoked' | 'error'
}> = []
for (const connection of connections as WooCommerceConnection[]) {
if (Date.now() >= deadlineMs) {
ctx.log.info('time budget reached', { processedSoFar: results.length })
break
}
if (!(await hasCapability(supabase, connection.company_id, CAPABILITY.woocommerce_sync))) {
ctx.log.info('skip: capability not entitled', { companyId: connection.company_id })
continue
}
try {
const summary = await syncWooCommerceOrders(supabase, connection, ctx.log, deadlineMs)
if (summary.deadlineReached) {
ctx.log.info('connection stopped early on time budget; remaining rows resume next run', {
connectionId: connection.id,
})
}
results.push({
connectionId: connection.id,
imported: summary.imported,
duplicates: summary.duplicates,
status: summary.revoked ? 'revoked' : 'synced',
})
} catch (error) {
ctx.log.error('woocommerce order sync failed for connection', error as Error, {
connectionId: connection.id,
companyId: connection.company_id,
})
results.push({
connectionId: connection.id,
imported: 0,
duplicates: 0,
status: 'error',
})
}
}
const totalImported = results.reduce((acc, r) => acc + r.imported, 0)
ctx.log.info('woocommerce order sync summary', {
processed: results.length,
totalImported,
failed: results.filter((r) => r.status === 'error').length,
})
return NextResponse.json({ processed: results.length, imported: totalImported, results })
})