* 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>
139 lines
5.2 KiB
TypeScript
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 })
|
|
})
|