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:
Mattsson
2026-08-09 12:44:08 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 02a5d10538
commit c187fabf92
38 changed files with 4034 additions and 9 deletions
@@ -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.')
})
})
+414
View File
@@ -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>
)
}
+32
View File
@@ -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 } }
}
+19
View File
@@ -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."
}
}
+91
View File
@@ -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
}