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
@@ -841,4 +841,8 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-08-08] Inline-page UI catch-up applies chips-mark-exceptions to report verdicts uniformly: positive verdicts (Balanserad, Balanserar, Avstämd) render as muted text and only deviations keep a destructive Badge; page-flow instructional copy on Bankavstämning moved behind the FocusedReport "?" HelpPopover (convention 7) instead of being deleted, since the IB/preview semantics are load-bearing for correct reconciliation.
|
||||
[2026-08-08] Journey branch question (PR 2 of the activation concept) renders only in mode='first' and persists initial_setup_path as a fire-and-forget PATCH: in mode='add' a silently-failed setActiveCompany (deliberately non-fatal in createCompanyFromOnboarding) would make the PATCH land on the PREVIOUS company's settings, and experienced multi-company users get the Hem checklist anyway; navigation never blocks on the PATCH because the checklist path is a nicety, not a prerequisite. Provider preselect at /import?mode=migration&provider=X auto-advances only for sieViaApi providers (fortnox/bjornlunden/briox): visma/bokio must land on the provider list where the "SIE krävs först" gate renders with its async connection status.
|
||||
[2026-08-08] SIE export always emits #FORMAT PC8 even when bytes are UTF-8: the record is compulsory in the spec and strict importers (Visma Spiris) reject files without it, while real encoding is detected from bytes (Fortnox ships the same shape). Default bytes stay UTF-8; encoding=cp437 remains opt-in.
|
||||
[2026-08-08] Shopify integration copies the WooCommerce feed pattern (extensions/general/shopify, feed-only, nothing auto-books) rather than the Stripe Connect OAuth pattern: Shopify discontinued admin-created custom apps with revealable `shpat_` tokens on 2026-01-01, and a one-click multi-merchant OAuth app requires Shopify App Store review plus protected-customer-data and `read_all_orders` approvals. MVP auth is therefore a merchant-created Dev Dashboard custom app whose client id/secret the user pastes; the server runs the client credentials grant per sync run (~24h tokens, never stored). Phase 2 (public unlisted OAuth app, Shopify Payments payout/fee reconciliation, bulk-operation backfill, webhooks) is deliberately out of v1.
|
||||
[2026-08-08] Shopify feed clearing account is 1584 (Fordringar Shopify Payments, the e-handel skill's 158x convention): 1686 is owned by the Stripe feed and 1680 by the WooCommerce feed, and cash_accounts enforces one account per ledger per company. Frozen `external_id` scheme: `shopify_{shop_domain}_order|refund_{legacyResourceId}`, scoped on the myshopify.com domain (not the connection id) so reconnects never re-import; distinct from the MCP skill's `shopify_order_{id}` scheme on purpose so the two import paths cannot collide.
|
||||
[2026-08-08] Shopify order feed queries zero PII fields (no customer/email/addresses): keeps the integration entirely outside Shopify's protected-customer-data program and the GDPR RoPA small; BFL verifikat reference needs only the order name/id. Qualification is `displayFinancialStatus` in `PAID`/`PARTIALLY_REFUNDED`/`REFUNDED` and `test=false`, row date is `processedAt`; no MCP tools (same precedent as `bank_sync`/woocommerce: feeds are cron/HTTP only, the agent path is the shopify-to-gnubok skill).
|
||||
[2026-08-08] extensions.schema.json enum also gained "stripe" while adding "shopify": the enum had drifted (stripe was enabled in extensions.config.json but missing from the schema, failing editor validation); fixed in the same touch since the file had to change anyway.
|
||||
[2026-08-08] Login panel is method-stated (BankID hero default, remembered via accounted-login-method cookie) instead of a stacked method list: matches the Swedish bank/Fortnox convention, gives exactly one primary action per view; errors moved from boxed banner to a field-adjacent single line (NN/g 3/4/10), reset link surfaces from the second failed attempt.
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
} from '@/components/ui/dialog'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { ArrowLeft, CreditCard, Landmark, Loader2, ChevronRight, Download, AlertTriangle, ShoppingCart } from 'lucide-react'
|
||||
import { ArrowLeft, CreditCard, Landmark, Loader2, ChevronRight, Download, AlertTriangle, ShoppingBag, ShoppingCart } from 'lucide-react'
|
||||
import { cn, formatDate } from '@/lib/utils'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { useCompany, useCapability } from '@/contexts/CompanyContext'
|
||||
@@ -1959,11 +1959,14 @@ const StripePanel = getSettingsPanel('stripe')
|
||||
// an import source in the same category as the Stripe feed above.
|
||||
const WooCommercePanel = getSettingsPanel('woocommerce')
|
||||
|
||||
// And for the Shopify order feed: same category as the WooCommerce feed above.
|
||||
const ShopifyPanel = getSettingsPanel('shopify')
|
||||
|
||||
// ============================================================
|
||||
// Import Page with Selection Cards
|
||||
// ============================================================
|
||||
|
||||
type ImportMode = null | 'psd2' | 'stripe' | 'woocommerce' | 'bank' | 'sie' | 'csv_data' | 'migration'
|
||||
type ImportMode = null | 'psd2' | 'stripe' | 'woocommerce' | 'shopify' | 'bank' | 'sie' | 'csv_data' | 'migration'
|
||||
|
||||
export default function ImportPage() {
|
||||
const { isSandbox } = useCompany()
|
||||
@@ -1996,7 +1999,7 @@ export default function ImportPage() {
|
||||
// Manual file-import modes (bank file, CSV/Excel, SIE) stay reachable.
|
||||
const allowedModes = isSandbox
|
||||
? ['bank', 'sie', 'csv_data']
|
||||
: ['psd2', 'stripe', 'woocommerce', 'bank', 'sie', 'csv_data', 'migration']
|
||||
: ['psd2', 'stripe', 'woocommerce', 'shopify', 'bank', 'sie', 'csv_data', 'migration']
|
||||
if (!isSandbox && searchParams.get('migration')) {
|
||||
setMode('migration')
|
||||
} else {
|
||||
@@ -2051,6 +2054,8 @@ export default function ImportPage() {
|
||||
const hasWooCommerceExtension = ENABLED_EXTENSION_IDS.has('woocommerce')
|
||||
// Same doctrine as Stripe: external credentials never leave the sandbox.
|
||||
const woocommerceDisabled = isSandbox
|
||||
const hasShopifyExtension = ENABLED_EXTENSION_IDS.has('shopify')
|
||||
const shopifyDisabled = isSandbox
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
@@ -2131,6 +2136,15 @@ export default function ImportPage() {
|
||||
onClick={() => setMode('woocommerce')}
|
||||
/>
|
||||
)}
|
||||
{hasShopifyExtension && (
|
||||
<ImportRow
|
||||
title={t('shopify_title')}
|
||||
sub={t('shopify_description')}
|
||||
chips={<LogoChip src="/logos/shopify.svg" name="Shopify" />}
|
||||
disabled={shopifyDisabled}
|
||||
onClick={() => setMode('shopify')}
|
||||
/>
|
||||
)}
|
||||
{hasMigrationExtension && (
|
||||
<ImportRow
|
||||
title={t('migration_title')}
|
||||
@@ -2308,6 +2322,21 @@ export default function ImportPage() {
|
||||
</Card>
|
||||
)
|
||||
)}
|
||||
{mode === 'shopify' && (
|
||||
hasShopifyExtension && ShopifyPanel ? (
|
||||
<ShopifyPanel />
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<ShoppingBag className="mb-4 h-10 w-10 text-muted-foreground/40" />
|
||||
<p className="mb-1 font-medium">{t('shopify_not_enabled_title')}</p>
|
||||
<p className="max-w-md text-sm text-muted-foreground">
|
||||
{t('shopify_not_enabled_description')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
)}
|
||||
{mode === 'bank' && <BankFileImportWizard />}
|
||||
{mode === 'sie' && <SIEImportWizard />}
|
||||
{mode === 'csv_data' && <CSVDataImportWizard />}
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
@@ -30,6 +30,7 @@
|
||||
0 5 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/enable-banking/sync/cron
|
||||
30 3 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/stripe/transactions/cron
|
||||
45 3 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/woocommerce/orders/cron
|
||||
15 3 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/shopify/orders/cron
|
||||
0 3 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/documents/verify/cron
|
||||
0 4 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/sandbox/cleanup/cron
|
||||
0 2 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/events/cleanup/cron
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
0 5 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/enable-banking/sync/cron
|
||||
30 3 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/stripe/transactions/cron
|
||||
45 3 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/woocommerce/orders/cron
|
||||
15 3 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/shopify/orders/cron
|
||||
0 3 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/documents/verify/cron
|
||||
0 4 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/sandbox/cleanup/cron
|
||||
0 2 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/events/cleanup/cron
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"$schema":"./extensions.schema.json","extensions":["enable-banking","email","arcim-migration","tic","mcp-server","cloud-backup","skatteverket","invoice-inbox","document-extraction","stripe","whatsapp-inbox","woocommerce"]}
|
||||
{"$schema":"./extensions.schema.json","extensions":["enable-banking","email","arcim-migration","tic","mcp-server","cloud-backup","skatteverket","invoice-inbox","document-extraction","stripe","whatsapp-inbox","woocommerce","shopify"]}
|
||||
|
||||
@@ -32,7 +32,9 @@
|
||||
"cloud-backup",
|
||||
"document-extraction",
|
||||
"whatsapp-inbox",
|
||||
"woocommerce"
|
||||
"woocommerce",
|
||||
"stripe",
|
||||
"shopify"
|
||||
]
|
||||
},
|
||||
"description": "Extension IDs to enable. Each ID must match a manifest.json in the extensions/ directory."
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import {
|
||||
normalizeShopDomain,
|
||||
exchangeAccessToken,
|
||||
shopifyGraphQL,
|
||||
listOrdersPage,
|
||||
isRevokedCredentialsError,
|
||||
ShopifyApiError,
|
||||
SHOPIFY_API_VERSION,
|
||||
SHOPIFY_PAGE_SIZE,
|
||||
type ShopifyCredentials,
|
||||
type ShopifySession,
|
||||
} from '../lib/api-client'
|
||||
|
||||
const CREDS: ShopifyCredentials = {
|
||||
shopDomain: 'minbutik.myshopify.com',
|
||||
clientId: 'client-id',
|
||||
clientSecret: 'client-secret',
|
||||
}
|
||||
const SESSION: ShopifySession = {
|
||||
shopDomain: 'minbutik.myshopify.com',
|
||||
accessToken: 'token-1',
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
const fetchMock = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('normalizeShopDomain', () => {
|
||||
it('accepts the full myshopify.com domain in any casing, with or without scheme', () => {
|
||||
expect(normalizeShopDomain('minbutik.myshopify.com')).toBe('minbutik.myshopify.com')
|
||||
expect(normalizeShopDomain(' MinButik.MyShopify.com ')).toBe('minbutik.myshopify.com')
|
||||
expect(normalizeShopDomain('https://minbutik.myshopify.com')).toBe('minbutik.myshopify.com')
|
||||
expect(normalizeShopDomain('https://minbutik.myshopify.com/admin')).toBe(
|
||||
'minbutik.myshopify.com',
|
||||
)
|
||||
})
|
||||
|
||||
it('expands a bare store handle', () => {
|
||||
expect(normalizeShopDomain('minbutik')).toBe('minbutik.myshopify.com')
|
||||
})
|
||||
|
||||
it('accepts a pasted admin URL', () => {
|
||||
expect(normalizeShopDomain('https://admin.shopify.com/store/minbutik')).toBe(
|
||||
'minbutik.myshopify.com',
|
||||
)
|
||||
expect(normalizeShopDomain('admin.shopify.com/store/minbutik/settings/apps')).toBe(
|
||||
'minbutik.myshopify.com',
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects anything that is not a myshopify.com host (SSRF guard)', () => {
|
||||
expect(normalizeShopDomain('minbutik.se')).toBeNull()
|
||||
expect(normalizeShopDomain('192.168.1.1')).toBeNull()
|
||||
expect(normalizeShopDomain('https://evil.example.com/x.myshopify.com')).toBeNull()
|
||||
expect(normalizeShopDomain('sub.domain.myshopify.com')).toBeNull()
|
||||
expect(normalizeShopDomain('')).toBeNull()
|
||||
// A bare word expands to <word>.myshopify.com, which is Shopify-owned:
|
||||
// safe by construction, never a private host.
|
||||
expect(normalizeShopDomain('localhost')).toBe('localhost.myshopify.com')
|
||||
})
|
||||
})
|
||||
|
||||
describe('exchangeAccessToken', () => {
|
||||
it('POSTs the client credentials grant and returns the token', async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
jsonResponse({ access_token: 'token-1', expires_in: 86399 }),
|
||||
)
|
||||
await expect(exchangeAccessToken(CREDS)).resolves.toBe('token-1')
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0]
|
||||
expect(url).toBe('https://minbutik.myshopify.com/admin/oauth/access_token')
|
||||
expect(JSON.parse((init as RequestInit).body as string)).toEqual({
|
||||
client_id: 'client-id',
|
||||
client_secret: 'client-secret',
|
||||
grant_type: 'client_credentials',
|
||||
})
|
||||
})
|
||||
|
||||
it('classifies a non-retryable 4xx as revoked credentials', async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
jsonResponse({ error: 'invalid_client' }, 400),
|
||||
)
|
||||
const error = await exchangeAccessToken(CREDS).catch((e) => e)
|
||||
expect(error).toBeInstanceOf(ShopifyApiError)
|
||||
expect(isRevokedCredentialsError(error)).toBe(true)
|
||||
expect((error as ShopifyApiError).code).toBe('invalid_client')
|
||||
})
|
||||
|
||||
it('does NOT classify sustained throttling (429) as revoked credentials', async () => {
|
||||
// A persistent 429 that survives every retry is still throttling.
|
||||
// Classifying it as revoked would delete the stored credentials and force
|
||||
// the merchant to reconnect over a rate limit.
|
||||
fetchMock.mockResolvedValue(jsonResponse({}, 429))
|
||||
const error = await exchangeAccessToken(CREDS).catch((e) => e)
|
||||
expect(error).toBeInstanceOf(ShopifyApiError)
|
||||
expect((error as ShopifyApiError).status).toBe(429)
|
||||
expect(isRevokedCredentialsError(error)).toBe(false)
|
||||
}, 15_000)
|
||||
})
|
||||
|
||||
describe('shopifyGraphQL', () => {
|
||||
it('sends the token header against the pinned API version and returns data', async () => {
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse({ data: { shop: { name: 'Butiken' } } }))
|
||||
const data = await shopifyGraphQL<{ shop: { name: string } }>(SESSION, 'query { shop { name } }')
|
||||
expect(data.shop.name).toBe('Butiken')
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0]
|
||||
expect(url).toBe(
|
||||
`https://minbutik.myshopify.com/admin/api/${SHOPIFY_API_VERSION}/graphql.json`,
|
||||
)
|
||||
expect((init as RequestInit).headers).toMatchObject({
|
||||
'X-Shopify-Access-Token': 'token-1',
|
||||
})
|
||||
})
|
||||
|
||||
it('retries a THROTTLED response before surfacing data', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({ errors: [{ message: 'Throttled', extensions: { code: 'THROTTLED' } }] }),
|
||||
)
|
||||
.mockResolvedValueOnce(jsonResponse({ data: { ok: true } }))
|
||||
await expect(shopifyGraphQL(SESSION, 'query { ok }')).resolves.toEqual({ ok: true })
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
}, 15_000)
|
||||
|
||||
it('classifies ACCESS_DENIED as revoked (missing scope)', async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
errors: [{ message: 'Access denied', extensions: { code: 'ACCESS_DENIED' } }],
|
||||
}),
|
||||
)
|
||||
const error = await shopifyGraphQL(SESSION, 'query { orders }').catch((e) => e)
|
||||
expect(isRevokedCredentialsError(error)).toBe(true)
|
||||
})
|
||||
|
||||
it('classifies HTTP 401 as revoked without retrying', async () => {
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse({}, 401))
|
||||
const error = await shopifyGraphQL(SESSION, 'query { ok }').catch((e) => e)
|
||||
expect(isRevokedCredentialsError(error)).toBe(true)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('listOrdersPage', () => {
|
||||
it('passes the window filter and cursor and unwraps the connection', async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
data: {
|
||||
orders: {
|
||||
pageInfo: { hasNextPage: true, endCursor: 'cursor-2' },
|
||||
nodes: [{ legacyResourceId: '1042', name: '#1042' }],
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
const page = await listOrdersPage(SESSION, {
|
||||
updatedAtMin: '2026-05-01T00:00:00.000Z',
|
||||
after: 'cursor-1',
|
||||
})
|
||||
expect(page.hasNextPage).toBe(true)
|
||||
expect(page.endCursor).toBe('cursor-2')
|
||||
expect(page.orders).toHaveLength(1)
|
||||
|
||||
const body = JSON.parse((fetchMock.mock.calls[0][1] as RequestInit).body as string)
|
||||
expect(body.variables).toEqual({
|
||||
first: SHOPIFY_PAGE_SIZE,
|
||||
after: 'cursor-1',
|
||||
query: "updated_at:>='2026-05-01T00:00:00.000Z'",
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,371 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
// Force the capability gate to run but stub requireCapability so entitlement
|
||||
// is controlled per test. Mirrors the stripe/woocommerce suites.
|
||||
vi.mock('@/lib/entitlements/has-capability', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/entitlements/has-capability')>()
|
||||
return { ...actual, requireCapability: vi.fn().mockResolvedValue(null) }
|
||||
})
|
||||
|
||||
// Never let a unit test reach a real Shopify host: the credential probe is
|
||||
// mocked, the pure helpers (normalizeShopDomain) stay real.
|
||||
vi.mock('../lib/api-client', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../lib/api-client')>()
|
||||
return { ...actual, testConnectionAndFetchShopInfo: vi.fn() }
|
||||
})
|
||||
|
||||
// The sync engine has its own suite; here it only needs to be callable.
|
||||
vi.mock('../lib/order-sync', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../lib/order-sync')>()
|
||||
return { ...actual, syncShopifyOrders: vi.fn() }
|
||||
})
|
||||
|
||||
vi.mock('@/lib/auth/api-keys', () => ({
|
||||
createServiceClientNoCookies: vi.fn(() => ({ service: true })),
|
||||
}))
|
||||
|
||||
import { shopifyExtension } from '../index'
|
||||
import { requireCapability, capabilityBlockedResponse } from '@/lib/entitlements/has-capability'
|
||||
import { CAPABILITY } from '@/lib/entitlements/keys'
|
||||
import { testConnectionAndFetchShopInfo } from '../lib/api-client'
|
||||
import { syncShopifyOrders } from '../lib/order-sync'
|
||||
import { decryptCredential } from '../lib/credentials'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
import type { ExtensionContext } from '@/lib/extensions/types'
|
||||
|
||||
function findRoute(method: string, path: string) {
|
||||
const route = shopifyExtension.apiRoutes?.find(
|
||||
(r) => r.method === method && r.path === path,
|
||||
)
|
||||
expect(route, `${method} ${path} must be registered`).toBeDefined()
|
||||
return route!
|
||||
}
|
||||
|
||||
function makeRequest(method: string, body?: unknown): Request {
|
||||
return new Request('https://test.local/api/extensions/ext/shopify/x', {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
function makeContext(supabase: unknown): ExtensionContext {
|
||||
return {
|
||||
userId: 'user-1',
|
||||
companyId: 'company-1',
|
||||
extensionId: 'shopify',
|
||||
requestId: 'req_test',
|
||||
supabase,
|
||||
emit: vi.fn().mockResolvedValue(undefined),
|
||||
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
settings: {
|
||||
get: vi.fn().mockResolvedValue(null),
|
||||
set: vi.fn().mockResolvedValue(undefined),
|
||||
clear: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any
|
||||
}
|
||||
|
||||
const USER = { id: 'user-1', is_anonymous: false }
|
||||
|
||||
const VALID_CONNECT_BODY = {
|
||||
shop_domain: 'minbutik.myshopify.com',
|
||||
client_id: 'client-id',
|
||||
client_secret: 'client-secret',
|
||||
}
|
||||
|
||||
describe('shopify extension routes', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(requireCapability).mockResolvedValue(null)
|
||||
vi.stubEnv('SHOPIFY_CREDENTIALS_ENCRYPTION_KEY', 'test-key')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
describe('GET /status', () => {
|
||||
it('returns 401 without a user', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
supabase.auth.getUser.mockResolvedValue({ data: { user: null }, error: null })
|
||||
const res = await findRoute('GET', '/status').handler(
|
||||
makeRequest('GET'),
|
||||
makeContext(supabase),
|
||||
)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('prefers the active connection and reports configured', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null })
|
||||
enqueue({
|
||||
data: [
|
||||
{ id: 'c2', status: 'revoked' },
|
||||
{ id: 'c1', status: 'active', shop_domain: 'minbutik.myshopify.com' },
|
||||
],
|
||||
})
|
||||
const res = await findRoute('GET', '/status').handler(
|
||||
makeRequest('GET'),
|
||||
makeContext(supabase),
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.configured).toBe(true)
|
||||
expect(body.connection.id).toBe('c1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /connect', () => {
|
||||
it('returns 401 without a user', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
supabase.auth.getUser.mockResolvedValue({ data: { user: null }, error: null })
|
||||
const res = await findRoute('POST', '/connect').handler(
|
||||
makeRequest('POST', {}),
|
||||
makeContext(supabase),
|
||||
)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('blocks anonymous (sandbox) users before any external call', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
supabase.auth.getUser.mockResolvedValue({
|
||||
data: { user: { id: 'user-1', is_anonymous: true } },
|
||||
error: null,
|
||||
})
|
||||
const res = await findRoute('POST', '/connect').handler(
|
||||
makeRequest('POST', {}),
|
||||
makeContext(supabase),
|
||||
)
|
||||
expect(res.status).toBe(403)
|
||||
const body = await res.json()
|
||||
expect(body.sandbox_blocked).toBe(true)
|
||||
expect(testConnectionAndFetchShopInfo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 403 capability_blocked when not entitled', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null })
|
||||
enqueue({ data: { is_sandbox: false } })
|
||||
vi.mocked(requireCapability).mockResolvedValue(
|
||||
capabilityBlockedResponse(CAPABILITY.shopify_sync),
|
||||
)
|
||||
const res = await findRoute('POST', '/connect').handler(
|
||||
makeRequest('POST', VALID_CONNECT_BODY),
|
||||
makeContext(supabase),
|
||||
)
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
|
||||
it('rejects a non-myshopify.com domain with 400', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null })
|
||||
enqueue({ data: { is_sandbox: false } })
|
||||
const res = await findRoute('POST', '/connect').handler(
|
||||
makeRequest('POST', { ...VALID_CONNECT_BODY, shop_domain: 'https://minbutik.se' }),
|
||||
makeContext(supabase),
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('rejects missing client credentials with 400', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null })
|
||||
enqueue({ data: { is_sandbox: false } })
|
||||
const res = await findRoute('POST', '/connect').handler(
|
||||
makeRequest('POST', { shop_domain: 'minbutik.myshopify.com', client_id: 'x' }),
|
||||
makeContext(supabase),
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 409 when an active connection already exists', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null })
|
||||
enqueue({ data: { is_sandbox: false } })
|
||||
enqueue({ data: [{ id: 'conn-0', status: 'active' }] })
|
||||
const res = await findRoute('POST', '/connect').handler(
|
||||
makeRequest('POST', VALID_CONNECT_BODY),
|
||||
makeContext(supabase),
|
||||
)
|
||||
expect(res.status).toBe(409)
|
||||
expect(testConnectionAndFetchShopInfo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects with 400 when the credential probe fails', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null })
|
||||
enqueue({ data: { is_sandbox: false } })
|
||||
enqueue({ data: [] }) // no existing connection
|
||||
vi.mocked(testConnectionAndFetchShopInfo).mockRejectedValue(new Error('401'))
|
||||
const res = await findRoute('POST', '/connect').handler(
|
||||
makeRequest('POST', VALID_CONNECT_BODY),
|
||||
makeContext(supabase),
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('verifies, encrypts and activates on the happy path', async () => {
|
||||
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
|
||||
supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null })
|
||||
enqueue({ data: { is_sandbox: false } })
|
||||
enqueue({ data: [] }) // no existing connection
|
||||
enqueue({ data: { id: 'conn-1', shop_domain: 'minbutik.myshopify.com' } }) // insert
|
||||
vi.mocked(testConnectionAndFetchShopInfo).mockResolvedValue({
|
||||
name: 'Testbutiken',
|
||||
currency: 'SEK',
|
||||
})
|
||||
const ctx = makeContext(supabase)
|
||||
const res = await findRoute('POST', '/connect').handler(
|
||||
makeRequest('POST', { ...VALID_CONNECT_BODY, shop_domain: 'MinButik' }),
|
||||
ctx,
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
const inserted = findCall('shopify_connections', 'insert')?.[0] as Record<
|
||||
string,
|
||||
string
|
||||
>
|
||||
expect(inserted.status).toBe('active')
|
||||
expect(inserted.shop_name).toBe('Testbutiken')
|
||||
// The bare handle was normalized before probing and storing.
|
||||
expect(inserted.shop_domain).toBe('minbutik.myshopify.com')
|
||||
// Secrets never stored in plaintext, and they decrypt back.
|
||||
expect(inserted.client_id_encrypted).not.toContain('client-id')
|
||||
expect(decryptCredential(inserted.client_id_encrypted)).toBe('client-id')
|
||||
expect(decryptCredential(inserted.client_secret_encrypted)).toBe('client-secret')
|
||||
expect(ctx.emit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'shopify.connected' }),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /sync', () => {
|
||||
it('returns 401 without a user', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
supabase.auth.getUser.mockResolvedValue({ data: { user: null }, error: null })
|
||||
const res = await findRoute('POST', '/sync').handler(
|
||||
makeRequest('POST'),
|
||||
makeContext(supabase),
|
||||
)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 404 without an active connection', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null })
|
||||
enqueue({ data: null })
|
||||
const res = await findRoute('POST', '/sync').handler(
|
||||
makeRequest('POST'),
|
||||
makeContext(supabase),
|
||||
)
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('runs the sync on the service client and returns the summary', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null })
|
||||
enqueue({ data: { id: 'conn-1', status: 'active' } })
|
||||
vi.mocked(syncShopifyOrders).mockResolvedValue({
|
||||
fetched: 3,
|
||||
refundsFetched: 1,
|
||||
imported: 4,
|
||||
duplicates: 0,
|
||||
skippedLocked: 0,
|
||||
errors: 0,
|
||||
})
|
||||
const res = await findRoute('POST', '/sync').handler(
|
||||
makeRequest('POST'),
|
||||
makeContext(supabase),
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.transactions.imported).toBe(4)
|
||||
expect(vi.mocked(syncShopifyOrders).mock.calls[0][0]).toEqual({ service: true })
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /transaction-sync', () => {
|
||||
it('returns 401 without a user', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
supabase.auth.getUser.mockResolvedValue({ data: { user: null }, error: null })
|
||||
const res = await findRoute('POST', '/transaction-sync').handler(
|
||||
makeRequest('POST', { enabled: true }),
|
||||
makeContext(supabase),
|
||||
)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('rejects a non-boolean enabled with 400', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null })
|
||||
const res = await findRoute('POST', '/transaction-sync').handler(
|
||||
makeRequest('POST', { enabled: 'yes' }),
|
||||
makeContext(supabase),
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('persists the toggle for the active connection', async () => {
|
||||
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
|
||||
supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null })
|
||||
enqueue({ data: [{ id: 'conn-1' }] })
|
||||
const res = await findRoute('POST', '/transaction-sync').handler(
|
||||
makeRequest('POST', { enabled: false }),
|
||||
makeContext(supabase),
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
expect(findCall('shopify_connections', 'update')?.[0]).toEqual({
|
||||
transaction_sync_enabled: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('DELETE /disconnect', () => {
|
||||
it('returns 401 without a user', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
supabase.auth.getUser.mockResolvedValue({ data: { user: null }, error: null })
|
||||
const res = await findRoute('DELETE', '/disconnect').handler(
|
||||
makeRequest('DELETE', {}),
|
||||
makeContext(supabase),
|
||||
)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 404 when no connection exists', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null })
|
||||
enqueue({ data: [] })
|
||||
const res = await findRoute('DELETE', '/disconnect').handler(
|
||||
makeRequest('DELETE', {}),
|
||||
makeContext(supabase),
|
||||
)
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('revokes (never deletes), drops credentials and emits the audit event', async () => {
|
||||
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
|
||||
supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null })
|
||||
enqueue({
|
||||
data: [{ id: 'conn-1', status: 'active', shop_domain: 'minbutik.myshopify.com' }],
|
||||
})
|
||||
enqueue({ data: null }) // update
|
||||
const ctx = makeContext(supabase)
|
||||
const res = await findRoute('DELETE', '/disconnect').handler(
|
||||
makeRequest('DELETE', {}),
|
||||
ctx,
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
const updated = findCall('shopify_connections', 'update')?.[0] as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
expect(updated.status).toBe('revoked')
|
||||
expect(updated.client_id_encrypted).toBeNull()
|
||||
expect(updated.client_secret_encrypted).toBeNull()
|
||||
expect(ctx.emit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'shopify.disconnected' }),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import {
|
||||
encryptCredential,
|
||||
decryptCredential,
|
||||
credentialsOf,
|
||||
isShopifyConfigured,
|
||||
} from '../lib/credentials'
|
||||
|
||||
describe('shopify credentials encryption', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('SHOPIFY_CREDENTIALS_ENCRYPTION_KEY', 'test-key')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
it('round-trips a credential', () => {
|
||||
const ciphertext = encryptCredential('shpca_client_secret_123')
|
||||
expect(ciphertext).not.toContain('shpca')
|
||||
expect(decryptCredential(ciphertext)).toBe('shpca_client_secret_123')
|
||||
})
|
||||
|
||||
it('uses a fresh IV per encryption', () => {
|
||||
const a = encryptCredential('same-input')
|
||||
const b = encryptCredential('same-input')
|
||||
expect(a).not.toBe(b)
|
||||
expect(decryptCredential(a)).toBe('same-input')
|
||||
expect(decryptCredential(b)).toBe('same-input')
|
||||
})
|
||||
|
||||
it('rejects tampered ciphertext', () => {
|
||||
const ciphertext = encryptCredential('secret')
|
||||
const buf = Buffer.from(ciphertext, 'base64url')
|
||||
buf[buf.length - 1] ^= 0xff
|
||||
expect(() => decryptCredential(buf.toString('base64url'))).toThrow()
|
||||
})
|
||||
|
||||
it('throws without the env key', () => {
|
||||
vi.stubEnv('SHOPIFY_CREDENTIALS_ENCRYPTION_KEY', '')
|
||||
expect(isShopifyConfigured()).toBe(false)
|
||||
expect(() => encryptCredential('x')).toThrow(/SHOPIFY_CREDENTIALS_ENCRYPTION_KEY/)
|
||||
})
|
||||
|
||||
it('credentialsOf decrypts a connection and refuses missing credentials', () => {
|
||||
const creds = credentialsOf({
|
||||
shop_domain: 'minbutik.myshopify.com',
|
||||
client_id_encrypted: encryptCredential('client-id'),
|
||||
client_secret_encrypted: encryptCredential('client-secret'),
|
||||
})
|
||||
expect(creds).toEqual({
|
||||
shopDomain: 'minbutik.myshopify.com',
|
||||
clientId: 'client-id',
|
||||
clientSecret: 'client-secret',
|
||||
})
|
||||
expect(() =>
|
||||
credentialsOf({
|
||||
shop_domain: 'minbutik.myshopify.com',
|
||||
client_id_encrypted: null,
|
||||
client_secret_encrypted: null,
|
||||
}),
|
||||
).toThrow(/no stored credentials/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,522 @@
|
||||
import { describe, it, expect, vi, beforeEach, beforeAll, afterAll } from 'vitest'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
|
||||
const listOrdersPage = vi.fn()
|
||||
const createShopifySession = vi.fn()
|
||||
|
||||
vi.mock('../lib/api-client', () => ({
|
||||
listOrdersPage: (...args: unknown[]) => listOrdersPage(...args),
|
||||
createShopifySession: (...args: unknown[]) => createShopifySession(...args),
|
||||
isRevokedCredentialsError: (error: unknown) =>
|
||||
error instanceof Error && error.message === 'REVOKED',
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/transactions/ingest', () => ({
|
||||
ingestTransactions: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/cash-accounts/service', () => ({
|
||||
ensureManualCashAccount: vi.fn().mockResolvedValue('cash-account-1'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/import/account-sync', () => ({
|
||||
syncMappedAccounts: vi.fn().mockResolvedValue({ error: null }),
|
||||
}))
|
||||
|
||||
import { ingestTransactions } from '@/lib/transactions/ingest'
|
||||
import { ensureManualCashAccount } from '@/lib/cash-accounts/service'
|
||||
import { encryptCredential } from '../lib/credentials'
|
||||
import {
|
||||
SHOPIFY_IMPORT_SOURCE,
|
||||
SHOPIFY_LEDGER_ACCOUNT,
|
||||
mapOrder,
|
||||
mapRefund,
|
||||
orderQualifies,
|
||||
rowBehindLock,
|
||||
shopifyOrderExternalId,
|
||||
shopifyRefundExternalId,
|
||||
shopifyShopScope,
|
||||
syncShopifyOrders,
|
||||
} from '../lib/order-sync'
|
||||
import type { ShopifyConnection, ShopifyOrder, ShopifyRefund } from '../types'
|
||||
|
||||
beforeAll(() => {
|
||||
vi.stubEnv('SHOPIFY_CREDENTIALS_ENCRYPTION_KEY', 'test-key')
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
function makeConnection(overrides: Partial<ShopifyConnection> = {}): ShopifyConnection {
|
||||
return {
|
||||
id: 'conn-1',
|
||||
company_id: 'company-1',
|
||||
user_id: 'user-1',
|
||||
shop_domain: 'minbutik.myshopify.com',
|
||||
shop_name: 'Testbutiken',
|
||||
client_id_encrypted: encryptCredential('client-id'),
|
||||
client_secret_encrypted: encryptCredential('client-secret'),
|
||||
status: 'active',
|
||||
currency: 'SEK',
|
||||
transaction_sync_enabled: true,
|
||||
last_order_synced_at: null,
|
||||
error_message: null,
|
||||
connected_at: '2026-07-01T00:00:00.000Z',
|
||||
disconnected_at: null,
|
||||
created_at: '2026-07-01T00:00:00.000Z',
|
||||
updated_at: '2026-07-01T00:00:00.000Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function money(amount: string, currencyCode = 'SEK') {
|
||||
return { shopMoney: { amount, currencyCode } }
|
||||
}
|
||||
|
||||
function makeOrder(overrides: Partial<ShopifyOrder> = {}): ShopifyOrder {
|
||||
return {
|
||||
legacyResourceId: '1042',
|
||||
name: '#1042',
|
||||
test: false,
|
||||
processedAt: '2026-08-01T09:04:30Z',
|
||||
updatedAt: '2026-08-01T09:05:00Z',
|
||||
displayFinancialStatus: 'PAID',
|
||||
paymentGatewayNames: ['Klarna'],
|
||||
totalPriceSet: money('1250.00'),
|
||||
refunds: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/** One-page result helper; the loop terminates on hasNextPage: false. */
|
||||
function page(orders: ShopifyOrder[], hasNextPage = false, endCursor: string | null = null) {
|
||||
return { orders, hasNextPage, endCursor }
|
||||
}
|
||||
|
||||
/** Minimal chainable supabase mock covering the sync's query patterns. */
|
||||
function makeSupabaseMock(options: { lockThrough?: string | null } = {}) {
|
||||
const updates: Array<{ table: string; values: Record<string, unknown> }> = []
|
||||
const client = {
|
||||
from(table: string) {
|
||||
const builder = {
|
||||
select: () => builder,
|
||||
eq: () => builder,
|
||||
maybeSingle: async () => ({
|
||||
data:
|
||||
table === 'company_settings'
|
||||
? { bookkeeping_locked_through: options.lockThrough ?? null }
|
||||
: null,
|
||||
error: null,
|
||||
}),
|
||||
update: (values: Record<string, unknown>) => {
|
||||
updates.push({ table, values })
|
||||
return builder
|
||||
},
|
||||
}
|
||||
return builder
|
||||
},
|
||||
}
|
||||
return { client: client as unknown as SupabaseClient, updates }
|
||||
}
|
||||
|
||||
function cursorUpdates(updates: Array<{ table: string; values: Record<string, unknown> }>) {
|
||||
return updates.filter(
|
||||
(u) => u.table === 'shopify_connections' && 'last_order_synced_at' in u.values,
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
createShopifySession.mockResolvedValue({
|
||||
shopDomain: 'minbutik.myshopify.com',
|
||||
accessToken: 'token-1',
|
||||
})
|
||||
listOrdersPage.mockResolvedValue(page([]))
|
||||
vi.mocked(ingestTransactions).mockResolvedValue({
|
||||
imported: 0,
|
||||
duplicates: 0,
|
||||
errors: 0,
|
||||
} as Awaited<ReturnType<typeof ingestTransactions>>)
|
||||
})
|
||||
|
||||
describe('frozen external_id formats', () => {
|
||||
// ⚠️ These assert the exact persisted formats. If this test fails, you are
|
||||
// about to orphan every previously imported Shopify row: do not update the
|
||||
// expectation without a coordinated backfill (see order-sync.ts).
|
||||
it('order id format is frozen', () => {
|
||||
expect(shopifyOrderExternalId('minbutik.myshopify.com', '1042')).toBe(
|
||||
'shopify_minbutik.myshopify.com_order_1042',
|
||||
)
|
||||
})
|
||||
|
||||
it('refund id format is frozen', () => {
|
||||
expect(shopifyRefundExternalId('minbutik.myshopify.com', '77')).toBe(
|
||||
'shopify_minbutik.myshopify.com_refund_77',
|
||||
)
|
||||
})
|
||||
|
||||
it('shop scope is the stored shop domain', () => {
|
||||
expect(shopifyShopScope('minbutik.myshopify.com')).toBe('minbutik.myshopify.com')
|
||||
})
|
||||
|
||||
it('import source and ledger account are frozen', () => {
|
||||
expect(SHOPIFY_IMPORT_SOURCE).toBe('shopify')
|
||||
expect(SHOPIFY_LEDGER_ACCOUNT).toBe('1584')
|
||||
})
|
||||
})
|
||||
|
||||
describe('orderQualifies', () => {
|
||||
it('requires a paid financial status and excludes test orders', () => {
|
||||
expect(orderQualifies(makeOrder())).toBe(true)
|
||||
expect(orderQualifies(makeOrder({ displayFinancialStatus: 'PARTIALLY_REFUNDED' }))).toBe(true)
|
||||
expect(orderQualifies(makeOrder({ displayFinancialStatus: 'REFUNDED' }))).toBe(true)
|
||||
expect(orderQualifies(makeOrder({ displayFinancialStatus: 'PENDING' }))).toBe(false)
|
||||
expect(orderQualifies(makeOrder({ displayFinancialStatus: 'AUTHORIZED' }))).toBe(false)
|
||||
expect(orderQualifies(makeOrder({ displayFinancialStatus: 'PARTIALLY_PAID' }))).toBe(false)
|
||||
expect(orderQualifies(makeOrder({ displayFinancialStatus: null }))).toBe(false)
|
||||
expect(orderQualifies(makeOrder({ test: true }))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mapOrder', () => {
|
||||
it('maps a paid order to one gross row dated by processedAt', () => {
|
||||
const rows = mapOrder('minbutik.myshopify.com', makeOrder())
|
||||
expect(rows).toEqual([
|
||||
{
|
||||
date: '2026-08-01',
|
||||
description: 'Shopify-order #1042',
|
||||
amount: 1250,
|
||||
currency: 'SEK',
|
||||
external_id: 'shopify_minbutik.myshopify.com_order_1042',
|
||||
import_source: 'shopify',
|
||||
reference: 'Klarna',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('rounds string money to two decimals and uppercases the currency', () => {
|
||||
const rows = mapOrder('s', makeOrder({ totalPriceSet: money('99.995', 'eur') }))
|
||||
expect(rows[0].amount).toBe(100)
|
||||
expect(rows[0].currency).toBe('EUR')
|
||||
})
|
||||
|
||||
it('skips unpaid, test, zero-total and unparseable orders', () => {
|
||||
expect(mapOrder('s', makeOrder({ displayFinancialStatus: 'PENDING' }))).toEqual([])
|
||||
expect(mapOrder('s', makeOrder({ test: true }))).toEqual([])
|
||||
expect(mapOrder('s', makeOrder({ totalPriceSet: money('0.00') }))).toEqual([])
|
||||
expect(mapOrder('s', makeOrder({ totalPriceSet: money('not-a-number') }))).toEqual([])
|
||||
})
|
||||
|
||||
it('leaves the reference null when no gateways are reported', () => {
|
||||
expect(mapOrder('s', makeOrder({ paymentGatewayNames: [] }))[0].reference).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('mapRefund', () => {
|
||||
const refund: ShopifyRefund = {
|
||||
legacyResourceId: '77',
|
||||
createdAt: '2026-08-03T10:00:00Z',
|
||||
totalRefundedSet: money('250.00'),
|
||||
}
|
||||
|
||||
it('maps a refund to one negative row dated by the refund date', () => {
|
||||
const rows = mapRefund('minbutik.myshopify.com', makeOrder(), refund)
|
||||
expect(rows).toEqual([
|
||||
{
|
||||
date: '2026-08-03',
|
||||
description: 'Shopify-återbetalning order #1042',
|
||||
amount: -250,
|
||||
currency: 'SEK',
|
||||
external_id: 'shopify_minbutik.myshopify.com_refund_77',
|
||||
import_source: 'shopify',
|
||||
reference: null,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('skips zero-amount refunds', () => {
|
||||
expect(
|
||||
mapRefund('s', makeOrder(), { ...refund, totalRefundedSet: money('0') }),
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('rowBehindLock', () => {
|
||||
it('drops dates on/before the lock and keeps later ones', () => {
|
||||
expect(rowBehindLock('2026-06-30', '2026-06-30')).toBe(true)
|
||||
expect(rowBehindLock('2026-06-15', '2026-06-30')).toBe(true)
|
||||
expect(rowBehindLock('2026-07-01', '2026-06-30')).toBe(false)
|
||||
expect(rowBehindLock('2026-06-15', null)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('syncShopifyOrders', () => {
|
||||
it('ingests order and refund rows against the 1584 cash account and advances the cursor', async () => {
|
||||
const { client, updates } = makeSupabaseMock()
|
||||
const order = makeOrder({
|
||||
displayFinancialStatus: 'PARTIALLY_REFUNDED',
|
||||
refunds: [
|
||||
{
|
||||
legacyResourceId: '77',
|
||||
createdAt: '2026-08-03T10:00:00Z',
|
||||
totalRefundedSet: money('250.00'),
|
||||
},
|
||||
],
|
||||
})
|
||||
listOrdersPage.mockResolvedValueOnce(page([order]))
|
||||
vi.mocked(ingestTransactions).mockResolvedValueOnce({
|
||||
imported: 2,
|
||||
duplicates: 0,
|
||||
errors: 0,
|
||||
} as Awaited<ReturnType<typeof ingestTransactions>>)
|
||||
|
||||
const summary = await syncShopifyOrders(client, makeConnection())
|
||||
|
||||
expect(summary).toMatchObject({ fetched: 1, refundsFetched: 1, imported: 2, duplicates: 0 })
|
||||
expect(ensureManualCashAccount).toHaveBeenCalledWith(
|
||||
client,
|
||||
'company-1',
|
||||
'1584',
|
||||
'SEK',
|
||||
'Shopify-saldo',
|
||||
)
|
||||
expect(ingestTransactions).toHaveBeenCalledTimes(1)
|
||||
const [, companyId, userId, rows, ingestOptions] =
|
||||
vi.mocked(ingestTransactions).mock.calls[0]
|
||||
expect(companyId).toBe('company-1')
|
||||
expect(userId).toBe('user-1')
|
||||
expect((rows as Array<{ external_id: string }>).map((r) => r.external_id)).toEqual([
|
||||
'shopify_minbutik.myshopify.com_order_1042',
|
||||
'shopify_minbutik.myshopify.com_refund_77',
|
||||
])
|
||||
expect(ingestOptions).toEqual({ settlementAccount: '1584', skipAutoCategorization: true })
|
||||
|
||||
// Cursor persisted from the page's max updatedAt, and any stale
|
||||
// error_message is cleared on progress. A fully-listed window then
|
||||
// advances the watermark to the run start time.
|
||||
const cursors = cursorUpdates(updates)
|
||||
expect(cursors).toHaveLength(2)
|
||||
expect(cursors[0].values.last_order_synced_at).toBe('2026-08-01T09:05:00.000Z')
|
||||
expect(cursors[0].values.error_message).toBeNull()
|
||||
const watermarkMs = Date.parse(cursors[1].values.last_order_synced_at as string)
|
||||
expect(Math.abs(watermarkMs - Date.now())).toBeLessThan(60_000)
|
||||
// hasNextPage: false terminates without a second list call.
|
||||
expect(listOrdersPage).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('walks Relay cursors within one fixed window', async () => {
|
||||
const { client } = makeSupabaseMock()
|
||||
const first = makeOrder()
|
||||
const second = makeOrder({
|
||||
legacyResourceId: '1043',
|
||||
name: '#1043',
|
||||
updatedAt: '2026-08-02T08:00:00Z',
|
||||
})
|
||||
listOrdersPage
|
||||
.mockResolvedValueOnce(page([first], true, 'cursor-1'))
|
||||
.mockResolvedValueOnce(page([second]))
|
||||
|
||||
const summary = await syncShopifyOrders(client, makeConnection())
|
||||
|
||||
expect(summary.fetched).toBe(2)
|
||||
expect(listOrdersPage).toHaveBeenCalledTimes(2)
|
||||
const [firstArgs, secondArgs] = listOrdersPage.mock.calls.map((c) => c[1])
|
||||
expect(firstArgs.after).toBeNull()
|
||||
expect(secondArgs.after).toBe('cursor-1')
|
||||
// The window is fixed for the whole run; only the cursor moves.
|
||||
expect(secondArgs.updatedAtMin).toBe(firstArgs.updatedAtMin)
|
||||
})
|
||||
|
||||
it('re-polls with a 24h overlap from the persisted cursor', async () => {
|
||||
const { client } = makeSupabaseMock()
|
||||
await syncShopifyOrders(
|
||||
client,
|
||||
makeConnection({ last_order_synced_at: '2026-08-05T12:00:00.000Z' }),
|
||||
)
|
||||
expect(listOrdersPage.mock.calls[0][1].updatedAtMin).toBe('2026-08-04T12:00:00.000Z')
|
||||
})
|
||||
|
||||
it('drops rows dated on/before the bookkeeping lock on every run', async () => {
|
||||
const { client, updates } = makeSupabaseMock({ lockThrough: '2026-08-02' })
|
||||
// Order paid 2026-08-01 (behind lock), refund created 2026-08-03 (after).
|
||||
const order = makeOrder({
|
||||
displayFinancialStatus: 'PARTIALLY_REFUNDED',
|
||||
refunds: [
|
||||
{
|
||||
legacyResourceId: '77',
|
||||
createdAt: '2026-08-03T10:00:00Z',
|
||||
totalRefundedSet: money('250.00'),
|
||||
},
|
||||
],
|
||||
})
|
||||
listOrdersPage.mockResolvedValueOnce(page([order]))
|
||||
|
||||
const summary = await syncShopifyOrders(client, makeConnection())
|
||||
|
||||
expect(summary.skippedLocked).toBe(1)
|
||||
const [, , , rows] = vi.mocked(ingestTransactions).mock.calls[0]
|
||||
expect((rows as Array<{ external_id: string }>).map((r) => r.external_id)).toEqual([
|
||||
'shopify_minbutik.myshopify.com_refund_77',
|
||||
])
|
||||
// The cursor still advances (page + watermark): the drop is by design,
|
||||
// not a failure.
|
||||
expect(cursorUpdates(updates)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('holds the cursor below a page whose ingest reported errors', async () => {
|
||||
const { client, updates } = makeSupabaseMock()
|
||||
// Two orders so the assertion distinguishes "first updatedAt minus 1s"
|
||||
// (the floor rule) from "max updatedAt minus 1s".
|
||||
listOrdersPage.mockResolvedValueOnce(
|
||||
page([
|
||||
makeOrder(),
|
||||
makeOrder({ legacyResourceId: '1043', name: '#1043', updatedAt: '2026-08-02T08:00:00Z' }),
|
||||
]),
|
||||
)
|
||||
vi.mocked(ingestTransactions).mockResolvedValueOnce({
|
||||
imported: 0,
|
||||
duplicates: 0,
|
||||
errors: 1,
|
||||
} as Awaited<ReturnType<typeof ingestTransactions>>)
|
||||
|
||||
const summary = await syncShopifyOrders(client, makeConnection())
|
||||
|
||||
expect(summary.errors).toBe(1)
|
||||
// Page's FIRST updatedAt 09:05:00 minus 1s: the next run re-lists it.
|
||||
// The floor also caps the end-of-run watermark, so no second update.
|
||||
const cursors = cursorUpdates(updates)
|
||||
expect(cursors).toHaveLength(1)
|
||||
expect(cursors[0].values.last_order_synced_at).toBe('2026-08-01T09:04:59.000Z')
|
||||
})
|
||||
|
||||
it('falls back to the first order currency when the shop currency was unreadable', async () => {
|
||||
const { client } = makeSupabaseMock()
|
||||
listOrdersPage.mockResolvedValueOnce(
|
||||
page([makeOrder({ totalPriceSet: money('10.00', 'eur') })]),
|
||||
)
|
||||
|
||||
await syncShopifyOrders(client, makeConnection({ currency: null }))
|
||||
|
||||
expect(ensureManualCashAccount).toHaveBeenCalledWith(
|
||||
client,
|
||||
'company-1',
|
||||
'1584',
|
||||
'EUR',
|
||||
'Shopify-saldo',
|
||||
)
|
||||
})
|
||||
|
||||
it('surfaces a cash-account failure on the connection instead of failing silently', async () => {
|
||||
const { client, updates } = makeSupabaseMock()
|
||||
listOrdersPage.mockResolvedValueOnce(page([makeOrder()]))
|
||||
vi.mocked(ensureManualCashAccount).mockRejectedValueOnce(
|
||||
new Error('cash account 1584 exists with currency EUR'),
|
||||
)
|
||||
|
||||
await expect(syncShopifyOrders(client, makeConnection())).rejects.toThrow(
|
||||
/currency EUR/,
|
||||
)
|
||||
const errorUpdate = updates.find(
|
||||
(u) => u.table === 'shopify_connections' && 'error_message' in u.values,
|
||||
)
|
||||
expect(errorUpdate?.values.error_message).toMatch(/1584/)
|
||||
})
|
||||
|
||||
it('counts an unparseable order total as an error without stalling the cursor', async () => {
|
||||
const { client, updates } = makeSupabaseMock()
|
||||
listOrdersPage.mockResolvedValueOnce(
|
||||
page([makeOrder({ totalPriceSet: money('not-a-number') })]),
|
||||
)
|
||||
|
||||
const summary = await syncShopifyOrders(client, makeConnection())
|
||||
|
||||
expect(summary.errors).toBe(1)
|
||||
expect(ingestTransactions).not.toHaveBeenCalled()
|
||||
// Deliberate: a permanently corrupt total must not stall the feed
|
||||
// (page cursor + end-of-run watermark both persist).
|
||||
expect(cursorUpdates(updates)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('advances a watermark on an empty first run so quiet stores rotate in the cron', async () => {
|
||||
const { client, updates } = makeSupabaseMock()
|
||||
|
||||
const summary = await syncShopifyOrders(client, makeConnection())
|
||||
|
||||
expect(summary.fetched).toBe(0)
|
||||
// Without this, an empty store keeps a NULL cursor forever and the cron's
|
||||
// nullsFirst selection re-picks it every night ahead of everyone else.
|
||||
const cursors = cursorUpdates(updates)
|
||||
expect(cursors).toHaveLength(1)
|
||||
const watermarkMs = Date.parse(cursors[0].values.last_order_synced_at as string)
|
||||
expect(Math.abs(watermarkMs - Date.now())).toBeLessThan(60_000)
|
||||
})
|
||||
|
||||
it('does nothing for a connection without credentials or not active', async () => {
|
||||
const { client } = makeSupabaseMock()
|
||||
const summary = await syncShopifyOrders(
|
||||
client,
|
||||
makeConnection({ client_id_encrypted: null }),
|
||||
)
|
||||
expect(summary.fetched).toBe(0)
|
||||
expect(createShopifySession).not.toHaveBeenCalled()
|
||||
|
||||
const revokedSummary = await syncShopifyOrders(
|
||||
client,
|
||||
makeConnection({ status: 'revoked' }),
|
||||
)
|
||||
expect(revokedSummary.fetched).toBe(0)
|
||||
})
|
||||
|
||||
it('flips the connection to revoked when the token exchange rejects the credentials', async () => {
|
||||
const { client, updates } = makeSupabaseMock()
|
||||
createShopifySession.mockRejectedValueOnce(new Error('REVOKED'))
|
||||
|
||||
const summary = await syncShopifyOrders(client, makeConnection())
|
||||
|
||||
expect(summary.revoked).toBe(true)
|
||||
const revokeUpdate = updates.find((u) => u.table === 'shopify_connections')
|
||||
expect(revokeUpdate?.values.status).toBe('revoked')
|
||||
expect(revokeUpdate?.values.client_id_encrypted).toBeNull()
|
||||
})
|
||||
|
||||
it('flips the connection to revoked when the store rejects the token mid-run', async () => {
|
||||
const { client, updates } = makeSupabaseMock()
|
||||
listOrdersPage.mockRejectedValueOnce(new Error('REVOKED'))
|
||||
|
||||
const summary = await syncShopifyOrders(client, makeConnection())
|
||||
|
||||
expect(summary.revoked).toBe(true)
|
||||
expect(updates.find((u) => u.table === 'shopify_connections')?.values.status).toBe(
|
||||
'revoked',
|
||||
)
|
||||
})
|
||||
|
||||
it('stops before fetching when the deadline is already reached', async () => {
|
||||
const { client } = makeSupabaseMock()
|
||||
const summary = await syncShopifyOrders(
|
||||
client,
|
||||
makeConnection(),
|
||||
undefined,
|
||||
Date.now() - 1,
|
||||
)
|
||||
expect(summary.deadlineReached).toBe(true)
|
||||
expect(listOrdersPage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stops between pages on deadline with the processed pages cursored', async () => {
|
||||
const { client, updates } = makeSupabaseMock()
|
||||
const deadlineMs = Date.now() + 150
|
||||
listOrdersPage.mockImplementationOnce(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 200))
|
||||
return page([makeOrder()], true, 'cursor-1')
|
||||
})
|
||||
|
||||
const summary = await syncShopifyOrders(client, makeConnection(), undefined, deadlineMs)
|
||||
|
||||
expect(summary.deadlineReached).toBe(true)
|
||||
// The fetched page was fully processed and cursored; page two never ran.
|
||||
expect(listOrdersPage).toHaveBeenCalledTimes(1)
|
||||
expect(cursorUpdates(updates)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { serverErrorMessage, syncSummary, type ShopifySyncPayload } from '../lib/settings-actions'
|
||||
|
||||
describe('syncSummary', () => {
|
||||
const base = { fetched: 5, refundsFetched: 1, imported: 4, duplicates: 1, errors: 0 }
|
||||
|
||||
it('classifies a revoked run before anything else', () => {
|
||||
expect(syncSummary({ transactions: { ...base, revoked: true } })).toEqual({
|
||||
reason: 'revoked',
|
||||
})
|
||||
})
|
||||
|
||||
it('classifies a truncated run as partial, keeping the error count', () => {
|
||||
expect(
|
||||
syncSummary({ transactions: { ...base, errors: 2, deadlineReached: true } }),
|
||||
).toEqual({ reason: 'partial', values: { fetched: 5, imported: 4, errors: 2 } })
|
||||
})
|
||||
|
||||
it('classifies an empty window as its own outcome', () => {
|
||||
expect(
|
||||
syncSummary({ transactions: { ...base, fetched: 0, imported: 0 } }),
|
||||
).toEqual({ reason: 'empty' })
|
||||
})
|
||||
|
||||
it('reports both halves when rows landed and rows failed', () => {
|
||||
expect(syncSummary({ transactions: { ...base, errors: 2 } })).toEqual({
|
||||
reason: 'errors',
|
||||
values: { fetched: 5, imported: 4, errors: 2 },
|
||||
})
|
||||
})
|
||||
|
||||
it('classifies a clean run as feed', () => {
|
||||
expect(syncSummary({ transactions: base })).toEqual({
|
||||
reason: 'feed',
|
||||
values: { fetched: 5, imported: 4 },
|
||||
})
|
||||
})
|
||||
|
||||
it('classifies an unreadable body as unknown', () => {
|
||||
expect(syncSummary(null)).toEqual({ reason: 'unknown' })
|
||||
expect(syncSummary({} as ShopifySyncPayload)).toEqual({ reason: 'unknown' })
|
||||
expect(syncSummary({ transactions: {} })).toEqual({ reason: 'unknown' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('serverErrorMessage', () => {
|
||||
it('prefers route copy over the generic map', () => {
|
||||
expect(serverErrorMessage({ error: 'Ingen ansluten Shopify-butik.' }, 404, 'sv')).toBe(
|
||||
'Ingen ansluten Shopify-butik.',
|
||||
)
|
||||
})
|
||||
|
||||
it('prefers the English variant when present and the locale is en', () => {
|
||||
expect(
|
||||
serverErrorMessage({ error: 'Fel.', error_en: 'An error.' }, 500, 'en'),
|
||||
).toBe('An error.')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,414 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { ApiRouteDefinition, ExtensionContext } from '@/lib/extensions/types'
|
||||
import { checkRateLimit } from '@/lib/auth/rate-limit-http'
|
||||
import { requireCapability } from '@/lib/entitlements/has-capability'
|
||||
import { CAPABILITY } from '@/lib/entitlements/keys'
|
||||
import { guardSandbox, sandboxBlockedResponse } from '@/lib/sandbox/guard'
|
||||
import { createServiceClientNoCookies } from '@/lib/auth/api-keys'
|
||||
import { isShopifyConfigured, encryptCredential } from './lib/credentials'
|
||||
import { normalizeShopDomain, testConnectionAndFetchShopInfo } from './lib/api-client'
|
||||
import { syncShopifyOrders } from './lib/order-sync'
|
||||
import type { ShopifyConnection, ShopifyStatusResponse } from './types'
|
||||
|
||||
// Per-user limits: connect probes the merchant's store, sync pages its
|
||||
// order history.
|
||||
const RATE_LIMIT_CONNECT = { maxRequests: 10, windowMs: 60_000 }
|
||||
const RATE_LIMIT_DISCONNECT = { maxRequests: 10, windowMs: 60_000 }
|
||||
const RATE_LIMIT_SYNC = { maxRequests: 10, windowMs: 60_000 }
|
||||
|
||||
const NOT_CONFIGURED_MESSAGE =
|
||||
'Shopify-integrationen är inte konfigurerad på den här installationen.'
|
||||
|
||||
/** Columns safe to hand to the browser: never the encrypted credentials. */
|
||||
const STATUS_COLUMNS =
|
||||
'id, status, shop_domain, shop_name, currency, error_message, connected_at, transaction_sync_enabled, last_order_synced_at'
|
||||
|
||||
type AuthedContext = {
|
||||
supabase: ExtensionContext['supabase']
|
||||
userId: string
|
||||
isAnonymous: boolean
|
||||
companyId: string
|
||||
}
|
||||
|
||||
/** Shared auth preamble: cookie user + company context, or an error response. */
|
||||
async function requireUserAndCompany(
|
||||
ctx: ExtensionContext | undefined,
|
||||
): Promise<AuthedContext | NextResponse> {
|
||||
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
if (!ctx?.companyId) {
|
||||
return NextResponse.json({ error: 'Company context required' }, { status: 400 })
|
||||
}
|
||||
return {
|
||||
supabase,
|
||||
userId: user.id,
|
||||
isAnonymous: Boolean(user.is_anonymous),
|
||||
companyId: ctx.companyId,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect guards: sandbox users never reach external stores (same doctrine as
|
||||
* the Stripe/WooCommerce connects), and the feed is a paid capability
|
||||
* (shopify_sync).
|
||||
*/
|
||||
async function guardConnectPreconditions(auth: AuthedContext): Promise<NextResponse | null> {
|
||||
if (auth.isAnonymous) return sandboxBlockedResponse()
|
||||
const sandboxBlocked = await guardSandbox(auth.supabase, auth.companyId)
|
||||
if (sandboxBlocked) return sandboxBlocked
|
||||
return requireCapability(auth.supabase, auth.companyId, CAPABILITY.shopify_sync)
|
||||
}
|
||||
|
||||
export const shopifyApiRoutes: ApiRouteDefinition[] = [
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/status',
|
||||
handler: async (_request: Request, ctx?: ExtensionContext) => {
|
||||
const auth = await requireUserAndCompany(ctx)
|
||||
if (auth instanceof NextResponse) return auth
|
||||
|
||||
// Prefer the active connection; otherwise surface the most recent row
|
||||
// so the panel can show error/revoked states.
|
||||
const { data: rows } = await auth.supabase
|
||||
.from('shopify_connections')
|
||||
.select(STATUS_COLUMNS)
|
||||
.eq('company_id', auth.companyId)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(10)
|
||||
|
||||
const connection = rows?.find((r) => r.status === 'active') ?? rows?.[0] ?? null
|
||||
const payload: ShopifyStatusResponse = {
|
||||
configured: isShopifyConfigured(),
|
||||
connection,
|
||||
}
|
||||
return NextResponse.json(payload)
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/connect',
|
||||
handler: async (request: Request, ctx?: ExtensionContext) => {
|
||||
const log = ctx?.log ?? console
|
||||
const auth = await requireUserAndCompany(ctx)
|
||||
if (auth instanceof NextResponse) return auth
|
||||
|
||||
const blocked = await guardConnectPreconditions(auth)
|
||||
if (blocked) return blocked
|
||||
|
||||
const rl = await checkRateLimit({
|
||||
prefix: 'shopify:connect',
|
||||
identifier: auth.userId,
|
||||
...RATE_LIMIT_CONNECT,
|
||||
})
|
||||
if (!rl.ok) return rl.response!
|
||||
|
||||
if (!isShopifyConfigured()) {
|
||||
return NextResponse.json({ error: NOT_CONFIGURED_MESSAGE }, { status: 503 })
|
||||
}
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as {
|
||||
shop_domain?: unknown
|
||||
client_id?: unknown
|
||||
client_secret?: unknown
|
||||
}
|
||||
const shopDomain =
|
||||
typeof body.shop_domain === 'string' ? normalizeShopDomain(body.shop_domain) : null
|
||||
const clientId = typeof body.client_id === 'string' ? body.client_id.trim() : ''
|
||||
const clientSecret =
|
||||
typeof body.client_secret === 'string' ? body.client_secret.trim() : ''
|
||||
if (!shopDomain) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Ange butikens myshopify.com-adress, t.ex. minbutik.myshopify.com.' },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
if (!clientId || !clientSecret) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Ange både appens klient-ID och klienthemlighet.' },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
const { data: existing } = await auth.supabase
|
||||
.from('shopify_connections')
|
||||
.select('id, status')
|
||||
.eq('company_id', auth.companyId)
|
||||
.eq('status', 'active')
|
||||
if (existing && existing.length > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Företaget har redan en ansluten Shopify-butik. Koppla från den först.' },
|
||||
{ status: 409 },
|
||||
)
|
||||
}
|
||||
|
||||
// Verify before storing: a typo'd secret or missing read_orders scope
|
||||
// must fail here, not at 03:15.
|
||||
let shopInfo
|
||||
try {
|
||||
shopInfo = await testConnectionAndFetchShopInfo({
|
||||
shopDomain,
|
||||
clientId,
|
||||
clientSecret,
|
||||
})
|
||||
} catch (probeError) {
|
||||
log.warn('[shopify] Credential probe failed', {
|
||||
companyId: auth.companyId,
|
||||
message: probeError instanceof Error ? probeError.message : String(probeError),
|
||||
})
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'Kunde inte ansluta till butiken med de angivna uppgifterna. Kontrollera adressen och att appen har behörigheten read_orders.',
|
||||
},
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
const { data: created, error: insertError } = await auth.supabase
|
||||
.from('shopify_connections')
|
||||
.insert({
|
||||
company_id: auth.companyId,
|
||||
user_id: auth.userId,
|
||||
shop_domain: shopDomain,
|
||||
shop_name: shopInfo.name,
|
||||
currency: shopInfo.currency,
|
||||
client_id_encrypted: encryptCredential(clientId),
|
||||
client_secret_encrypted: encryptCredential(clientSecret),
|
||||
status: 'active',
|
||||
connected_at: new Date().toISOString(),
|
||||
transaction_sync_enabled: true,
|
||||
})
|
||||
.select('id, shop_domain')
|
||||
.single()
|
||||
|
||||
if (insertError || !created) {
|
||||
const isConflict = insertError?.code === '23505'
|
||||
log.error('[shopify] Failed to create connection', {
|
||||
message: insertError?.message,
|
||||
code: insertError?.code,
|
||||
companyId: auth.companyId,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: isConflict
|
||||
? 'Butiken är redan ansluten till ett företag.'
|
||||
: 'Kunde inte spara anslutningen. Försök igen.',
|
||||
},
|
||||
{ status: isConflict ? 409 : 500 },
|
||||
)
|
||||
}
|
||||
|
||||
if (ctx?.emit) {
|
||||
try {
|
||||
await ctx.emit({
|
||||
type: 'shopify.connected',
|
||||
payload: {
|
||||
connectionId: created.id,
|
||||
shopDomain: created.shop_domain,
|
||||
userId: auth.userId,
|
||||
companyId: auth.companyId,
|
||||
},
|
||||
})
|
||||
} catch {
|
||||
// Audit event failure must not block the connect itself.
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, connection_id: created.id })
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/sync',
|
||||
handler: async (_request: Request, ctx?: ExtensionContext) => {
|
||||
const log = ctx?.log ?? console
|
||||
const auth = await requireUserAndCompany(ctx)
|
||||
if (auth instanceof NextResponse) return auth
|
||||
|
||||
const capabilityBlocked = await requireCapability(
|
||||
auth.supabase,
|
||||
auth.companyId,
|
||||
CAPABILITY.shopify_sync,
|
||||
)
|
||||
if (capabilityBlocked) return capabilityBlocked
|
||||
|
||||
const rl = await checkRateLimit({
|
||||
prefix: 'shopify:sync',
|
||||
identifier: auth.userId,
|
||||
...RATE_LIMIT_SYNC,
|
||||
})
|
||||
if (!rl.ok) return rl.response!
|
||||
|
||||
// Membership-scoped lookup via the user client; the sync itself runs on
|
||||
// the service client (cursor updates and ingest are service paths). The
|
||||
// manual button ignores transaction_sync_enabled (that flag gates the
|
||||
// nightly cron): pressing it IS the opt-in.
|
||||
const { data: connection } = await auth.supabase
|
||||
.from('shopify_connections')
|
||||
.select('*')
|
||||
.eq('company_id', auth.companyId)
|
||||
.eq('status', 'active')
|
||||
.maybeSingle()
|
||||
|
||||
if (!connection) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Ingen ansluten Shopify-butik.' },
|
||||
{ status: 404 },
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const serviceClient = createServiceClientNoCookies()
|
||||
// Bounded like the cron: without a deadline a huge first sync would be
|
||||
// killed at the dispatcher's maxDuration with no cursor persisted;
|
||||
// with one it stops cleanly, reports a partial sync and resumes where
|
||||
// it stopped on the next press.
|
||||
const summary = await syncShopifyOrders(
|
||||
serviceClient,
|
||||
connection as ShopifyConnection,
|
||||
undefined,
|
||||
Date.now() + 240_000,
|
||||
)
|
||||
return NextResponse.json({ success: true, transactions: summary })
|
||||
} catch (error) {
|
||||
log.error('[shopify] Manual sync failed', {
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
connection_id: connection.id,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'Synkroniseringen misslyckades. Försök igen.' },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/transaction-sync',
|
||||
handler: async (request: Request, ctx?: ExtensionContext) => {
|
||||
const auth = await requireUserAndCompany(ctx)
|
||||
if (auth instanceof NextResponse) return auth
|
||||
|
||||
const capabilityBlocked = await requireCapability(
|
||||
auth.supabase,
|
||||
auth.companyId,
|
||||
CAPABILITY.shopify_sync,
|
||||
)
|
||||
if (capabilityBlocked) return capabilityBlocked
|
||||
|
||||
const rl = await checkRateLimit({
|
||||
prefix: 'shopify:transaction-sync-toggle',
|
||||
identifier: auth.userId,
|
||||
...RATE_LIMIT_SYNC,
|
||||
})
|
||||
if (!rl.ok) return rl.response!
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as { enabled?: unknown }
|
||||
if (typeof body.enabled !== 'boolean') {
|
||||
return NextResponse.json({ error: 'enabled (boolean) krävs.' }, { status: 400 })
|
||||
}
|
||||
|
||||
const { data: updated, error: updateError } = await auth.supabase
|
||||
.from('shopify_connections')
|
||||
.update({ transaction_sync_enabled: body.enabled })
|
||||
.eq('company_id', auth.companyId)
|
||||
.eq('status', 'active')
|
||||
.select('id')
|
||||
|
||||
if (updateError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Kunde inte spara inställningen. Försök igen.' },
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
if (!updated || updated.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Ingen ansluten Shopify-butik.' },
|
||||
{ status: 404 },
|
||||
)
|
||||
}
|
||||
return NextResponse.json({ success: true, enabled: body.enabled })
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'DELETE',
|
||||
path: '/disconnect',
|
||||
handler: async (request: Request, ctx?: ExtensionContext) => {
|
||||
const log = ctx?.log ?? console
|
||||
const auth = await requireUserAndCompany(ctx)
|
||||
if (auth instanceof NextResponse) return auth
|
||||
|
||||
const rl = await checkRateLimit({
|
||||
prefix: 'shopify:disconnect',
|
||||
identifier: auth.userId,
|
||||
...RATE_LIMIT_DISCONNECT,
|
||||
})
|
||||
if (!rl.ok) return rl.response!
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as { connection_id?: string }
|
||||
const base = auth.supabase
|
||||
.from('shopify_connections')
|
||||
.select('id, status, shop_domain')
|
||||
.eq('company_id', auth.companyId)
|
||||
const query = body.connection_id
|
||||
? base.eq('id', body.connection_id).limit(1)
|
||||
: base.neq('status', 'revoked').order('created_at', { ascending: false }).limit(1)
|
||||
const { data: rows, error: findError } = await query
|
||||
const connection = rows?.[0]
|
||||
|
||||
if (findError || !connection) {
|
||||
return NextResponse.json({ error: 'Connection not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// There is no remote revoke API for a merchant-owned custom app: the
|
||||
// app lives in the merchant's Dev Dashboard and only they can delete it
|
||||
// or rotate its secret. We drop our copy of the credentials outright
|
||||
// (nothing reads them after revoke, and a reconnect inserts a fresh
|
||||
// row); the audit row keeps shop_domain and the connect/disconnect
|
||||
// timestamps. The panel tells the user to remove the app in Shopify too.
|
||||
const { error: updateError } = await auth.supabase
|
||||
.from('shopify_connections')
|
||||
.update({
|
||||
status: 'revoked',
|
||||
client_id_encrypted: null,
|
||||
client_secret_encrypted: null,
|
||||
disconnected_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', connection.id)
|
||||
.eq('company_id', auth.companyId)
|
||||
|
||||
if (updateError) {
|
||||
log.error('[shopify] Failed to mark connection revoked', {
|
||||
message: updateError.message,
|
||||
connection_id: connection.id,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'Kunde inte koppla från. Försök igen.' },
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
|
||||
if (ctx?.emit) {
|
||||
try {
|
||||
await ctx.emit({
|
||||
type: 'shopify.disconnected',
|
||||
payload: {
|
||||
connectionId: connection.id,
|
||||
shopDomain: connection.shop_domain ?? null,
|
||||
reason: 'user',
|
||||
userId: auth.userId,
|
||||
companyId: auth.companyId,
|
||||
},
|
||||
})
|
||||
} catch {
|
||||
// Audit event failure must not block the disconnect itself.
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,413 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useLocale, useTranslations } from 'next-intl'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { useFormat } from '@/lib/hooks/use-format'
|
||||
import { failureDescription } from '@/lib/browser/action-failure'
|
||||
import type { ErrorLocale } from '@/lib/errors/get-error-message'
|
||||
import { KeyRound, Loader2, RefreshCw, ShoppingBag, Unlink } from 'lucide-react'
|
||||
import {
|
||||
shopifyRequest,
|
||||
syncSummary,
|
||||
SHOPIFY_CONNECT_TIMEOUT_MS,
|
||||
SHOPIFY_SYNC_TIMEOUT_MS,
|
||||
type ShopifySyncPayload,
|
||||
} from '../lib/settings-actions'
|
||||
import type { ShopifyStatusResponse } from '../types'
|
||||
|
||||
type ConnectionInfo = NonNullable<ShopifyStatusResponse['connection']>
|
||||
|
||||
const STATUS_VARIANT: Record<ConnectionInfo['status'], 'success' | 'secondary' | 'destructive' | 'warning'> = {
|
||||
active: 'success',
|
||||
pending: 'secondary',
|
||||
revoked: 'warning',
|
||||
error: 'destructive',
|
||||
}
|
||||
|
||||
export default function ShopifySettingsPanel() {
|
||||
const t = useTranslations('shopify')
|
||||
const tCommon = useTranslations('common')
|
||||
const locale = useLocale() as ErrorLocale
|
||||
const { toast } = useToast()
|
||||
const { formatDateLong } = useFormat()
|
||||
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadFailed, setLoadFailed] = useState(false)
|
||||
const [configured, setConfigured] = useState(false)
|
||||
const [connection, setConnection] = useState<ConnectionInfo | null>(null)
|
||||
const [shopDomain, setShopDomain] = useState('')
|
||||
const [clientId, setClientId] = useState('')
|
||||
const [clientSecret, setClientSecret] = useState('')
|
||||
const [connecting, setConnecting] = useState(false)
|
||||
const [disconnecting, setDisconnecting] = useState(false)
|
||||
const [confirmDisconnect, setConfirmDisconnect] = useState(false)
|
||||
const [syncing, setSyncing] = useState(false)
|
||||
const [togglingTransactionSync, setTogglingTransactionSync] = useState(false)
|
||||
|
||||
const failureCopy = { timeout: t('action_timeout'), network: t('action_network') }
|
||||
|
||||
const loadStatus = useCallback(async () => {
|
||||
// A failed status read must never render as "not configured" (see the
|
||||
// Stripe panel: that copy sends the user to an administrator for nothing).
|
||||
const result = await shopifyRequest<ShopifyStatusResponse>({
|
||||
url: '/api/extensions/ext/shopify/status',
|
||||
method: 'GET',
|
||||
locale,
|
||||
})
|
||||
setLoading(false)
|
||||
if (!result.ok || !result.data) {
|
||||
setLoadFailed(true)
|
||||
return
|
||||
}
|
||||
setLoadFailed(false)
|
||||
setConfigured(result.data.configured)
|
||||
setConnection(result.data.connection)
|
||||
}, [locale])
|
||||
|
||||
useEffect(() => {
|
||||
void loadStatus()
|
||||
}, [loadStatus])
|
||||
|
||||
function retryLoadStatus() {
|
||||
setLoading(true)
|
||||
void loadStatus()
|
||||
}
|
||||
|
||||
async function handleConnect() {
|
||||
if (connecting) return
|
||||
setConnecting(true)
|
||||
try {
|
||||
const result = await shopifyRequest({
|
||||
url: '/api/extensions/ext/shopify/connect',
|
||||
body: {
|
||||
shop_domain: shopDomain,
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
},
|
||||
locale,
|
||||
timeoutMs: SHOPIFY_CONNECT_TIMEOUT_MS,
|
||||
})
|
||||
if (!result.ok) {
|
||||
toast({
|
||||
title: t('connect_failed_title'),
|
||||
description: failureDescription(result, failureCopy),
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
toast({ title: t('connected_toast_title'), description: t('connected_toast_description') })
|
||||
setClientId('')
|
||||
setClientSecret('')
|
||||
await loadStatus()
|
||||
} finally {
|
||||
setConnecting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSyncNow() {
|
||||
if (syncing) return
|
||||
setSyncing(true)
|
||||
try {
|
||||
const result = await shopifyRequest<ShopifySyncPayload>({
|
||||
url: '/api/extensions/ext/shopify/sync',
|
||||
locale,
|
||||
timeoutMs: SHOPIFY_SYNC_TIMEOUT_MS,
|
||||
})
|
||||
if (!result.ok) {
|
||||
toast({
|
||||
title: t('sync_failed_title'),
|
||||
description: failureDescription(result, failureCopy),
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
const summary = syncSummary(result.data)
|
||||
if (summary.reason === 'revoked') {
|
||||
toast({
|
||||
title: t('sync_failed_title'),
|
||||
description: t('sync_revoked'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
} else if (summary.reason === 'partial') {
|
||||
toast({ title: t('sync_partial_title'), description: t('sync_partial', summary.values) })
|
||||
} else if (summary.reason === 'empty') {
|
||||
toast({ title: t('sync_done_title'), description: t('sync_done_empty') })
|
||||
} else if (summary.reason === 'errors') {
|
||||
toast({ title: t('sync_done_title'), description: t('sync_done_feed_errors', summary.values) })
|
||||
} else if (summary.reason === 'feed') {
|
||||
toast({ title: t('sync_done_title'), description: t('sync_done_feed', summary.values) })
|
||||
} else {
|
||||
toast({ title: t('sync_done_title') })
|
||||
}
|
||||
await loadStatus()
|
||||
} finally {
|
||||
setSyncing(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleTransactionSync(enabled: boolean) {
|
||||
if (togglingTransactionSync) return
|
||||
setTogglingTransactionSync(true)
|
||||
try {
|
||||
const result = await shopifyRequest({
|
||||
url: '/api/extensions/ext/shopify/transaction-sync',
|
||||
body: { enabled },
|
||||
locale,
|
||||
})
|
||||
if (!result.ok) {
|
||||
toast({
|
||||
title: t('transaction_sync_toggle_failed'),
|
||||
description: failureDescription(result, failureCopy),
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
toast({
|
||||
title: enabled
|
||||
? t('transaction_sync_enabled_toast')
|
||||
: t('transaction_sync_disabled_toast'),
|
||||
})
|
||||
await loadStatus()
|
||||
} finally {
|
||||
setTogglingTransactionSync(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDisconnect() {
|
||||
if (!connection || disconnecting) return
|
||||
setDisconnecting(true)
|
||||
try {
|
||||
const result = await shopifyRequest({
|
||||
url: '/api/extensions/ext/shopify/disconnect',
|
||||
method: 'DELETE',
|
||||
body: { connection_id: connection.id },
|
||||
locale,
|
||||
})
|
||||
if (!result.ok) {
|
||||
toast({
|
||||
title: t('disconnect_failed_title'),
|
||||
description: failureDescription(result, failureCopy),
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
// The app still exists in the merchant's Shopify Dev Dashboard; only
|
||||
// they can delete it there, so the toast says so.
|
||||
toast({ title: t('disconnected_toast_title'), description: t('disconnected_toast_description') })
|
||||
setConfirmDisconnect(false)
|
||||
await loadStatus()
|
||||
} finally {
|
||||
setDisconnecting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="space-y-3 p-6">
|
||||
<Skeleton className="h-5 w-48" />
|
||||
<Skeleton className="h-4 w-72" />
|
||||
<Skeleton className="h-10 w-40" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (loadFailed) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t('title')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 pt-0">
|
||||
<p className="text-sm text-destructive">{t('load_failed')}</p>
|
||||
<Button variant="outline" size="sm" onClick={retryLoadStatus}>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
{tCommon('retry')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (!configured) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t('title')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<p className="text-sm text-muted-foreground">{t('not_configured')}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const isActive = connection?.status === 'active'
|
||||
const showConnectForm = !connection || !isActive
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t('title')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6 pt-0">
|
||||
<p className="text-sm text-muted-foreground">{t('description')}</p>
|
||||
|
||||
{connection && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 rounded-lg border border-border p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<ShoppingBag className="h-5 w-5 text-muted-foreground" />
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">
|
||||
{connection.shop_name || connection.shop_domain || t('unnamed_store')}
|
||||
</span>
|
||||
<Badge variant={STATUS_VARIANT[connection.status]}>
|
||||
{t(`status_${connection.status}`)}
|
||||
</Badge>
|
||||
</div>
|
||||
{connection.shop_name && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">{connection.shop_domain}</p>
|
||||
)}
|
||||
{isActive && connection.connected_at && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t('connected_since', { date: formatDateLong(connection.connected_at) })}
|
||||
</p>
|
||||
)}
|
||||
{connection.error_message && (
|
||||
// Shown for active connections too: a sync that cannot run
|
||||
// (e.g. cash-account currency conflict) must not hide
|
||||
// behind a healthy-looking "Ansluten" badge.
|
||||
<p className="mt-1 text-sm text-destructive">{connection.error_message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isActive && (
|
||||
confirmDisconnect ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={handleDisconnect}
|
||||
disabled={disconnecting}
|
||||
>
|
||||
{t('disconnect_confirm')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setConfirmDisconnect(false)}
|
||||
disabled={disconnecting}
|
||||
>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleSyncNow} disabled={syncing}>
|
||||
{syncing ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{syncing ? t('syncing') : t('sync_now')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setConfirmDisconnect(true)}>
|
||||
<Unlink className="mr-2 h-4 w-4" />
|
||||
{t('disconnect')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showConnectForm && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">{t('connect_hint')}</p>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="shopify-shop-domain">{t('shop_domain_label')}</Label>
|
||||
<Input
|
||||
id="shopify-shop-domain"
|
||||
inputMode="url"
|
||||
placeholder="minbutik.myshopify.com"
|
||||
value={shopDomain}
|
||||
onChange={(e) => setShopDomain(e.target.value)}
|
||||
disabled={connecting}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="shopify-client-id">{t('client_id_label')}</Label>
|
||||
<Input
|
||||
id="shopify-client-id"
|
||||
autoComplete="off"
|
||||
value={clientId}
|
||||
onChange={(e) => setClientId(e.target.value)}
|
||||
disabled={connecting}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="shopify-client-secret">{t('client_secret_label')}</Label>
|
||||
<Input
|
||||
id="shopify-client-secret"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={clientSecret}
|
||||
onChange={(e) => setClientSecret(e.target.value)}
|
||||
disabled={connecting}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleConnect}
|
||||
disabled={connecting || !shopDomain || !clientId || !clientSecret}
|
||||
>
|
||||
{connecting ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<KeyRound className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{connecting ? t('connecting') : t('connect')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isActive && connection && (
|
||||
<div className="flex flex-wrap items-start justify-between gap-4 rounded-lg border border-border p-4">
|
||||
<div className="min-w-0 max-w-prose space-y-1">
|
||||
<p className="text-sm font-medium">{t('transaction_sync_title')}</p>
|
||||
<p className="text-sm text-muted-foreground">{t('transaction_sync_description')}</p>
|
||||
{connection.transaction_sync_enabled ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{connection.last_order_synced_at
|
||||
? t('transaction_sync_last_synced', {
|
||||
date: formatDateLong(connection.last_order_synced_at),
|
||||
})
|
||||
: t('transaction_sync_never_synced')}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('transaction_sync_backfill_note')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Switch
|
||||
checked={connection.transaction_sync_enabled}
|
||||
onCheckedChange={handleToggleTransactionSync}
|
||||
disabled={togglingTransactionSync}
|
||||
aria-label={t('transaction_sync_title')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { Extension } from '@/lib/extensions/types'
|
||||
import { shopifyApiRoutes } from './api-routes'
|
||||
|
||||
/**
|
||||
* Shopify extension
|
||||
*
|
||||
* Connects a company's Shopify store via a merchant-created Dev Dashboard
|
||||
* custom app (client credentials grant; the revealable shpat_ token flow was
|
||||
* discontinued 2026-01-01) and imports the store's paid orders and refunds
|
||||
* into the transactions inbox as a bank-style feed on the 1584 cash account.
|
||||
* Feed-only (same doctrine as the Stripe and WooCommerce feeds): nothing is
|
||||
* auto-booked, and Shopify Payments payout/fee reconciliation is out of
|
||||
* scope for now (phase 2).
|
||||
*
|
||||
* Required environment variables:
|
||||
* - SHOPIFY_CREDENTIALS_ENCRYPTION_KEY (at-rest key for client id/secret)
|
||||
*/
|
||||
export const shopifyExtension: Extension = {
|
||||
id: 'shopify',
|
||||
name: 'Shopify',
|
||||
version: '1.0.0',
|
||||
sector: 'general',
|
||||
|
||||
settingsPanel: {
|
||||
label: 'Shopify',
|
||||
path: '/import?mode=shopify',
|
||||
},
|
||||
|
||||
apiRoutes: shopifyApiRoutes,
|
||||
}
|
||||
|
||||
export default shopifyExtension
|
||||
@@ -0,0 +1,349 @@
|
||||
import type { ShopifyOrder, ShopifyShopInfo } from '../types'
|
||||
|
||||
/**
|
||||
* Minimal Shopify GraphQL Admin API client for the order feed.
|
||||
*
|
||||
* Auth is the client credentials grant: the merchant creates a custom app in
|
||||
* their own Shopify Dev Dashboard (the admin-created custom apps with
|
||||
* revealable shpat_ tokens were discontinued 2026-01-01) and pastes the app's
|
||||
* client id/secret; the server exchanges those for a ~24h access token at the
|
||||
* start of every run. REST is legacy since 2024, so everything here is
|
||||
* GraphQL against a pinned API version.
|
||||
*
|
||||
* Rate limiting is cost-based (points/second leaky bucket); a throttled query
|
||||
* comes back as HTTP 200 with a THROTTLED GraphQL error, so both that and
|
||||
* plain 429/5xx get a short backoff before the error is surfaced.
|
||||
*/
|
||||
|
||||
/** Pinned Admin API version; bump quarterly (supported >= 12 months). */
|
||||
export const SHOPIFY_API_VERSION = '2026-07'
|
||||
/** Orders per page; the API caps `first` at 250, 100 keeps query cost low. */
|
||||
export const SHOPIFY_PAGE_SIZE = 100
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 30_000
|
||||
const RETRYABLE_STATUS = new Set([429, 502, 503, 504])
|
||||
const RETRY_DELAYS_MS = [1_000, 3_000]
|
||||
|
||||
export interface ShopifyCredentials {
|
||||
/** Normalized myshopify.com domain. */
|
||||
shopDomain: string
|
||||
clientId: string
|
||||
clientSecret: string
|
||||
}
|
||||
|
||||
/** A run-scoped session: the exchanged token lives ~24h and is never stored. */
|
||||
export interface ShopifySession {
|
||||
shopDomain: string
|
||||
accessToken: string
|
||||
}
|
||||
|
||||
export class ShopifyApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
/** HTTP status, or 0 for network-level / GraphQL-level failures. */
|
||||
readonly status: number,
|
||||
/** GraphQL error code (e.g. ACCESS_DENIED) or OAuth error, if any. */
|
||||
readonly code: string | null = null,
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'ShopifyApiError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an API error means the credentials themselves are dead (app deleted
|
||||
* or secret rotated in the Dev Dashboard), as opposed to a transient failure.
|
||||
* Used to flip a connection to status 'revoked' so the UI offers a reconnect
|
||||
* instead of the cron retrying forever.
|
||||
*/
|
||||
export function isRevokedCredentialsError(error: unknown): boolean {
|
||||
if (!(error instanceof ShopifyApiError)) return false
|
||||
return error.status === 401 || error.status === 403
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize user input to a bare myshopify.com domain. Accepts the domain
|
||||
* with or without scheme/path, the bare store handle, and a pasted admin URL
|
||||
* (admin.shopify.com/store/<handle>). Anything that does not resolve to
|
||||
* <handle>.myshopify.com returns null: the Admin API only lives there, and
|
||||
* refusing arbitrary hosts doubles as the SSRF guard (the server never
|
||||
* fetches a user-controlled hostname).
|
||||
*/
|
||||
export function normalizeShopDomain(input: string): string | null {
|
||||
let value = input.trim().toLowerCase()
|
||||
if (!value) return null
|
||||
const adminMatch =
|
||||
/^(?:https?:\/\/)?admin\.shopify\.com\/store\/([a-z0-9][a-z0-9-]*)(?:[/?#]|$)/.exec(value)
|
||||
if (adminMatch) return `${adminMatch[1]}.myshopify.com`
|
||||
value = value.replace(/^https?:\/\//, '')
|
||||
value = value.split(/[/?#]/)[0]
|
||||
if (!value) return null
|
||||
if (!value.includes('.')) value = `${value}.myshopify.com`
|
||||
return /^[a-z0-9][a-z0-9-]*\.myshopify\.com$/.test(value) ? value : null
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
async function postJson(url: string, body: unknown, headers: Record<string, string>) {
|
||||
return fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...headers },
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange the custom app's client id/secret for an access token
|
||||
* (client credentials grant; expires_in ~86400s). Any 4xx here means the
|
||||
* credentials are unusable (invalid_client, app uninstalled, plan gate), so
|
||||
* it is reported as status 401 and classified as revoked; 429/5xx retry on
|
||||
* the normal backoff schedule.
|
||||
*/
|
||||
export async function exchangeAccessToken(creds: ShopifyCredentials): Promise<string> {
|
||||
let lastError: unknown
|
||||
for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt++) {
|
||||
let response: Response
|
||||
try {
|
||||
response = await postJson(
|
||||
`https://${creds.shopDomain}/admin/oauth/access_token`,
|
||||
{
|
||||
client_id: creds.clientId,
|
||||
client_secret: creds.clientSecret,
|
||||
grant_type: 'client_credentials',
|
||||
},
|
||||
{},
|
||||
)
|
||||
} catch (err) {
|
||||
lastError = new ShopifyApiError(
|
||||
`Shopify token exchange failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
0,
|
||||
)
|
||||
if (attempt < RETRY_DELAYS_MS.length) {
|
||||
await sleep(RETRY_DELAYS_MS[attempt])
|
||||
continue
|
||||
}
|
||||
throw lastError
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
const body = (await response.json().catch(() => null)) as {
|
||||
access_token?: unknown
|
||||
} | null
|
||||
if (typeof body?.access_token === 'string' && body.access_token) {
|
||||
return body.access_token
|
||||
}
|
||||
throw new ShopifyApiError('Shopify token exchange returned no access token', 0)
|
||||
}
|
||||
|
||||
if (RETRYABLE_STATUS.has(response.status) && attempt < RETRY_DELAYS_MS.length) {
|
||||
lastError = new ShopifyApiError(
|
||||
`Shopify token exchange ${response.status}`,
|
||||
response.status,
|
||||
)
|
||||
await sleep(RETRY_DELAYS_MS[attempt])
|
||||
continue
|
||||
}
|
||||
|
||||
const errorBody = (await response.json().catch(() => null)) as {
|
||||
error?: string
|
||||
error_description?: string
|
||||
} | null
|
||||
// Only non-retryable 4xx means the credentials are unusable. A 429 that
|
||||
// survived every retry is still throttling, not revocation: mapping it to
|
||||
// 401 would classify as revoked and delete the stored credentials.
|
||||
const credentialFailure =
|
||||
response.status >= 400 &&
|
||||
response.status < 500 &&
|
||||
!RETRYABLE_STATUS.has(response.status)
|
||||
throw new ShopifyApiError(
|
||||
`Shopify token exchange ${response.status}${
|
||||
errorBody?.error_description ? `: ${errorBody.error_description}` : ''
|
||||
}`,
|
||||
credentialFailure ? 401 : response.status,
|
||||
errorBody?.error ?? null,
|
||||
)
|
||||
}
|
||||
throw lastError instanceof Error
|
||||
? lastError
|
||||
: new ShopifyApiError('Shopify token exchange failed', 0)
|
||||
}
|
||||
|
||||
/** Exchange the credentials for a run-scoped session. */
|
||||
export async function createShopifySession(
|
||||
creds: ShopifyCredentials,
|
||||
): Promise<ShopifySession> {
|
||||
return { shopDomain: creds.shopDomain, accessToken: await exchangeAccessToken(creds) }
|
||||
}
|
||||
|
||||
interface GraphQLErrorShape {
|
||||
message?: string
|
||||
extensions?: { code?: string }
|
||||
}
|
||||
|
||||
/**
|
||||
* POST one GraphQL query. Retries THROTTLED (HTTP 200 + GraphQL error code),
|
||||
* 429/5xx and network errors with a short backoff; 401/403 (token expired
|
||||
* mid-run, app revoked) and ACCESS_DENIED (scope missing) throw a
|
||||
* ShopifyApiError that classifies as revoked.
|
||||
*/
|
||||
export async function shopifyGraphQL<T>(
|
||||
session: ShopifySession,
|
||||
query: string,
|
||||
variables: Record<string, unknown> = {},
|
||||
): Promise<T> {
|
||||
const url = `https://${session.shopDomain}/admin/api/${SHOPIFY_API_VERSION}/graphql.json`
|
||||
let lastError: unknown
|
||||
for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt++) {
|
||||
let response: Response
|
||||
try {
|
||||
response = await postJson(url, { query, variables }, {
|
||||
'X-Shopify-Access-Token': session.accessToken,
|
||||
})
|
||||
} catch (err) {
|
||||
lastError = new ShopifyApiError(
|
||||
`Shopify request failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
0,
|
||||
)
|
||||
if (attempt < RETRY_DELAYS_MS.length) {
|
||||
await sleep(RETRY_DELAYS_MS[attempt])
|
||||
continue
|
||||
}
|
||||
throw lastError
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (RETRYABLE_STATUS.has(response.status) && attempt < RETRY_DELAYS_MS.length) {
|
||||
lastError = new ShopifyApiError(`Shopify API ${response.status}`, response.status)
|
||||
await sleep(RETRY_DELAYS_MS[attempt])
|
||||
continue
|
||||
}
|
||||
throw new ShopifyApiError(`Shopify API ${response.status}`, response.status)
|
||||
}
|
||||
|
||||
const body = (await response.json().catch(() => null)) as {
|
||||
data?: T
|
||||
errors?: GraphQLErrorShape[]
|
||||
} | null
|
||||
|
||||
if (body?.errors && body.errors.length > 0) {
|
||||
const throttled = body.errors.some((e) => e.extensions?.code === 'THROTTLED')
|
||||
if (throttled && attempt < RETRY_DELAYS_MS.length) {
|
||||
lastError = new ShopifyApiError('Shopify API throttled', 0, 'THROTTLED')
|
||||
await sleep(RETRY_DELAYS_MS[attempt])
|
||||
continue
|
||||
}
|
||||
const accessDenied = body.errors.some((e) => e.extensions?.code === 'ACCESS_DENIED')
|
||||
const message = body.errors[0]?.message ?? 'unknown GraphQL error'
|
||||
throw new ShopifyApiError(
|
||||
`Shopify GraphQL error: ${message}`,
|
||||
accessDenied ? 403 : 0,
|
||||
body.errors[0]?.extensions?.code ?? null,
|
||||
)
|
||||
}
|
||||
|
||||
if (!body?.data) {
|
||||
throw new ShopifyApiError('Shopify API returned no data', 0)
|
||||
}
|
||||
return body.data
|
||||
}
|
||||
throw lastError instanceof Error
|
||||
? lastError
|
||||
: new ShopifyApiError('Shopify request failed', 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Order fields for the feed. Deliberately NO customer/PII fields (customer,
|
||||
* email, addresses): they are gated behind Shopify's protected customer data
|
||||
* program, and a bookkeeping feed does not need them; the verifikat reference
|
||||
* is the order name/id. Refunds come inline (plain list, not a connection),
|
||||
* so no per-order follow-up requests are needed.
|
||||
*/
|
||||
const ORDERS_QUERY = `
|
||||
query OrdersFeed($first: Int!, $after: String, $query: String) {
|
||||
orders(first: $first, after: $after, query: $query, sortKey: UPDATED_AT) {
|
||||
pageInfo { hasNextPage endCursor }
|
||||
nodes {
|
||||
legacyResourceId
|
||||
name
|
||||
test
|
||||
processedAt
|
||||
updatedAt
|
||||
displayFinancialStatus
|
||||
paymentGatewayNames
|
||||
totalPriceSet { shopMoney { amount currencyCode } }
|
||||
refunds {
|
||||
legacyResourceId
|
||||
createdAt
|
||||
totalRefundedSet { shopMoney { amount currencyCode } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
export interface OrdersPage {
|
||||
orders: ShopifyOrder[]
|
||||
hasNextPage: boolean
|
||||
endCursor: string | null
|
||||
}
|
||||
|
||||
export interface ListOrdersOptions {
|
||||
/** ISO timestamp; orders with updated_at >= this are listed. */
|
||||
updatedAtMin: string
|
||||
/** Relay cursor from the previous page's endCursor, or null for page one. */
|
||||
after: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* One page of orders updated on/after the window start, oldest-updated first
|
||||
* so the caller's cursor advances chronologically. Relay cursor pagination is
|
||||
* stable across same-second ties, so no offset fallback is needed (unlike the
|
||||
* WooCommerce client).
|
||||
*/
|
||||
export async function listOrdersPage(
|
||||
session: ShopifySession,
|
||||
options: ListOrdersOptions,
|
||||
): Promise<OrdersPage> {
|
||||
const data = await shopifyGraphQL<{
|
||||
orders: {
|
||||
pageInfo: { hasNextPage: boolean; endCursor: string | null }
|
||||
nodes: ShopifyOrder[]
|
||||
}
|
||||
}>(session, ORDERS_QUERY, {
|
||||
first: SHOPIFY_PAGE_SIZE,
|
||||
after: options.after,
|
||||
query: `updated_at:>='${options.updatedAtMin}'`,
|
||||
})
|
||||
return {
|
||||
orders: data.orders.nodes,
|
||||
hasNextPage: data.orders.pageInfo.hasNextPage,
|
||||
endCursor: data.orders.pageInfo.endCursor,
|
||||
}
|
||||
}
|
||||
|
||||
const PROBE_QUERY = `
|
||||
query ConnectProbe {
|
||||
shop { name currencyCode }
|
||||
orders(first: 1) { nodes { legacyResourceId } }
|
||||
}`
|
||||
|
||||
/**
|
||||
* Verify credentials and read shop metadata. The one-order probe inside the
|
||||
* query is the authoritative check: it exercises the read_orders scope the
|
||||
* feed needs, so an app created without that scope fails here (ACCESS_DENIED)
|
||||
* instead of at 03:15.
|
||||
*/
|
||||
export async function testConnectionAndFetchShopInfo(
|
||||
creds: ShopifyCredentials,
|
||||
): Promise<ShopifyShopInfo> {
|
||||
const session = await createShopifySession(creds)
|
||||
const data = await shopifyGraphQL<{
|
||||
shop: { name: string | null; currencyCode: string | null }
|
||||
}>(session, PROBE_QUERY)
|
||||
return {
|
||||
name: data.shop?.name ?? null,
|
||||
currency: data.shop?.currencyCode ? data.shop.currencyCode.toUpperCase() : null,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import crypto from 'crypto'
|
||||
|
||||
/**
|
||||
* At-rest encryption for Shopify custom-app client id/secret.
|
||||
*
|
||||
* AES-256-GCM with a dedicated env key, mirroring the WooCommerce credential
|
||||
* store (extensions/general/woocommerce/lib/credentials.ts) and the
|
||||
* Skatteverket token store: 12-byte IV, 16-byte auth tag, layout
|
||||
* iv|tag|ciphertext, base64url encoded. The key is deployment-wide (not
|
||||
* per-tenant); what makes rows useless off-server is that
|
||||
* SHOPIFY_CREDENTIALS_ENCRYPTION_KEY never leaves the environment.
|
||||
*/
|
||||
|
||||
const ALGORITHM = 'aes-256-gcm'
|
||||
|
||||
/** Whether the integration is configured on this deployment. */
|
||||
export function isShopifyConfigured(): boolean {
|
||||
return Boolean(process.env.SHOPIFY_CREDENTIALS_ENCRYPTION_KEY)
|
||||
}
|
||||
|
||||
function getEncryptionKey(): Buffer {
|
||||
const key = process.env.SHOPIFY_CREDENTIALS_ENCRYPTION_KEY
|
||||
if (!key) throw new Error('SHOPIFY_CREDENTIALS_ENCRYPTION_KEY is required')
|
||||
return crypto.createHash('sha256').update(key).digest()
|
||||
}
|
||||
|
||||
export function encryptCredential(plaintext: string): string {
|
||||
const key = getEncryptionKey()
|
||||
const iv = crypto.randomBytes(12)
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, key, iv)
|
||||
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
|
||||
const tag = cipher.getAuthTag()
|
||||
return Buffer.concat([iv, tag, encrypted]).toString('base64url')
|
||||
}
|
||||
|
||||
export function decryptCredential(ciphertext: string): string {
|
||||
const key = getEncryptionKey()
|
||||
const combined = Buffer.from(ciphertext, 'base64url')
|
||||
const iv = combined.subarray(0, 12)
|
||||
const tag = combined.subarray(12, 28)
|
||||
const encrypted = combined.subarray(28)
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv)
|
||||
decipher.setAuthTag(tag)
|
||||
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8')
|
||||
}
|
||||
|
||||
/** Decrypted API credentials for an active connection. */
|
||||
export function credentialsOf(connection: {
|
||||
shop_domain: string
|
||||
client_id_encrypted: string | null
|
||||
client_secret_encrypted: string | null
|
||||
}): { shopDomain: string; clientId: string; clientSecret: string } {
|
||||
if (!connection.client_id_encrypted || !connection.client_secret_encrypted) {
|
||||
throw new Error('Connection has no stored credentials')
|
||||
}
|
||||
return {
|
||||
shopDomain: connection.shop_domain,
|
||||
clientId: decryptCredential(connection.client_id_encrypted),
|
||||
clientSecret: decryptCredential(connection.client_secret_encrypted),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,564 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { ingestTransactions } from '@/lib/transactions/ingest'
|
||||
import { ensureManualCashAccount } from '@/lib/cash-accounts/service'
|
||||
import { syncMappedAccounts } from '@/lib/import/account-sync'
|
||||
import { createLogger, type Logger } from '@/lib/logger'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import type { RawTransaction } from '@/types'
|
||||
import {
|
||||
createShopifySession,
|
||||
isRevokedCredentialsError,
|
||||
listOrdersPage,
|
||||
} from './api-client'
|
||||
import { credentialsOf } from './credentials'
|
||||
import type { ShopifyConnection, ShopifyOrder, ShopifyRefund } from '../types'
|
||||
|
||||
const defaultLog = createLogger('shopify/order-sync')
|
||||
|
||||
/**
|
||||
* Shopify order sync: the store's paid orders and refunds treated as a
|
||||
* bank-style feed.
|
||||
*
|
||||
* The store becomes a cash account on ledger 1584 (Fordringar Shopify
|
||||
* Payments in the 158x sub-account convention: money the payment gateways owe
|
||||
* the merchant), and orders land in the transactions inbox exactly like PSD2
|
||||
* bank rows: deduped on external_id, bound to the cash account so booking
|
||||
* settles against 1584, and categorized/booked by the user through the normal
|
||||
* flows. Nothing here auto-books (feed-only doctrine, same as the Stripe and
|
||||
* WooCommerce feeds). 1680 and 1686 are owned by those feeds, and
|
||||
* cash_accounts enforces one account per ledger per company.
|
||||
*
|
||||
* Row model: a paid order produces one positive row for its gross total; each
|
||||
* refund produces one negative row. Payment-processor fees never appear in
|
||||
* this feed: order-level fee data only exists for Shopify Payments and payout
|
||||
* reconciliation is a separate concern (phase 2); external gateways (Klarna,
|
||||
* Stripe) report no fees through Shopify at all. The gateway names ride along
|
||||
* as the row reference for later gateway-side reconciliation.
|
||||
*
|
||||
* Pagination: one fixed updated_at window per run, walked with Relay cursors
|
||||
* (sortKey UPDATED_AT ascending). Cursors are stable across same-second ties,
|
||||
* so there is no offset fallback (unlike the WooCommerce sync). The persisted
|
||||
* cursor is shopify_connections.last_order_synced_at: per page the max
|
||||
* updatedAt processed, and after a fully-listed window the run's start time
|
||||
* (a scanned-through watermark, so quiet and empty-first-run stores still
|
||||
* rotate to the back of the cron's oldest-first selection). Re-polled with a
|
||||
* 24h overlap; (company_id, external_id) dedup makes overlaps no-ops. It
|
||||
* never advances past failed work: a page with ingest errors caps the
|
||||
* persisted cursor just below the page's first updatedAt, so the next run
|
||||
* re-lists exactly the orders whose rows are incomplete. First run fetches
|
||||
* BACKFILL_DAYS back.
|
||||
*
|
||||
* Lock-date guard: the window selects on updatedAt, but rows are dated by
|
||||
* processedAt / refund createdAt, which can be arbitrarily older (a refund
|
||||
* bumps updatedAt long after payment). Rows dated on or before
|
||||
* company_settings.bookkeeping_locked_through are therefore dropped at map
|
||||
* time on EVERY run: the enforce_company_lock_date trigger makes them
|
||||
* permanently unbookable, and feed rows are undeletable by design, so
|
||||
* importing them would create permanent inbox noise. Dropped rows are counted
|
||||
* in skippedLocked and logged.
|
||||
*/
|
||||
|
||||
/** BAS ledger account for the Shopify store cash account. */
|
||||
export const SHOPIFY_LEDGER_ACCOUNT = '1584'
|
||||
/** 158x sub-account name (e-handel convention); used for the chart account. */
|
||||
const SHOPIFY_LEDGER_ACCOUNT_NAME = 'Fordringar Shopify Payments'
|
||||
/** transactions.import_source for Shopify feed rows. */
|
||||
export const SHOPIFY_IMPORT_SOURCE = 'shopify'
|
||||
/** First-run backfill window (matches the WooCommerce/Enable Banking convention). */
|
||||
export const BACKFILL_DAYS = 90
|
||||
/** Cursor re-poll overlap; external_id dedup makes duplicates no-ops. */
|
||||
const CURSOR_OVERLAP_MS = 24 * 60 * 60 * 1000
|
||||
/**
|
||||
* Safety cap on orders per run (matches the Stripe/WooCommerce feeds). The
|
||||
* real bound is the caller's deadline; hitting this cap is logged loudly
|
||||
* because a silent cap reads as "covered everything" when it did not. The
|
||||
* cursor resumes where a truncated run stopped.
|
||||
*/
|
||||
const MAX_ORDERS_PER_RUN = 10_000
|
||||
|
||||
/**
|
||||
* ⚠️ STORED-KEY FORMATS. These are persisted to transactions.external_id and
|
||||
* dedup compares stored ids byte-for-byte, exactly like the Stripe, Enable
|
||||
* Banking and WooCommerce schemes. Changing a template silently orphans every
|
||||
* prior row and re-imports the whole feed on the next sync. Locked by the
|
||||
* frozen-format test in order-sync.test.ts; any change MUST ship a
|
||||
* coordinated backfill.
|
||||
*
|
||||
* The scope is the store's normalized myshopify.com domain, NOT the
|
||||
* connection id, so a disconnect/reconnect of the same store keeps every
|
||||
* previously imported row deduped. The ids are Shopify's numeric
|
||||
* legacyResourceIds, matching the shopify_order_{id} convention the MCP
|
||||
* agent path already uses (different prefix, so the two paths never collide
|
||||
* on the same rows by accident).
|
||||
*/
|
||||
export function shopifyShopScope(shopDomain: string): string {
|
||||
return shopDomain
|
||||
}
|
||||
|
||||
export function shopifyOrderExternalId(shopScope: string, orderId: string): string {
|
||||
return `shopify_${shopScope}_order_${orderId}`
|
||||
}
|
||||
|
||||
export function shopifyRefundExternalId(shopScope: string, refundId: string): string {
|
||||
return `shopify_${shopScope}_refund_${refundId}`
|
||||
}
|
||||
|
||||
export interface ShopifySyncSummary {
|
||||
/** Orders listed from the store (all statuses in the window). */
|
||||
fetched: number
|
||||
/** Refund objects seen on qualifying orders in the window. */
|
||||
refundsFetched: number
|
||||
/** New inbox rows inserted. */
|
||||
imported: number
|
||||
/** Rows skipped by external_id / content dedup. */
|
||||
duplicates: number
|
||||
/** Rows dropped because they are dated on/before the bookkeeping lock. */
|
||||
skippedLocked: number
|
||||
errors: number
|
||||
/** Set when the caller's time budget ran out before all pages processed. */
|
||||
deadlineReached?: boolean
|
||||
/** Set when the store reported the credentials revoked (401/403). */
|
||||
revoked?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Money fields arrive as decimal strings in major units for every currency
|
||||
* (GraphQL MoneyV2; zero-decimal currencies like JPY included, so never
|
||||
* divide by 100). Unparseable input returns null so callers can tell a
|
||||
* corrupt total (counted + logged) from a legitimate zero (silently skipped).
|
||||
*/
|
||||
function parseAmount(value: string): number | null {
|
||||
const parsed = Number.parseFloat(value)
|
||||
return Number.isFinite(parsed) ? roundOre(parsed) : null
|
||||
}
|
||||
|
||||
/** Whether a qualifying order's total cannot be read as money. */
|
||||
export function orderAmountUnparseable(order: Pick<ShopifyOrder, 'totalPriceSet'>): boolean {
|
||||
return parseAmount(order.totalPriceSet.shopMoney.amount) === null
|
||||
}
|
||||
|
||||
/** Date part of an ISO timestamp. */
|
||||
function isoDateOf(timestamp: string): string {
|
||||
return timestamp.split('T')[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Financial statuses that mean the order has been paid (possibly later
|
||||
* refunded). AUTHORIZED/PENDING/PARTIALLY_PAID orders carry no settled
|
||||
* revenue yet and EXPIRED/VOIDED never will; they re-surface via updatedAt
|
||||
* once payment captures. REFUNDED stays IN: a fully refunded order was still
|
||||
* paid, and its refunds land as separate negative rows so the pair nets to
|
||||
* zero instead of the gross silently disappearing.
|
||||
*/
|
||||
const PAID_STATUSES = new Set(['PAID', 'PARTIALLY_REFUNDED', 'REFUNDED'])
|
||||
|
||||
/**
|
||||
* Whether an order belongs in the feed: it must have been paid and not be a
|
||||
* test-gateway order (dev stores, Bogus Gateway: never real revenue).
|
||||
*/
|
||||
export function orderQualifies(
|
||||
order: Pick<ShopifyOrder, 'test' | 'displayFinancialStatus'>,
|
||||
): boolean {
|
||||
return !order.test && PAID_STATUSES.has(order.displayFinancialStatus ?? '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a paid order to its gross feed row. Dates use processedAt (when the
|
||||
* money event happened), not createdAt: booked entries, invoice matching, and
|
||||
* month boundaries all want the payment date. Descriptions are deterministic
|
||||
* from immutable data (order names never change) because the content-dedup
|
||||
* bridge keys off them.
|
||||
*/
|
||||
export function mapOrder(shopScope: string, order: ShopifyOrder): RawTransaction[] {
|
||||
if (!orderQualifies(order)) return []
|
||||
const amount = parseAmount(order.totalPriceSet.shopMoney.amount)
|
||||
if (amount === null || amount === 0) return []
|
||||
return [
|
||||
{
|
||||
date: isoDateOf(order.processedAt),
|
||||
description: `Shopify-order ${order.name}`,
|
||||
amount,
|
||||
currency: order.totalPriceSet.shopMoney.currencyCode.toUpperCase(),
|
||||
external_id: shopifyOrderExternalId(shopScope, order.legacyResourceId),
|
||||
import_source: SHOPIFY_IMPORT_SOURCE,
|
||||
reference: order.paymentGatewayNames.join(', ') || null,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/** Map one refund of a paid order to its negative feed row. */
|
||||
export function mapRefund(
|
||||
shopScope: string,
|
||||
order: Pick<ShopifyOrder, 'name'>,
|
||||
refund: ShopifyRefund,
|
||||
): RawTransaction[] {
|
||||
const amount = parseAmount(refund.totalRefundedSet.shopMoney.amount)
|
||||
if (amount === null || amount === 0) return []
|
||||
return [
|
||||
{
|
||||
date: isoDateOf(refund.createdAt),
|
||||
description: `Shopify-återbetalning order ${order.name}`,
|
||||
amount: -amount,
|
||||
currency: refund.totalRefundedSet.shopMoney.currencyCode.toUpperCase(),
|
||||
external_id: shopifyRefundExternalId(shopScope, refund.legacyResourceId),
|
||||
import_source: SHOPIFY_IMPORT_SOURCE,
|
||||
reference: null,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/** Company lock date (YYYY-MM-DD) or null; read once per run. */
|
||||
async function fetchLockThrough(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
): Promise<string | null> {
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('bookkeeping_locked_through')
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
return (
|
||||
(settings as { bookkeeping_locked_through?: string | null } | null)
|
||||
?.bookkeeping_locked_through ?? null
|
||||
)
|
||||
}
|
||||
|
||||
/** Whether a feed-row date is on/before the lock date (=> never bookable). */
|
||||
export function rowBehindLock(rowDate: string, lockThrough: string | null): boolean {
|
||||
return lockThrough !== null && rowDate <= lockThrough
|
||||
}
|
||||
|
||||
/**
|
||||
* Window start (ISO, UTC) for the updated_at filter. With a cursor: cursor
|
||||
* minus the 24h overlap. First run: BACKFILL_DAYS back. (The lock date does
|
||||
* not floor the window: it selects on updatedAt while rows are dated by
|
||||
* processedAt, so the real guard is rowBehindLock at map time, every run.)
|
||||
*/
|
||||
function resolveWindowStartIso(connection: ShopifyConnection): string {
|
||||
if (connection.last_order_synced_at) {
|
||||
const cursorMs = Date.parse(connection.last_order_synced_at)
|
||||
return new Date(Math.max(0, cursorMs - CURSOR_OVERLAP_MS)).toISOString()
|
||||
}
|
||||
return new Date(Date.now() - BACKFILL_DAYS * 86_400_000).toISOString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Make sure the store cash account exists (ledger 1584, source manual so a
|
||||
* later remap/promotion follows the normal cash-account rules) and, on the
|
||||
* first run, that 1584 exists in the chart of accounts: the booking dialog
|
||||
* and AccountPicker only list chart accounts.
|
||||
*
|
||||
* Currency comes from the shop settings read at connect time, falling back to
|
||||
* the first fetched order's real currency (guessing SEK for an EUR store
|
||||
* would poison the account). A conflict with an existing 1584 cash account
|
||||
* throws; the caller surfaces that on the connection so the panel shows why
|
||||
* nothing syncs.
|
||||
*/
|
||||
async function ensureStoreAccount(
|
||||
supabase: SupabaseClient,
|
||||
connection: ShopifyConnection,
|
||||
fallbackCurrency: string | undefined,
|
||||
firstRun: boolean,
|
||||
log: Logger,
|
||||
): Promise<void> {
|
||||
const currency =
|
||||
connection.currency?.toUpperCase() || fallbackCurrency?.toUpperCase() || 'SEK'
|
||||
try {
|
||||
await ensureManualCashAccount(
|
||||
supabase,
|
||||
connection.company_id,
|
||||
SHOPIFY_LEDGER_ACCOUNT,
|
||||
currency,
|
||||
'Shopify-saldo',
|
||||
)
|
||||
} catch (accountError) {
|
||||
// Typically a currency conflict with an existing 1584 cash account. Made
|
||||
// visible on the connection: without this the panel shows a healthy
|
||||
// "Ansluten" store that silently never syncs.
|
||||
await supabase
|
||||
.from('shopify_connections')
|
||||
.update({
|
||||
error_message:
|
||||
'Kassakontot för butiken (1584) kunde inte skapas. Kontrollera att befintligt konto 1584 har samma valuta som butiken.',
|
||||
})
|
||||
.eq('id', connection.id)
|
||||
throw accountError
|
||||
}
|
||||
if (firstRun) {
|
||||
const sync = await syncMappedAccounts(
|
||||
supabase,
|
||||
connection.company_id,
|
||||
connection.user_id,
|
||||
[
|
||||
{
|
||||
sourceAccount: SHOPIFY_LEDGER_ACCOUNT,
|
||||
sourceName: SHOPIFY_LEDGER_ACCOUNT_NAME,
|
||||
targetAccount: SHOPIFY_LEDGER_ACCOUNT,
|
||||
targetName: SHOPIFY_LEDGER_ACCOUNT_NAME,
|
||||
confidence: 1,
|
||||
matchType: 'exact',
|
||||
isOverride: false,
|
||||
},
|
||||
],
|
||||
false,
|
||||
)
|
||||
if (sync.error) {
|
||||
// Rows still import and bind to the cash account; only the chart
|
||||
// listing is affected (the account can be added manually), so this is
|
||||
// deliberately non-fatal.
|
||||
log.warn('chart sync for 1584 failed', {
|
||||
companyId: connection.company_id,
|
||||
error: sync.error,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Rows for one page of orders: gross rows plus inline refund rows. */
|
||||
function buildPageRows(
|
||||
shopScope: string,
|
||||
orders: ShopifyOrder[],
|
||||
lockThrough: string | null,
|
||||
summary: ShopifySyncSummary,
|
||||
log: Logger,
|
||||
): RawTransaction[] {
|
||||
const rows: RawTransaction[] = []
|
||||
|
||||
const push = (mapped: RawTransaction[]) => {
|
||||
for (const row of mapped) {
|
||||
if (rowBehindLock(row.date, lockThrough)) {
|
||||
summary.skippedLocked += 1
|
||||
continue
|
||||
}
|
||||
rows.push(row)
|
||||
}
|
||||
}
|
||||
|
||||
for (const order of orders) {
|
||||
// A corrupt total is counted and logged, never silently identical to a
|
||||
// zero-total order. Deliberately NOT held via the cursor: a permanently
|
||||
// corrupt total would stall the whole feed forever, where a skipped row
|
||||
// plus a loud error can be followed up.
|
||||
if (orderQualifies(order) && orderAmountUnparseable(order)) {
|
||||
summary.errors += 1
|
||||
log.warn('unparseable order total; row skipped', {
|
||||
orderId: order.legacyResourceId,
|
||||
total: order.totalPriceSet.shopMoney.amount,
|
||||
})
|
||||
}
|
||||
push(mapOrder(shopScope, order))
|
||||
// Refunds only exist in the feed for qualifying (paid) orders: a refund
|
||||
// row without its gross counterpart would be an unexplainable negative in
|
||||
// the inbox. They come inline on the order (no follow-up request).
|
||||
if (!orderQualifies(order)) continue
|
||||
for (const refund of order.refunds) {
|
||||
summary.refundsFetched += 1
|
||||
if (parseAmount(refund.totalRefundedSet.shopMoney.amount) === null) {
|
||||
summary.errors += 1
|
||||
log.warn('unparseable refund amount; row skipped', {
|
||||
orderId: order.legacyResourceId,
|
||||
refundId: refund.legacyResourceId,
|
||||
amount: refund.totalRefundedSet.shopMoney.amount,
|
||||
})
|
||||
}
|
||||
push(mapRefund(shopScope, order, refund))
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
export async function syncShopifyOrders(
|
||||
supabase: SupabaseClient,
|
||||
connection: ShopifyConnection,
|
||||
log: Logger = defaultLog,
|
||||
/**
|
||||
* Absolute deadline (epoch ms) from the caller's time budget. Enforced
|
||||
* between pages: the cursor advances only over fully-processed pages, so
|
||||
* the next run resumes exactly where this one stopped.
|
||||
*/
|
||||
deadlineMs?: number,
|
||||
): Promise<ShopifySyncSummary> {
|
||||
const summary: ShopifySyncSummary = {
|
||||
fetched: 0,
|
||||
refundsFetched: 0,
|
||||
imported: 0,
|
||||
duplicates: 0,
|
||||
skippedLocked: 0,
|
||||
errors: 0,
|
||||
}
|
||||
if (
|
||||
connection.status !== 'active' ||
|
||||
!connection.client_id_encrypted ||
|
||||
!connection.client_secret_encrypted
|
||||
) {
|
||||
return summary
|
||||
}
|
||||
|
||||
const shopScope = shopifyShopScope(connection.shop_domain)
|
||||
const firstRun = !connection.last_order_synced_at
|
||||
const lockThrough = await fetchLockThrough(supabase, connection.company_id)
|
||||
|
||||
const runStartMs = Date.now()
|
||||
const updatedAtMin = resolveWindowStartIso(connection)
|
||||
let after: string | null = null
|
||||
let prevCursorMs = connection.last_order_synced_at
|
||||
? Date.parse(connection.last_order_synced_at)
|
||||
: 0
|
||||
// Earliest incomplete work this run; the persisted cursor never passes it.
|
||||
let failureFloorMs = Number.POSITIVE_INFINITY
|
||||
// True once the whole window was listed to its end (empty page or last page).
|
||||
let windowExhausted = false
|
||||
let accountEnsured = false
|
||||
|
||||
try {
|
||||
// Token exchange happens up front (the token lives ~24h, far longer than
|
||||
// any run); a dead client secret surfaces here as a revoked-classified
|
||||
// error before any paging starts.
|
||||
const session = await createShopifySession(credentialsOf(connection))
|
||||
|
||||
for (;;) {
|
||||
if (deadlineMs !== undefined && Date.now() >= deadlineMs) {
|
||||
summary.deadlineReached = true
|
||||
log.info('time budget exhausted; stopping order sync', {
|
||||
connectionId: connection.id,
|
||||
processed: summary.imported + summary.duplicates,
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
const page = await listOrdersPage(session, { updatedAtMin, after })
|
||||
if (page.orders.length === 0) {
|
||||
windowExhausted = true
|
||||
break
|
||||
}
|
||||
summary.fetched += page.orders.length
|
||||
|
||||
// Deferred until the window is known non-empty so a quiet store costs
|
||||
// one API call and zero DB writes; also gives us a real order currency
|
||||
// as the fallback when the shop currency was unreadable at connect.
|
||||
if (!accountEnsured) {
|
||||
await ensureStoreAccount(
|
||||
supabase,
|
||||
connection,
|
||||
page.orders[0].totalPriceSet.shopMoney.currencyCode,
|
||||
firstRun,
|
||||
log,
|
||||
)
|
||||
accountEnsured = true
|
||||
}
|
||||
|
||||
const rows = buildPageRows(shopScope, page.orders, lockThrough, summary, log)
|
||||
|
||||
const firstMs = Date.parse(page.orders[0].updatedAt)
|
||||
const lastMs = Date.parse(page.orders[page.orders.length - 1].updatedAt)
|
||||
|
||||
if (rows.length > 0) {
|
||||
// Auto-categorization is skipped on purpose: booking Shopify money is
|
||||
// a human decision in the inbox (feed-only doctrine, same as the
|
||||
// Stripe and WooCommerce feeds). Invoice matching still runs
|
||||
// (suggestions only), and FX enrichment covers non-SEK stores.
|
||||
const result = await ingestTransactions(
|
||||
supabase,
|
||||
connection.company_id,
|
||||
connection.user_id,
|
||||
rows,
|
||||
{ settlementAccount: SHOPIFY_LEDGER_ACCOUNT, skipAutoCategorization: true },
|
||||
)
|
||||
summary.imported += result.imported
|
||||
summary.duplicates += result.duplicates
|
||||
summary.errors += result.errors
|
||||
if (result.errors > 0) {
|
||||
// Failed inserts are dropped inside ingest; hold the cursor below
|
||||
// this page so the next run re-lists and retries it rather than
|
||||
// turning a transient DB error into permanently missing rows.
|
||||
failureFloorMs = Math.min(failureFloorMs, firstMs - 1000)
|
||||
}
|
||||
}
|
||||
|
||||
// Persist the cursor after each page: monotonic (never regresses below
|
||||
// the pre-run cursor) and capped by the failure floor. error_message is
|
||||
// cleared on progress so a resolved incident stops showing in the panel.
|
||||
const candidateMs = Math.min(lastMs, failureFloorMs)
|
||||
if (candidateMs > prevCursorMs) {
|
||||
const cursorIso = new Date(candidateMs).toISOString()
|
||||
await supabase
|
||||
.from('shopify_connections')
|
||||
.update({ last_order_synced_at: cursorIso, error_message: null })
|
||||
.eq('id', connection.id)
|
||||
connection.last_order_synced_at = cursorIso
|
||||
prevCursorMs = candidateMs
|
||||
}
|
||||
|
||||
if (!page.hasNextPage) {
|
||||
windowExhausted = true
|
||||
break
|
||||
}
|
||||
after = page.endCursor
|
||||
|
||||
if (summary.fetched >= MAX_ORDERS_PER_RUN) {
|
||||
log.warn('order cap reached; remaining orders resume next run', {
|
||||
connectionId: connection.id,
|
||||
cap: MAX_ORDERS_PER_RUN,
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Watermark advance: a fully-listed window means "scanned through run
|
||||
// start", even when it produced no rows. Without this, an empty first run
|
||||
// keeps a NULL cursor forever, and the cron's oldest-first selection
|
||||
// (nullsFirst, limit 50) lets quiet stores permanently occupy the batch
|
||||
// and starve other connections. Capped by the failure floor like every
|
||||
// other cursor write; the 24h overlap re-poll still covers updates that
|
||||
// landed while the run was in flight.
|
||||
if (windowExhausted) {
|
||||
const watermarkMs = Math.min(runStartMs, failureFloorMs)
|
||||
if (watermarkMs > prevCursorMs) {
|
||||
const cursorIso = new Date(watermarkMs).toISOString()
|
||||
await supabase
|
||||
.from('shopify_connections')
|
||||
.update({ last_order_synced_at: cursorIso, error_message: null })
|
||||
.eq('id', connection.id)
|
||||
connection.last_order_synced_at = cursorIso
|
||||
prevCursorMs = watermarkMs
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (isRevokedCredentialsError(err)) {
|
||||
// The app was deleted or its secret rotated in the Dev Dashboard: flip
|
||||
// the connection so the UI offers a reconnect instead of the cron
|
||||
// retrying forever.
|
||||
summary.revoked = true
|
||||
await supabase
|
||||
.from('shopify_connections')
|
||||
.update({
|
||||
status: 'revoked',
|
||||
error_message: 'Butiken avvisade appens uppgifter. Anslut butiken igen.',
|
||||
// The store already rejected these; keeping decryptable dead
|
||||
// credentials would be pure data retention (same as /disconnect).
|
||||
client_id_encrypted: null,
|
||||
client_secret_encrypted: null,
|
||||
disconnected_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', connection.id)
|
||||
.eq('status', 'active')
|
||||
log.warn('credentials revoked upstream; connection flipped to revoked', {
|
||||
connectionId: connection.id,
|
||||
})
|
||||
return summary
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
if (summary.skippedLocked > 0) {
|
||||
log.info('rows behind the bookkeeping lock were skipped', {
|
||||
connectionId: connection.id,
|
||||
skippedLocked: summary.skippedLocked,
|
||||
})
|
||||
}
|
||||
log.info('shopify order sync done', {
|
||||
connectionId: connection.id,
|
||||
...summary,
|
||||
})
|
||||
return summary
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* The Shopify settings panel's server calls, each classified into exactly one
|
||||
* outcome. Same doctrine as the Stripe and WooCommerce panels'
|
||||
* settings-actions (see the doc blocks there): never throw, one toast
|
||||
* sentence per click, and the classification lives outside the component
|
||||
* because component logic has no tests in this repo.
|
||||
*/
|
||||
|
||||
import { fetchWithTimeout, isTimeoutError } from '@/lib/http/fetch-with-timeout'
|
||||
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
|
||||
import type { ActionFailure } from '@/lib/browser/action-failure'
|
||||
|
||||
/**
|
||||
* Deadline for the quick calls (status, toggle, disconnect). Connect probes
|
||||
* the merchant's store (token exchange + orders probe, with retries), so it
|
||||
* gets a longer one.
|
||||
*/
|
||||
export const SHOPIFY_ACTION_TIMEOUT_MS = 15_000
|
||||
export const SHOPIFY_CONNECT_TIMEOUT_MS = 120_000
|
||||
|
||||
/**
|
||||
* Deadline for "Synka nu": the route's own ceiling (maxDuration 300 on the
|
||||
* extension dispatcher) plus margin, same reasoning as the Stripe/WooCommerce
|
||||
* panels. A first sync backfills 90 days and legitimately takes minutes; the
|
||||
* server keeps working and advances the cursor even if we aborted, so
|
||||
* aborting early would misreport a sync that landed.
|
||||
*/
|
||||
export const SHOPIFY_SYNC_TIMEOUT_MS = 310_000
|
||||
|
||||
export type ShopifyRequestResult<T> =
|
||||
/** 2xx. `data` is null when the body was not readable JSON. */
|
||||
| { ok: true; data: T | null }
|
||||
| ActionFailure
|
||||
|
||||
export interface ShopifyRequestOptions {
|
||||
url: string
|
||||
method?: 'GET' | 'POST' | 'DELETE'
|
||||
body?: unknown
|
||||
locale?: ErrorLocale
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
|
||||
/** The one sentence for a non-2xx; route copy wins over the generic map. */
|
||||
export function serverErrorMessage(
|
||||
body: unknown,
|
||||
status: number,
|
||||
locale: ErrorLocale,
|
||||
): string {
|
||||
if (isRecord(body)) {
|
||||
if (locale === 'en' && typeof body.error_en === 'string' && body.error_en.trim()) {
|
||||
return body.error_en.trim()
|
||||
}
|
||||
if (typeof body.error === 'string' && body.error.trim()) {
|
||||
return body.error.trim()
|
||||
}
|
||||
}
|
||||
return getErrorMessage(body, { statusCode: status, locale })
|
||||
}
|
||||
|
||||
/** Call one of the panel's endpoints and report exactly why it failed. */
|
||||
export async function shopifyRequest<T>({
|
||||
url,
|
||||
method = 'POST',
|
||||
body,
|
||||
locale = 'sv',
|
||||
timeoutMs = SHOPIFY_ACTION_TIMEOUT_MS,
|
||||
}: ShopifyRequestOptions): Promise<ShopifyRequestResult<T>> {
|
||||
try {
|
||||
const res = await fetchWithTimeout(
|
||||
url,
|
||||
body === undefined
|
||||
? { method }
|
||||
: {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
{ timeoutMs, description: `${method} ${url}` },
|
||||
)
|
||||
|
||||
const payload = await res.json().catch(() => null)
|
||||
|
||||
if (!res.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'server',
|
||||
status: res.status,
|
||||
message: serverErrorMessage(payload, res.status, locale),
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, data: payload as T | null }
|
||||
} catch (err) {
|
||||
if (isTimeoutError(err)) return { ok: false, reason: 'timeout' }
|
||||
return { ok: false, reason: 'network', message: getErrorMessage(err, { locale }) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Success body of POST /api/extensions/ext/shopify/sync. */
|
||||
export interface ShopifySyncPayload {
|
||||
success?: boolean
|
||||
/** `ShopifySyncSummary` from lib/order-sync.ts, over the wire. */
|
||||
transactions?: {
|
||||
fetched?: number
|
||||
refundsFetched?: number
|
||||
imported?: number
|
||||
duplicates?: number
|
||||
errors?: number
|
||||
revoked?: boolean
|
||||
deadlineReached?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
type SyncCounts = {
|
||||
fetched: number
|
||||
imported: number
|
||||
}
|
||||
|
||||
export type ShopifySyncOutcome =
|
||||
/** The store rejected the credentials; the connection was flipped to revoked. */
|
||||
| { reason: 'revoked' }
|
||||
/** The window genuinely held nothing. A real answer, not a silent success. */
|
||||
| { reason: 'empty' }
|
||||
/**
|
||||
* The time budget ran out with orders still unfetched. Reported before the
|
||||
* count-based outcomes so a truncated run never reads as a complete one;
|
||||
* the cursor persisted, so pressing sync again continues where it stopped.
|
||||
* Carries the error count too: a truncated run can also have failed rows,
|
||||
* and dropping that number would repeat the silent-partial mistake.
|
||||
*/
|
||||
| { reason: 'partial'; values: SyncCounts & { errors: number } }
|
||||
/** Rows landed, and some rows did not. Both halves get said. */
|
||||
| { reason: 'errors'; values: SyncCounts & { errors: number } }
|
||||
/** Rows landed. */
|
||||
| { reason: 'feed'; values: SyncCounts }
|
||||
/** 2xx whose body could not be read: the sync ran, the counts are unknown. */
|
||||
| { reason: 'unknown' }
|
||||
|
||||
/** Turn the sync route's success body into the single sentence the user gets. */
|
||||
export function syncSummary(payload: ShopifySyncPayload | null): ShopifySyncOutcome {
|
||||
const summary = payload?.transactions
|
||||
if (!summary) return { reason: 'unknown' }
|
||||
if (summary.revoked === true) return { reason: 'revoked' }
|
||||
if (typeof summary.fetched !== 'number') return { reason: 'unknown' }
|
||||
|
||||
const fetched = summary.fetched
|
||||
const imported = typeof summary.imported === 'number' ? summary.imported : 0
|
||||
const errors = typeof summary.errors === 'number' ? summary.errors : 0
|
||||
|
||||
if (summary.deadlineReached === true) {
|
||||
return { reason: 'partial', values: { fetched, imported, errors } }
|
||||
}
|
||||
if (fetched === 0) return { reason: 'empty' }
|
||||
if (errors > 0) return { reason: 'errors', values: { fetched, imported, errors } }
|
||||
return { reason: 'feed', values: { fetched, imported } }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"id": "shopify",
|
||||
"sector": "general",
|
||||
"exportName": "shopifyExtension",
|
||||
"entryPoint": "@/extensions/general/shopify",
|
||||
"workspace": null,
|
||||
"requiredEnvVars": ["SHOPIFY_CREDENTIALS_ENCRYPTION_KEY"],
|
||||
"optionalEnvVars": [],
|
||||
"npmDependencies": [],
|
||||
"definition": {
|
||||
"name": "Shopify",
|
||||
"category": "import",
|
||||
"icon": "ShoppingBag",
|
||||
"dataPattern": "manual",
|
||||
"hasOwnData": true,
|
||||
"description": "Hämta betalda ordrar och återbetalningar från din Shopify-butik till transaktionsinkorgen",
|
||||
"longDescription": "Anslut din Shopify-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."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/** Row shape of public.shopify_connections. */
|
||||
export interface ShopifyConnection {
|
||||
id: string
|
||||
company_id: string
|
||||
user_id: string
|
||||
/** Normalized myshopify.com domain, lowercased, no scheme/path. */
|
||||
shop_domain: string
|
||||
shop_name: string | null
|
||||
/**
|
||||
* Dev Dashboard custom-app credentials (client credentials grant). Shopify
|
||||
* discontinued revealable admin API tokens for new custom apps on
|
||||
* 2026-01-01; the merchant pastes the app's client id/secret instead and
|
||||
* the server exchanges them for a short-lived (24h) access token per run.
|
||||
*/
|
||||
client_id_encrypted: string | null
|
||||
client_secret_encrypted: string | null
|
||||
status: 'pending' | 'active' | 'revoked' | 'error'
|
||||
/** ISO 4217 shop currency read at connect time. */
|
||||
currency: string | null
|
||||
/** Opt-in: nightly order-feed cron (the manual sync button ignores it). */
|
||||
transaction_sync_enabled: boolean
|
||||
/** Order-polling cursor (max updatedAt processed). */
|
||||
last_order_synced_at: string | null
|
||||
error_message: string | null
|
||||
connected_at: string | null
|
||||
disconnected_at: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/** Status payload returned by GET /api/extensions/ext/shopify/status. */
|
||||
export interface ShopifyStatusResponse {
|
||||
configured: boolean
|
||||
connection: Pick<
|
||||
ShopifyConnection,
|
||||
| 'id'
|
||||
| 'status'
|
||||
| 'shop_domain'
|
||||
| 'shop_name'
|
||||
| 'currency'
|
||||
| 'error_message'
|
||||
| 'connected_at'
|
||||
| 'transaction_sync_enabled'
|
||||
| 'last_order_synced_at'
|
||||
> | null
|
||||
}
|
||||
|
||||
/**
|
||||
* GraphQL MoneyBag, shop-currency side only. Amounts are decimal strings in
|
||||
* major units for every currency (also zero-decimal ones like JPY), so
|
||||
* parseFloat is correct universally: never divide by 100.
|
||||
*/
|
||||
export interface ShopifyMoneyBag {
|
||||
shopMoney: { amount: string; currencyCode: string }
|
||||
}
|
||||
|
||||
/** Minimal refund shape; Shopify returns refunds inline on the order. */
|
||||
export interface ShopifyRefund {
|
||||
legacyResourceId: string
|
||||
createdAt: string
|
||||
totalRefundedSet: ShopifyMoneyBag
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal GraphQL Admin API order shape consumed by the feed. All timestamps
|
||||
* are ISO 8601 UTC with a Z suffix.
|
||||
*/
|
||||
export interface ShopifyOrder {
|
||||
/** Numeric order id as a string; stable join key for external_id. */
|
||||
legacyResourceId: string
|
||||
/** Display name incl. the # prefix (e.g. "#1042"); plugins can renumber. */
|
||||
name: string
|
||||
/** Test-gateway order (dev stores, Bogus Gateway); never real revenue. */
|
||||
test: boolean
|
||||
/** When payment was captured; the feed's row date. */
|
||||
processedAt: string
|
||||
updatedAt: string
|
||||
displayFinancialStatus: string | null
|
||||
/** Gateway display names; join key for gateway-side reconciliation. */
|
||||
paymentGatewayNames: string[]
|
||||
/** Grand total actually charged (gross, incl. tax and shipping). */
|
||||
totalPriceSet: ShopifyMoneyBag
|
||||
refunds: ShopifyRefund[]
|
||||
}
|
||||
|
||||
/** Shop metadata read at connect time. */
|
||||
export interface ShopifyShopInfo {
|
||||
name: string | null
|
||||
/** ISO 4217 shop currency. */
|
||||
currency: string | null
|
||||
}
|
||||
@@ -32,6 +32,8 @@ export const CAPABILITY = {
|
||||
stripe_payments: 'stripe_payments',
|
||||
/** WooCommerce store sync: orders/refunds imported as a transaction feed. */
|
||||
woocommerce_sync: 'woocommerce_sync',
|
||||
/** Shopify store sync: orders/refunds imported as a transaction feed. */
|
||||
shopify_sync: 'shopify_sync',
|
||||
} as const
|
||||
|
||||
export type CapabilityKey = (typeof CAPABILITY)[keyof typeof CAPABILITY]
|
||||
@@ -58,6 +60,7 @@ export const PAID_CAPABILITIES: readonly CapabilityKey[] = [
|
||||
CAPABILITY.email_send,
|
||||
CAPABILITY.stripe_payments,
|
||||
CAPABILITY.woocommerce_sync,
|
||||
CAPABILITY.shopify_sync,
|
||||
] as const
|
||||
|
||||
/**
|
||||
|
||||
@@ -82,6 +82,9 @@ export type CoreEvent =
|
||||
// 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 } }
|
||||
// Shopify store lifecycle: same audit doctrine as stripe.*/woocommerce.*.
|
||||
| { type: 'shopify.connected'; payload: { connectionId: string; shopDomain: string; userId: string; companyId: string } }
|
||||
| { type: 'shopify.disconnected'; payload: { connectionId: string; shopDomain: 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 } }
|
||||
|
||||
@@ -48,8 +48,8 @@ describe('sectors registry', () => {
|
||||
expect(SECTORS.length).toBe(1)
|
||||
})
|
||||
|
||||
it('should have 16 total extensions', () => {
|
||||
expect(getAllExtensions().length).toBe(16)
|
||||
it('should have 17 total extensions', () => {
|
||||
expect(getAllExtensions().length).toBe(17)
|
||||
})
|
||||
|
||||
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(16)
|
||||
expect(extensions.length).toBe(17)
|
||||
})
|
||||
|
||||
it('all extensions have required fields', () => {
|
||||
|
||||
+1
@@ -13,4 +13,5 @@ export const ENABLED_EXTENSION_IDS: ReadonlySet<string> = new Set([
|
||||
'stripe',
|
||||
'whatsapp-inbox',
|
||||
'woocommerce',
|
||||
'shopify',
|
||||
])
|
||||
|
||||
+2
@@ -12,6 +12,7 @@ import { documentExtractionExtension } from '@/extensions/general/document-extra
|
||||
import { stripeExtension } from '@/extensions/general/stripe'
|
||||
import { whatsappInboxExtension } from '@/extensions/general/whatsapp-inbox'
|
||||
import { woocommerceExtension } from '@/extensions/general/woocommerce'
|
||||
import { shopifyExtension } from '@/extensions/general/shopify'
|
||||
|
||||
export const FIRST_PARTY_EXTENSIONS: Extension[] = [
|
||||
enableBankingExtension,
|
||||
@@ -26,4 +27,5 @@ export const FIRST_PARTY_EXTENSIONS: Extension[] = [
|
||||
stripeExtension,
|
||||
whatsappInboxExtension,
|
||||
woocommerceExtension,
|
||||
shopifyExtension,
|
||||
]
|
||||
|
||||
+11
@@ -158,5 +158,16 @@ export const EXTENSION_DEFINITIONS: Record<string, ExtensionDefinition[]> = {
|
||||
"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
|
||||
},
|
||||
{
|
||||
"slug": "shopify",
|
||||
"name": "Shopify",
|
||||
"sector": "general",
|
||||
"category": "import",
|
||||
"icon": "ShoppingBag",
|
||||
"dataPattern": "manual",
|
||||
"description": "Hämta betalda ordrar och återbetalningar från din Shopify-butik till transaktionsinkorgen",
|
||||
"longDescription": "Anslut din Shopify-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
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -22,6 +22,9 @@ const SETTINGS_PANELS: Record<string, ComponentType> = {
|
||||
woocommerce: dynamic(
|
||||
() => import('@/extensions/general/woocommerce/components/WooCommerceSettingsPanel')
|
||||
),
|
||||
shopify: dynamic(
|
||||
() => import('@/extensions/general/shopify/components/ShopifySettingsPanel')
|
||||
),
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1019,6 +1019,7 @@ export const ARCHIVE_EXCLUDED_TABLES: Record<string, string> = {
|
||||
'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)',
|
||||
shopify_connections: 'Shopify 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', 'woocommerce_sync'])
|
||||
expect(rows.map((r) => r.capability_key).sort()).toEqual(['ai', 'bank_sync', 'email_send', 'shopify_sync', 'skatteverket', 'stripe_payments', 'woocommerce_sync'])
|
||||
expect(rows.every((r) => r.source === 'stripe')).toBe(true)
|
||||
})
|
||||
|
||||
|
||||
@@ -467,6 +467,53 @@
|
||||
"transaction_sync_disabled_toast": "Order sync disabled.",
|
||||
"transaction_sync_toggle_failed": "Could not save the setting. Please try again."
|
||||
},
|
||||
"shopify": {
|
||||
"title": "Shopify",
|
||||
"description": "Connect your Shopify store to fetch paid orders and refunds into the transaction inbox, as a bank-style feed for the store. You book the rows from the inbox as usual. Note that an order can mix VAT rates (25/12/6%); split the VAT accordingly when you book the row.",
|
||||
"not_configured": "The Shopify integration is not configured on this installation. Contact your administrator.",
|
||||
"load_failed": "Could not read the Shopify status. Check your connection and try again.",
|
||||
"action_timeout": "The action took too long. Reload the page to see whether it went through.",
|
||||
"action_network": "No contact with the server. Check your connection and try again.",
|
||||
"shop_domain_label": "Store address (myshopify.com)",
|
||||
"client_id_label": "Client ID",
|
||||
"client_secret_label": "Client secret",
|
||||
"connect": "Connect store",
|
||||
"connecting": "Connecting…",
|
||||
"connect_hint": "Create an app in Shopify's Dev Dashboard with the read_orders scope and paste the app's client ID and client secret here. The credentials are stored encrypted and you can disconnect at any time, here or by removing the app in Shopify.",
|
||||
"disconnect": "Disconnect",
|
||||
"disconnect_confirm": "Yes, disconnect",
|
||||
"cancel": "Cancel",
|
||||
"status_active": "Connected",
|
||||
"status_pending": "Pending",
|
||||
"status_revoked": "Disconnected",
|
||||
"status_error": "Error",
|
||||
"connected_since": "Connected {date}",
|
||||
"unnamed_store": "Shopify store",
|
||||
"connected_toast_title": "Shopify connected",
|
||||
"connected_toast_description": "The store's paid orders and refunds are now fetched every night.",
|
||||
"disconnected_toast_title": "Shopify disconnected",
|
||||
"disconnected_toast_description": "Also remove the app in the store's Shopify Dev Dashboard.",
|
||||
"connect_failed_title": "Connection failed",
|
||||
"disconnect_failed_title": "Disconnect failed",
|
||||
"sync_now": "Sync now",
|
||||
"syncing": "Syncing…",
|
||||
"sync_done_title": "Sync complete",
|
||||
"sync_done_feed": "{fetched} order(s) fetched: {imported} new rows in the inbox.",
|
||||
"sync_done_empty": "The store returned no orders for the period. Check that the right store is connected if you expected orders.",
|
||||
"sync_done_feed_errors": "{fetched} order(s) fetched: {imported} new rows in the inbox. {errors} row(s) could not be imported: sync again.",
|
||||
"sync_partial_title": "Sync paused",
|
||||
"sync_partial": "{fetched} order(s) fetched so far: {imported} new rows in the inbox.{errors, plural, =0 {} other { # row(s) could not be imported.}} Not all orders were fetched in time: sync again to continue where it stopped.",
|
||||
"sync_failed_title": "Sync failed",
|
||||
"sync_revoked": "The store rejected the app credentials, so no orders could be fetched. Connect the store again.",
|
||||
"transaction_sync_title": "Orders from Shopify",
|
||||
"transaction_sync_description": "Fetch the store's paid orders and refunds into the transaction inbox every night, as a bank-style feed for the store. You book the rows from the inbox as usual.",
|
||||
"transaction_sync_backfill_note": "The first sync fetches up to 90 days of history, but never before the bookkeeping lock.",
|
||||
"transaction_sync_last_synced": "Last synced {date}",
|
||||
"transaction_sync_never_synced": "Not synced yet",
|
||||
"transaction_sync_enabled_toast": "Order sync enabled. History is fetched on the next sync.",
|
||||
"transaction_sync_disabled_toast": "Order sync disabled.",
|
||||
"transaction_sync_toggle_failed": "Could not save the setting. Please try again."
|
||||
},
|
||||
"settings_modal": {
|
||||
"title": "Settings",
|
||||
"description": "Manage your company and account"
|
||||
@@ -6458,6 +6505,10 @@
|
||||
"woocommerce_description": "Connect your WooCommerce store to fetch paid orders and refunds into the transaction inbox.",
|
||||
"woocommerce_not_enabled_title": "The WooCommerce extension is not enabled",
|
||||
"woocommerce_not_enabled_description": "Enable the WooCommerce extension to connect your store and fetch orders automatically.",
|
||||
"shopify_title": "Shopify",
|
||||
"shopify_description": "Connect your Shopify store to fetch paid orders and refunds into the transaction inbox.",
|
||||
"shopify_not_enabled_title": "The Shopify extension is not enabled",
|
||||
"shopify_not_enabled_description": "Enable the Shopify extension to connect your store and fetch orders automatically.",
|
||||
"migration_title": "Import from another system",
|
||||
"migration_description": "Nothing changes in your existing system.",
|
||||
"bankfile_title": "Bank file",
|
||||
|
||||
@@ -467,6 +467,53 @@
|
||||
"transaction_sync_disabled_toast": "Ordersynk avaktiverad.",
|
||||
"transaction_sync_toggle_failed": "Kunde inte spara inställningen. Försök igen."
|
||||
},
|
||||
"shopify": {
|
||||
"title": "Shopify",
|
||||
"description": "Koppla din Shopify-butik så hämtas betalda ordrar och återbetalningar till transaktionsinkorgen, som ett bankflöde för butiken. Du bokför raderna som vanligt från inkorgen. Observera att en order kan innehålla flera momssatser (25/12/6 %); dela upp momsen därefter när du bokför raden.",
|
||||
"not_configured": "Shopify-integrationen är inte konfigurerad på den här installationen. Kontakta administratören.",
|
||||
"load_failed": "Kunde inte läsa Shopify-statusen. Kontrollera din uppkoppling och försök igen.",
|
||||
"action_timeout": "Åtgärden tog för lång tid. Ladda om sidan för att se om den gick igenom.",
|
||||
"action_network": "Ingen kontakt med servern. Kontrollera din uppkoppling och försök igen.",
|
||||
"shop_domain_label": "Butikens adress (myshopify.com)",
|
||||
"client_id_label": "Klient-ID (client ID)",
|
||||
"client_secret_label": "Klienthemlighet (client secret)",
|
||||
"connect": "Anslut butik",
|
||||
"connecting": "Ansluter…",
|
||||
"connect_hint": "Skapa en app i Shopifys Dev Dashboard med behörigheten read_orders och klistra in appens klient-ID och klienthemlighet här. Uppgifterna lagras krypterade och du kan när som helst koppla från här eller ta bort appen i Shopify.",
|
||||
"disconnect": "Koppla från",
|
||||
"disconnect_confirm": "Ja, koppla från",
|
||||
"cancel": "Avbryt",
|
||||
"status_active": "Ansluten",
|
||||
"status_pending": "Väntar",
|
||||
"status_revoked": "Frånkopplad",
|
||||
"status_error": "Fel",
|
||||
"connected_since": "Ansluten {date}",
|
||||
"unnamed_store": "Shopify-butik",
|
||||
"connected_toast_title": "Shopify anslutet",
|
||||
"connected_toast_description": "Butikens betalda ordrar och återbetalningar hämtas nu varje natt.",
|
||||
"disconnected_toast_title": "Shopify frånkopplat",
|
||||
"disconnected_toast_description": "Ta även bort appen i butikens Shopify Dev Dashboard.",
|
||||
"connect_failed_title": "Anslutningen misslyckades",
|
||||
"disconnect_failed_title": "Frånkopplingen misslyckades",
|
||||
"sync_now": "Synka nu",
|
||||
"syncing": "Synkar…",
|
||||
"sync_done_title": "Synkronisering klar",
|
||||
"sync_done_feed": "{fetched} order/ordrar hämtade: {imported} nya rader i inkorgen.",
|
||||
"sync_done_empty": "Butiken returnerade inga ordrar för perioden. Kontrollera att rätt butik är ansluten om du väntade dig ordrar.",
|
||||
"sync_done_feed_errors": "{fetched} order/ordrar hämtade: {imported} nya rader i inkorgen. {errors} rad(er) kunde inte importeras: synka igen.",
|
||||
"sync_partial_title": "Synkroniseringen pausades",
|
||||
"sync_partial": "{fetched} order/ordrar hämtade hittills: {imported} nya rader i inkorgen.{errors, plural, =0 {} other { # rad(er) kunde inte importeras.}} Alla ordrar hann inte hämtas: synka igen för att fortsätta där det stannade.",
|
||||
"sync_failed_title": "Synkroniseringen misslyckades",
|
||||
"sync_revoked": "Butiken avvisade appens uppgifter, så inga ordrar kunde hämtas. Anslut butiken igen.",
|
||||
"transaction_sync_title": "Ordrar från Shopify",
|
||||
"transaction_sync_description": "Hämta butikens betalda ordrar och återbetalningar till transaktionsinkorgen varje natt, som ett bankflöde för butiken. Du bokför raderna som vanligt från inkorgen.",
|
||||
"transaction_sync_backfill_note": "Vid första synkningen hämtas upp till 90 dagars historik, dock inte före bokföringslåset.",
|
||||
"transaction_sync_last_synced": "Senast synkad {date}",
|
||||
"transaction_sync_never_synced": "Inte synkad ännu",
|
||||
"transaction_sync_enabled_toast": "Ordersynk aktiverad. Historiken hämtas vid nästa synkning.",
|
||||
"transaction_sync_disabled_toast": "Ordersynk avaktiverad.",
|
||||
"transaction_sync_toggle_failed": "Kunde inte spara inställningen. Försök igen."
|
||||
},
|
||||
"settings_modal": {
|
||||
"title": "Inställningar",
|
||||
"description": "Hantera ditt företag och konto"
|
||||
@@ -6458,6 +6505,10 @@
|
||||
"woocommerce_description": "Koppla din WooCommerce-butik så hämtas betalda ordrar och återbetalningar till transaktionsinkorgen.",
|
||||
"woocommerce_not_enabled_title": "WooCommerce-tillägget är inte aktiverat",
|
||||
"woocommerce_not_enabled_description": "Aktivera tillägget WooCommerce för att koppla din butik och hämta ordrar automatiskt.",
|
||||
"shopify_title": "Shopify",
|
||||
"shopify_description": "Koppla din Shopify-butik så hämtas betalda ordrar och återbetalningar till transaktionsinkorgen.",
|
||||
"shopify_not_enabled_title": "Shopify-tillägget är inte aktiverat",
|
||||
"shopify_not_enabled_description": "Aktivera tillägget Shopify för att koppla din butik och hämta ordrar automatiskt.",
|
||||
"migration_title": "Hämta från annat system",
|
||||
"migration_description": "Inget ändras i ditt befintliga system.",
|
||||
"bankfile_title": "Bankfil",
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
|
||||
<path fill="#95BF47" d="M17.9 5.3c0-.1-.1-.2-.2-.2l-1.9-.1-1.4-1.4c-.1-.1-.4-.1-.5 0l-.6.2c-.4-1-1-1.9-2.1-1.9h-.2c-.3-.4-.7-.6-1.1-.6C7.7 1.3 6.6 3.4 6.2 5l-2 .6c-.6.2-.6.2-.7.8L2 18.6l11.4 2.1 6.2-1.3S18 5.4 17.9 5.3zM12.7 4l-1 .3v-.2c0-.6-.1-1.2-.2-1.6.5.1.9.8 1.2 1.5zm-1.9.6-2.1.6c.2-.8.6-1.6 1.1-2.1.2-.2.5-.4.8-.5.2.5.2 1.3.2 2zm-1.3-2.7c.2 0 .3 0 .5.1-.3.2-.6.4-.9.7-.7.7-1.2 1.9-1.4 3l-1.7.5c.4-1.5 1.6-4.2 3.5-4.3z"/>
|
||||
<path fill="#5E8E3E" d="m17.7 5.1-1.9-.1-1.4-1.4c-.1-.1-.1-.1-.2-.1v17.2l6.2-1.3S18 5.4 17.9 5.3c0-.1-.1-.2-.2-.2z"/>
|
||||
<path fill="#FFF" d="m12.9 7.8-.8 2.3s-.7-.4-1.5-.4c-1.2 0-1.3.8-1.3 1 0 1 2.8 1.4 2.8 3.9 0 2-1.3 3.2-2.9 3.2-2 0-3-1.2-3-1.2l.5-1.8s1 .9 1.9.9c.6 0 .8-.4.8-.8 0-1.4-2.3-1.4-2.3-3.7 0-1.9 1.4-3.8 4.2-3.8 1.1.1 1.6.4 1.6.4z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 870 B |
@@ -0,0 +1,90 @@
|
||||
-- Shopify store connections: per-company Shopify custom-app credentials for
|
||||
-- the order/refund transaction feed (extensions/general/shopify).
|
||||
--
|
||||
-- Shopify discontinued admin-created custom apps with revealable shpat_
|
||||
-- tokens on 2026-01-01. The merchant instead creates a custom app in their
|
||||
-- own Dev Dashboard and pastes its client id/secret; the server exchanges
|
||||
-- those for a ~24h access token per run (client credentials grant), so only
|
||||
-- the client id/secret are stored, AES-256-GCM encrypted with a dedicated
|
||||
-- server-side key (SHOPIFY_CREDENTIALS_ENCRYPTION_KEY), never in plaintext.
|
||||
-- The encrypted blobs are useless without that env key, mirroring the
|
||||
-- WooCommerce credential store (20260806170000).
|
||||
--
|
||||
-- Modeled on woocommerce_connections: same status lifecycle, same
|
||||
-- member-scoped RLS, no DELETE policy (connections are revoked, never
|
||||
-- deleted, for audit). Like the sibling tables there is no write_audit_log
|
||||
-- trigger: this is connection state, not accounting data, and audit-logging
|
||||
-- rows that carry encrypted credentials would copy secret ciphertext into
|
||||
-- audit_log. The 'pending' status is unused by the current paste-credentials
|
||||
-- flow but kept for lifecycle parity (a future OAuth-app flow needs it).
|
||||
|
||||
create table public.shopify_connections (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
company_id uuid not null references public.companies(id) on delete cascade,
|
||||
user_id uuid not null references auth.users(id) on delete cascade,
|
||||
-- Normalized myshopify.com domain, lowercased, no scheme/path. The Admin
|
||||
-- API only lives on *.myshopify.com, so this doubles as the SSRF guard.
|
||||
shop_domain text not null,
|
||||
-- Shop display name for the settings panel.
|
||||
shop_name text,
|
||||
-- AES-256-GCM encrypted Dev Dashboard custom-app client id/secret.
|
||||
client_id_encrypted text,
|
||||
client_secret_encrypted text,
|
||||
status text not null default 'pending'
|
||||
check (status in ('pending', 'active', 'revoked', 'error')),
|
||||
-- ISO 4217 shop currency read at connect time; drives the feed's cash account.
|
||||
currency text,
|
||||
-- Opt-in for the nightly order feed cron (the manual sync button ignores it).
|
||||
transaction_sync_enabled boolean not null default false,
|
||||
-- Order-polling cursor: max updatedAt processed. Re-polled with a 24h
|
||||
-- overlap; (company_id, external_id) dedup makes overlaps no-ops.
|
||||
last_order_synced_at timestamptz,
|
||||
error_message text,
|
||||
connected_at timestamptz,
|
||||
disconnected_at timestamptz,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
-- One active connection per company.
|
||||
create unique index shopify_connections_one_active_per_company
|
||||
on public.shopify_connections (company_id) where (status = 'active');
|
||||
|
||||
-- A store may be actively connected to at most one company: two companies
|
||||
-- importing the same order stream would double-book it.
|
||||
create unique index shopify_connections_shop_active_uniq
|
||||
on public.shopify_connections (shop_domain) where (status = 'active');
|
||||
|
||||
create index idx_shopify_connections_company_id
|
||||
on public.shopify_connections (company_id);
|
||||
|
||||
alter table public.shopify_connections enable row level security;
|
||||
|
||||
-- Members read their company's connection. Insert/update are member-scoped so
|
||||
-- the connect/disconnect routes can run on the user's cookie session; the
|
||||
-- sync cron uses the service role (bypasses RLS).
|
||||
-- No DELETE policy: connections are revoked (status flip), never deleted.
|
||||
create policy "members read shopify_connections"
|
||||
on public.shopify_connections for select
|
||||
using (company_id in (select public.user_company_ids()));
|
||||
|
||||
create policy "members insert shopify_connections"
|
||||
on public.shopify_connections for insert
|
||||
with check (
|
||||
company_id in (select public.user_company_ids())
|
||||
and user_id = auth.uid()
|
||||
);
|
||||
|
||||
create policy "members update shopify_connections"
|
||||
on public.shopify_connections for update
|
||||
using (company_id in (select public.user_company_ids()))
|
||||
with check (company_id in (select public.user_company_ids()));
|
||||
|
||||
create trigger set_updated_at_shopify_connections
|
||||
before update on public.shopify_connections
|
||||
for each row execute function public.update_updated_at_column();
|
||||
|
||||
comment on table public.shopify_connections is
|
||||
'Shopify store connections per company. Custom-app client id/secret stored AES-256-GCM encrypted; decryption requires the server-side SHOPIFY_CREDENTIALS_ENCRYPTION_KEY.';
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,29 @@
|
||||
-- Backfill capability_grants for the new 'shopify_sync' capability.
|
||||
--
|
||||
-- shopify_sync joins PAID_CAPABILITIES, but existing companies' grants were
|
||||
-- written by the billing webhook / trial seeding BEFORE this key existed, and
|
||||
-- are only refreshed on the next subscription event. Without a backfill,
|
||||
-- every current payer and trialer would see the Shopify integration as not
|
||||
-- entitled until their next webhook. Mirror each existing bank_sync grant
|
||||
-- (the same sibling used by the stripe_payments and woocommerce_sync
|
||||
-- backfills, 20260712100100 / 20260806170100) with identical scope, source
|
||||
-- and expiry, so entitlement state stays exactly aligned.
|
||||
--
|
||||
-- Idempotent: the (scope, key, source) unique index makes re-runs no-ops.
|
||||
|
||||
insert into public.capability_grants
|
||||
(company_id, team_id, capability_key, source, granted_at, expires_at, metadata)
|
||||
select
|
||||
g.company_id,
|
||||
g.team_id,
|
||||
'shopify_sync',
|
||||
g.source,
|
||||
g.granted_at,
|
||||
g.expires_at,
|
||||
jsonb_build_object(
|
||||
'backfilled_from', 'bank_sync',
|
||||
'backfill_migration', '20260808150100'
|
||||
)
|
||||
from public.capability_grants g
|
||||
where g.capability_key = 'bank_sync'
|
||||
on conflict (company_id, team_id, capability_key, source) do nothing;
|
||||
@@ -0,0 +1,130 @@
|
||||
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 shop_domain
|
||||
// partial unique index is global: fixed domains would collide with rows left
|
||||
// by a previous run before the assertion under test is ever reached.
|
||||
const uniqueShop = (label: string) => label + '-' + randomUUID() + '.myshopify.com'
|
||||
|
||||
/**
|
||||
* Covers migration 20260808150000_shopify_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('shopify_connections RLS', () => {
|
||||
it('a member can insert and read their company connection', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const shop = uniqueShop('member')
|
||||
await withUserContext(userId, async (client) => {
|
||||
const inserted = await client.query(
|
||||
`INSERT INTO public.shopify_connections
|
||||
(company_id, user_id, shop_domain, status)
|
||||
VALUES ($1, $2, $3, 'active')
|
||||
RETURNING id`,
|
||||
[companyId, userId, shop],
|
||||
)
|
||||
expect(inserted.rows).toHaveLength(1)
|
||||
|
||||
const read = await client.query(
|
||||
`SELECT status, shop_domain FROM public.shopify_connections WHERE company_id = $1`,
|
||||
[companyId],
|
||||
)
|
||||
expect(read.rows).toEqual([{ status: 'active', shop_domain: shop }])
|
||||
})
|
||||
})
|
||||
|
||||
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.shopify_connections (company_id, user_id, shop_domain, status)
|
||||
VALUES ($1, $2, $3, 'active')`,
|
||||
[companyId, ownerId, uniqueShop('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.shopify_connections WHERE company_id = $1`,
|
||||
[companyId],
|
||||
)
|
||||
expect(read.rows).toHaveLength(0)
|
||||
|
||||
await expect(
|
||||
client.query(
|
||||
`INSERT INTO public.shopify_connections (company_id, user_id, shop_domain, status)
|
||||
VALUES ($1, $2, $3, 'pending')`,
|
||||
[companyId, outsiderId, uniqueShop('intruder')],
|
||||
),
|
||||
).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.shopify_connections (company_id, user_id, shop_domain, status)
|
||||
VALUES ($1, $2, $3, 'active')`,
|
||||
[companyId, userId, uniqueShop('shop-one')],
|
||||
)
|
||||
await expect(
|
||||
getPool().query(
|
||||
`INSERT INTO public.shopify_connections (company_id, user_id, shop_domain, status)
|
||||
VALUES ($1, $2, $3, 'active')`,
|
||||
[companyId, userId, uniqueShop('shop-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 sharedShop = uniqueShop('shared')
|
||||
await getPool().query(
|
||||
`INSERT INTO public.shopify_connections (company_id, user_id, shop_domain, status)
|
||||
VALUES ($1, $2, $3, 'active')`,
|
||||
[companyA, userA, sharedShop],
|
||||
)
|
||||
await expect(
|
||||
getPool().query(
|
||||
`INSERT INTO public.shopify_connections (company_id, user_id, shop_domain, status)
|
||||
VALUES ($1, $2, $3, 'active')`,
|
||||
[companyB, userB, sharedShop],
|
||||
),
|
||||
).rejects.toMatchObject({ code: '23505' })
|
||||
|
||||
// A revoked row for the same store is fine (history is kept).
|
||||
const revoked = await getPool().query(
|
||||
`INSERT INTO public.shopify_connections (company_id, user_id, shop_domain, status)
|
||||
VALUES ($1, $2, $3, 'revoked') RETURNING id`,
|
||||
[companyB, userB, sharedShop],
|
||||
)
|
||||
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.shopify_connections (company_id, user_id, shop_domain, status)
|
||||
VALUES ($1, $2, $3, 'active') RETURNING id`,
|
||||
[companyId, userId, uniqueShop('keep')],
|
||||
)
|
||||
await withUserContext(userId, async (client) => {
|
||||
const del = await client.query(
|
||||
`DELETE FROM public.shopify_connections WHERE id = $1`,
|
||||
[rows[0].id],
|
||||
)
|
||||
expect(del.rowCount).toBe(0)
|
||||
})
|
||||
const still = await getPool().query(
|
||||
`SELECT id FROM public.shopify_connections WHERE id = $1`,
|
||||
[rows[0].id],
|
||||
)
|
||||
expect(still.rows).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -29,6 +29,10 @@
|
||||
"path": "/api/extensions/woocommerce/orders/cron",
|
||||
"schedule": "45 3 * * *"
|
||||
},
|
||||
{
|
||||
"path": "/api/extensions/shopify/orders/cron",
|
||||
"schedule": "15 3 * * *"
|
||||
},
|
||||
{
|
||||
"path": "/api/documents/verify/cron",
|
||||
"schedule": "0 3 * * *"
|
||||
|
||||
Reference in New Issue
Block a user