feat(shopify): Shopify order/refund feed into the transactions inbox (#1474)
* feat(shopify): Shopify order/refund feed into the transactions inbox
New extensions/general/shopify feed extension, modeled on the WooCommerce
feed: connect a Shopify store with Dev Dashboard custom-app client
credentials (client credentials grant, ~24h tokens, never stored), then a
nightly cron + manual sync imports paid orders and refunds via the GraphQL
Admin API (pinned 2026-07) into the transactions inbox on clearing account
1584. Feed-only: nothing auto-books. Zero PII fields are queried, keeping
the app outside Shopify's protected customer data program.
- shopify_connections migration (RLS, revoke-never-delete, encrypted
client id/secret) + shopify_sync capability and bank_sync-mirrored
backfill
- frozen external_id scheme shopify_{shop_domain}_order|refund_{id},
scoped on the shop domain so reconnects never re-import
- cursor sync on updated_at windows with 24h overlap, lock-date drop at
map time, ingest-failure cursor floor, deadline stop-and-resume,
revoked-credential flip
- /import card + settings panel, sv/en i18n, cron 03:15 in vercel.json +
regenerated Docker crontabs, logo, events, panel registry
- 65 unit tests + pg-real RLS test; extensions.schema.json enum also
gains the missing stripe entry (pre-existing drift)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(shopify): review findings from PR 1474
- token exchange: a 429 that survives every retry is throttling, not a
credential failure; stop remapping retryable 4xx to 401 so sustained
throttling can no longer flip the connection to revoked and delete the
stored credentials (CodeRabbit critical)
- order sync: advance a scanned-through watermark (run start, capped by
the failure floor) after a fully-listed window, so empty first runs and
quiet stores rotate to the back of the cron's oldest-first selection
instead of permanently occupying the 50-connection batch (CodeRabbit
major, starvation)
- add handler-level tests for the orders cron route (auth 401, disabled
503, unconfigured no-op, query failure, capability skip, happy path,
per-connection failure isolation, revoked marking)
- add 401 tests for /sync, /transaction-sync and /disconnect; pin the
cursor floor rule with a two-order page; stub the encryption key via
vi.stubEnv
- note in the panel description (sv/en) that orders can mix VAT rates and
must be split at booking (Swedish review advisory)
- DECISIONS.md: wrap underscore identifiers in backticks (MD037)
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
02a5d10538
commit
c187fabf92
@@ -0,0 +1,162 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const verifyCronSecret = vi.fn(() => null as unknown)
|
||||
vi.mock('@/lib/auth/cron', () => ({
|
||||
verifyCronSecret: (...args: unknown[]) => verifyCronSecret(...args),
|
||||
}))
|
||||
|
||||
const registryGet = vi.fn()
|
||||
vi.mock('@/lib/extensions/loader', () => ({ loadExtensions: vi.fn() }))
|
||||
vi.mock('@/lib/extensions/registry', () => ({
|
||||
extensionRegistry: { get: (...args: unknown[]) => registryGet(...args) },
|
||||
}))
|
||||
|
||||
// The connection query chain ends in .limit(); resolve it there.
|
||||
const limitResult = vi.fn()
|
||||
vi.mock('@supabase/supabase-js', () => ({
|
||||
createClient: vi.fn(() => ({
|
||||
from: () => ({
|
||||
select: () => ({
|
||||
eq: () => ({
|
||||
eq: () => ({
|
||||
order: () => ({
|
||||
limit: (...args: unknown[]) => limitResult(...args),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
})),
|
||||
}))
|
||||
|
||||
const isShopifyConfigured = vi.fn(() => true)
|
||||
vi.mock('@/extensions/general/shopify/lib/credentials', () => ({
|
||||
isShopifyConfigured: (...args: unknown[]) => isShopifyConfigured(...args),
|
||||
}))
|
||||
|
||||
const syncShopifyOrders = vi.fn()
|
||||
vi.mock('@/extensions/general/shopify/lib/order-sync', () => ({
|
||||
syncShopifyOrders: (...args: unknown[]) => syncShopifyOrders(...args),
|
||||
}))
|
||||
|
||||
const hasCapability = vi.fn()
|
||||
vi.mock('@/lib/entitlements/has-capability', () => ({
|
||||
hasCapability: (...args: unknown[]) => hasCapability(...args),
|
||||
}))
|
||||
|
||||
const CONNECTION = { id: 'conn-1', company_id: 'company-1' }
|
||||
|
||||
async function callRoute() {
|
||||
const { GET } = await import('../route')
|
||||
return GET(new Request('https://example.test/api/extensions/shopify/orders/cron'))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
verifyCronSecret.mockReturnValue(null)
|
||||
registryGet.mockReturnValue({ id: 'shopify' })
|
||||
isShopifyConfigured.mockReturnValue(true)
|
||||
hasCapability.mockResolvedValue(true)
|
||||
limitResult.mockResolvedValue({ data: [CONNECTION], error: null })
|
||||
syncShopifyOrders.mockResolvedValue({
|
||||
fetched: 2,
|
||||
refundsFetched: 0,
|
||||
imported: 2,
|
||||
duplicates: 0,
|
||||
skippedLocked: 0,
|
||||
errors: 0,
|
||||
})
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL = 'https://project.supabase.co'
|
||||
process.env.SUPABASE_SERVICE_ROLE_KEY = 'service-key'
|
||||
})
|
||||
|
||||
describe('GET /api/extensions/shopify/orders/cron', () => {
|
||||
it('returns 401 when the cron secret is wrong', async () => {
|
||||
verifyCronSecret.mockReturnValue({ error: 'unauthorized' })
|
||||
const res = await callRoute()
|
||||
expect(res.status).toBe(401)
|
||||
expect(syncShopifyOrders).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses with 503 when the extension is not enabled', async () => {
|
||||
registryGet.mockReturnValue(undefined)
|
||||
const res = await callRoute()
|
||||
expect(res.status).toBe(503)
|
||||
const body = await res.json()
|
||||
expect(body.code).toBe('EXTENSION_DISABLED')
|
||||
})
|
||||
|
||||
it('no-ops when the encryption key is not configured', async () => {
|
||||
isShopifyConfigured.mockReturnValue(false)
|
||||
const res = await callRoute()
|
||||
expect(res.status).toBe(200)
|
||||
expect((await res.json()).processed).toBe(0)
|
||||
expect(syncShopifyOrders).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails loudly when the connection query errors', async () => {
|
||||
limitResult.mockResolvedValue({ data: null, error: { message: 'boom', code: '500' } })
|
||||
const res = await callRoute()
|
||||
expect(res.status).toBeGreaterThanOrEqual(500)
|
||||
})
|
||||
|
||||
it('syncs each eligible connection and reports the totals', async () => {
|
||||
limitResult.mockResolvedValue({
|
||||
data: [CONNECTION, { id: 'conn-2', company_id: 'company-2' }],
|
||||
error: null,
|
||||
})
|
||||
const res = await callRoute()
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.processed).toBe(2)
|
||||
expect(body.imported).toBe(4)
|
||||
expect(syncShopifyOrders).toHaveBeenCalledTimes(2)
|
||||
// Runs on the service client with a shared deadline.
|
||||
expect(syncShopifyOrders.mock.calls[0][0]).toBeTruthy()
|
||||
expect(typeof syncShopifyOrders.mock.calls[0][3]).toBe('number')
|
||||
})
|
||||
|
||||
it('skips connections whose company is not entitled', async () => {
|
||||
hasCapability.mockResolvedValue(false)
|
||||
const res = await callRoute()
|
||||
expect(res.status).toBe(200)
|
||||
expect((await res.json()).processed).toBe(0)
|
||||
expect(syncShopifyOrders).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('records a failed connection without aborting the run', async () => {
|
||||
limitResult.mockResolvedValue({
|
||||
data: [CONNECTION, { id: 'conn-2', company_id: 'company-2' }],
|
||||
error: null,
|
||||
})
|
||||
syncShopifyOrders
|
||||
.mockRejectedValueOnce(new Error('store on fire'))
|
||||
.mockResolvedValueOnce({
|
||||
fetched: 1,
|
||||
refundsFetched: 0,
|
||||
imported: 1,
|
||||
duplicates: 0,
|
||||
skippedLocked: 0,
|
||||
errors: 0,
|
||||
})
|
||||
const res = await callRoute()
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.processed).toBe(2)
|
||||
expect(body.results.map((r: { status: string }) => r.status)).toEqual(['error', 'synced'])
|
||||
})
|
||||
|
||||
it('marks a revoked connection in the results', async () => {
|
||||
syncShopifyOrders.mockResolvedValue({
|
||||
fetched: 0,
|
||||
refundsFetched: 0,
|
||||
imported: 0,
|
||||
duplicates: 0,
|
||||
skippedLocked: 0,
|
||||
errors: 0,
|
||||
revoked: true,
|
||||
})
|
||||
const body = await (await callRoute()).json()
|
||||
expect(body.results[0].status).toBe('revoked')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,138 @@
|
||||
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 { isShopifyConfigured } from '@/extensions/general/shopify/lib/credentials'
|
||||
import { syncShopifyOrders } from '@/extensions/general/shopify/lib/order-sync'
|
||||
import type { ShopifyConnection } from '@/extensions/general/shopify/types'
|
||||
|
||||
export const maxDuration = 300
|
||||
|
||||
/**
|
||||
* GET /api/extensions/shopify/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 1584 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.shopify_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('shopify')) {
|
||||
ctx.log.warn('shopify extension is not enabled; cron refused')
|
||||
return NextResponse.json(
|
||||
{ error: 'Shopify 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 (!isShopifyConfigured()) {
|
||||
return NextResponse.json({ message: 'Shopify not configured', processed: 0 })
|
||||
}
|
||||
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceKey)
|
||||
|
||||
const { data: connections, error: connError } = await supabase
|
||||
.from('shopify_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 shopify 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 syncShopifyOrders: 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 ShopifyConnection[]) {
|
||||
if (Date.now() >= deadlineMs) {
|
||||
ctx.log.info('time budget reached', { processedSoFar: results.length })
|
||||
break
|
||||
}
|
||||
|
||||
if (!(await hasCapability(supabase, connection.company_id, CAPABILITY.shopify_sync))) {
|
||||
ctx.log.info('skip: capability not entitled', { companyId: connection.company_id })
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const summary = await syncShopifyOrders(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('shopify 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('shopify order sync summary', {
|
||||
processed: results.length,
|
||||
totalImported,
|
||||
failed: results.filter((r) => r.status === 'error').length,
|
||||
})
|
||||
|
||||
return NextResponse.json({ processed: results.length, imported: totalImported, results })
|
||||
})
|
||||
Reference in New Issue
Block a user