feat(shopify): Shopify order/refund feed into the transactions inbox (#1474)
* feat(shopify): Shopify order/refund feed into the transactions inbox
New extensions/general/shopify feed extension, modeled on the WooCommerce
feed: connect a Shopify store with Dev Dashboard custom-app client
credentials (client credentials grant, ~24h tokens, never stored), then a
nightly cron + manual sync imports paid orders and refunds via the GraphQL
Admin API (pinned 2026-07) into the transactions inbox on clearing account
1584. Feed-only: nothing auto-books. Zero PII fields are queried, keeping
the app outside Shopify's protected customer data program.
- shopify_connections migration (RLS, revoke-never-delete, encrypted
client id/secret) + shopify_sync capability and bank_sync-mirrored
backfill
- frozen external_id scheme shopify_{shop_domain}_order|refund_{id},
scoped on the shop domain so reconnects never re-import
- cursor sync on updated_at windows with 24h overlap, lock-date drop at
map time, ingest-failure cursor floor, deadline stop-and-resume,
revoked-credential flip
- /import card + settings panel, sv/en i18n, cron 03:15 in vercel.json +
regenerated Docker crontabs, logo, events, panel registry
- 65 unit tests + pg-real RLS test; extensions.schema.json enum also
gains the missing stripe entry (pre-existing drift)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(shopify): review findings from PR 1474
- token exchange: a 429 that survives every retry is throttling, not a
credential failure; stop remapping retryable 4xx to 401 so sustained
throttling can no longer flip the connection to revoked and delete the
stored credentials (CodeRabbit critical)
- order sync: advance a scanned-through watermark (run start, capped by
the failure floor) after a fully-listed window, so empty first runs and
quiet stores rotate to the back of the cron's oldest-first selection
instead of permanently occupying the 50-connection batch (CodeRabbit
major, starvation)
- add handler-level tests for the orders cron route (auth 401, disabled
503, unconfigured no-op, query failure, capability skip, happy path,
per-connection failure isolation, revoked marking)
- add 401 tests for /sync, /transaction-sync and /disconnect; pin the
cursor floor rule with a two-order page; stub the encryption key via
vi.stubEnv
- note in the panel description (sv/en) that orders can mix VAT rates and
must be split at booking (Swedish review advisory)
- DECISIONS.md: wrap underscore identifiers in backticks (MD037)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
02a5d10538
commit
c187fabf92
@@ -0,0 +1,186 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import {
|
||||
normalizeShopDomain,
|
||||
exchangeAccessToken,
|
||||
shopifyGraphQL,
|
||||
listOrdersPage,
|
||||
isRevokedCredentialsError,
|
||||
ShopifyApiError,
|
||||
SHOPIFY_API_VERSION,
|
||||
SHOPIFY_PAGE_SIZE,
|
||||
type ShopifyCredentials,
|
||||
type ShopifySession,
|
||||
} from '../lib/api-client'
|
||||
|
||||
const CREDS: ShopifyCredentials = {
|
||||
shopDomain: 'minbutik.myshopify.com',
|
||||
clientId: 'client-id',
|
||||
clientSecret: 'client-secret',
|
||||
}
|
||||
const SESSION: ShopifySession = {
|
||||
shopDomain: 'minbutik.myshopify.com',
|
||||
accessToken: 'token-1',
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
const fetchMock = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('normalizeShopDomain', () => {
|
||||
it('accepts the full myshopify.com domain in any casing, with or without scheme', () => {
|
||||
expect(normalizeShopDomain('minbutik.myshopify.com')).toBe('minbutik.myshopify.com')
|
||||
expect(normalizeShopDomain(' MinButik.MyShopify.com ')).toBe('minbutik.myshopify.com')
|
||||
expect(normalizeShopDomain('https://minbutik.myshopify.com')).toBe('minbutik.myshopify.com')
|
||||
expect(normalizeShopDomain('https://minbutik.myshopify.com/admin')).toBe(
|
||||
'minbutik.myshopify.com',
|
||||
)
|
||||
})
|
||||
|
||||
it('expands a bare store handle', () => {
|
||||
expect(normalizeShopDomain('minbutik')).toBe('minbutik.myshopify.com')
|
||||
})
|
||||
|
||||
it('accepts a pasted admin URL', () => {
|
||||
expect(normalizeShopDomain('https://admin.shopify.com/store/minbutik')).toBe(
|
||||
'minbutik.myshopify.com',
|
||||
)
|
||||
expect(normalizeShopDomain('admin.shopify.com/store/minbutik/settings/apps')).toBe(
|
||||
'minbutik.myshopify.com',
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects anything that is not a myshopify.com host (SSRF guard)', () => {
|
||||
expect(normalizeShopDomain('minbutik.se')).toBeNull()
|
||||
expect(normalizeShopDomain('192.168.1.1')).toBeNull()
|
||||
expect(normalizeShopDomain('https://evil.example.com/x.myshopify.com')).toBeNull()
|
||||
expect(normalizeShopDomain('sub.domain.myshopify.com')).toBeNull()
|
||||
expect(normalizeShopDomain('')).toBeNull()
|
||||
// A bare word expands to <word>.myshopify.com, which is Shopify-owned:
|
||||
// safe by construction, never a private host.
|
||||
expect(normalizeShopDomain('localhost')).toBe('localhost.myshopify.com')
|
||||
})
|
||||
})
|
||||
|
||||
describe('exchangeAccessToken', () => {
|
||||
it('POSTs the client credentials grant and returns the token', async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
jsonResponse({ access_token: 'token-1', expires_in: 86399 }),
|
||||
)
|
||||
await expect(exchangeAccessToken(CREDS)).resolves.toBe('token-1')
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0]
|
||||
expect(url).toBe('https://minbutik.myshopify.com/admin/oauth/access_token')
|
||||
expect(JSON.parse((init as RequestInit).body as string)).toEqual({
|
||||
client_id: 'client-id',
|
||||
client_secret: 'client-secret',
|
||||
grant_type: 'client_credentials',
|
||||
})
|
||||
})
|
||||
|
||||
it('classifies a non-retryable 4xx as revoked credentials', async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
jsonResponse({ error: 'invalid_client' }, 400),
|
||||
)
|
||||
const error = await exchangeAccessToken(CREDS).catch((e) => e)
|
||||
expect(error).toBeInstanceOf(ShopifyApiError)
|
||||
expect(isRevokedCredentialsError(error)).toBe(true)
|
||||
expect((error as ShopifyApiError).code).toBe('invalid_client')
|
||||
})
|
||||
|
||||
it('does NOT classify sustained throttling (429) as revoked credentials', async () => {
|
||||
// A persistent 429 that survives every retry is still throttling.
|
||||
// Classifying it as revoked would delete the stored credentials and force
|
||||
// the merchant to reconnect over a rate limit.
|
||||
fetchMock.mockResolvedValue(jsonResponse({}, 429))
|
||||
const error = await exchangeAccessToken(CREDS).catch((e) => e)
|
||||
expect(error).toBeInstanceOf(ShopifyApiError)
|
||||
expect((error as ShopifyApiError).status).toBe(429)
|
||||
expect(isRevokedCredentialsError(error)).toBe(false)
|
||||
}, 15_000)
|
||||
})
|
||||
|
||||
describe('shopifyGraphQL', () => {
|
||||
it('sends the token header against the pinned API version and returns data', async () => {
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse({ data: { shop: { name: 'Butiken' } } }))
|
||||
const data = await shopifyGraphQL<{ shop: { name: string } }>(SESSION, 'query { shop { name } }')
|
||||
expect(data.shop.name).toBe('Butiken')
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0]
|
||||
expect(url).toBe(
|
||||
`https://minbutik.myshopify.com/admin/api/${SHOPIFY_API_VERSION}/graphql.json`,
|
||||
)
|
||||
expect((init as RequestInit).headers).toMatchObject({
|
||||
'X-Shopify-Access-Token': 'token-1',
|
||||
})
|
||||
})
|
||||
|
||||
it('retries a THROTTLED response before surfacing data', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({ errors: [{ message: 'Throttled', extensions: { code: 'THROTTLED' } }] }),
|
||||
)
|
||||
.mockResolvedValueOnce(jsonResponse({ data: { ok: true } }))
|
||||
await expect(shopifyGraphQL(SESSION, 'query { ok }')).resolves.toEqual({ ok: true })
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
}, 15_000)
|
||||
|
||||
it('classifies ACCESS_DENIED as revoked (missing scope)', async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
errors: [{ message: 'Access denied', extensions: { code: 'ACCESS_DENIED' } }],
|
||||
}),
|
||||
)
|
||||
const error = await shopifyGraphQL(SESSION, 'query { orders }').catch((e) => e)
|
||||
expect(isRevokedCredentialsError(error)).toBe(true)
|
||||
})
|
||||
|
||||
it('classifies HTTP 401 as revoked without retrying', async () => {
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse({}, 401))
|
||||
const error = await shopifyGraphQL(SESSION, 'query { ok }').catch((e) => e)
|
||||
expect(isRevokedCredentialsError(error)).toBe(true)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('listOrdersPage', () => {
|
||||
it('passes the window filter and cursor and unwraps the connection', async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
data: {
|
||||
orders: {
|
||||
pageInfo: { hasNextPage: true, endCursor: 'cursor-2' },
|
||||
nodes: [{ legacyResourceId: '1042', name: '#1042' }],
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
const page = await listOrdersPage(SESSION, {
|
||||
updatedAtMin: '2026-05-01T00:00:00.000Z',
|
||||
after: 'cursor-1',
|
||||
})
|
||||
expect(page.hasNextPage).toBe(true)
|
||||
expect(page.endCursor).toBe('cursor-2')
|
||||
expect(page.orders).toHaveLength(1)
|
||||
|
||||
const body = JSON.parse((fetchMock.mock.calls[0][1] as RequestInit).body as string)
|
||||
expect(body.variables).toEqual({
|
||||
first: SHOPIFY_PAGE_SIZE,
|
||||
after: 'cursor-1',
|
||||
query: "updated_at:>='2026-05-01T00:00:00.000Z'",
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,371 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
// Force the capability gate to run but stub requireCapability so entitlement
|
||||
// is controlled per test. Mirrors the stripe/woocommerce suites.
|
||||
vi.mock('@/lib/entitlements/has-capability', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/entitlements/has-capability')>()
|
||||
return { ...actual, requireCapability: vi.fn().mockResolvedValue(null) }
|
||||
})
|
||||
|
||||
// Never let a unit test reach a real Shopify host: the credential probe is
|
||||
// mocked, the pure helpers (normalizeShopDomain) stay real.
|
||||
vi.mock('../lib/api-client', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../lib/api-client')>()
|
||||
return { ...actual, testConnectionAndFetchShopInfo: vi.fn() }
|
||||
})
|
||||
|
||||
// The sync engine has its own suite; here it only needs to be callable.
|
||||
vi.mock('../lib/order-sync', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../lib/order-sync')>()
|
||||
return { ...actual, syncShopifyOrders: vi.fn() }
|
||||
})
|
||||
|
||||
vi.mock('@/lib/auth/api-keys', () => ({
|
||||
createServiceClientNoCookies: vi.fn(() => ({ service: true })),
|
||||
}))
|
||||
|
||||
import { shopifyExtension } from '../index'
|
||||
import { requireCapability, capabilityBlockedResponse } from '@/lib/entitlements/has-capability'
|
||||
import { CAPABILITY } from '@/lib/entitlements/keys'
|
||||
import { testConnectionAndFetchShopInfo } from '../lib/api-client'
|
||||
import { syncShopifyOrders } from '../lib/order-sync'
|
||||
import { decryptCredential } from '../lib/credentials'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
import type { ExtensionContext } from '@/lib/extensions/types'
|
||||
|
||||
function findRoute(method: string, path: string) {
|
||||
const route = shopifyExtension.apiRoutes?.find(
|
||||
(r) => r.method === method && r.path === path,
|
||||
)
|
||||
expect(route, `${method} ${path} must be registered`).toBeDefined()
|
||||
return route!
|
||||
}
|
||||
|
||||
function makeRequest(method: string, body?: unknown): Request {
|
||||
return new Request('https://test.local/api/extensions/ext/shopify/x', {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
function makeContext(supabase: unknown): ExtensionContext {
|
||||
return {
|
||||
userId: 'user-1',
|
||||
companyId: 'company-1',
|
||||
extensionId: 'shopify',
|
||||
requestId: 'req_test',
|
||||
supabase,
|
||||
emit: vi.fn().mockResolvedValue(undefined),
|
||||
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
settings: {
|
||||
get: vi.fn().mockResolvedValue(null),
|
||||
set: vi.fn().mockResolvedValue(undefined),
|
||||
clear: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any
|
||||
}
|
||||
|
||||
const USER = { id: 'user-1', is_anonymous: false }
|
||||
|
||||
const VALID_CONNECT_BODY = {
|
||||
shop_domain: 'minbutik.myshopify.com',
|
||||
client_id: 'client-id',
|
||||
client_secret: 'client-secret',
|
||||
}
|
||||
|
||||
describe('shopify extension routes', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(requireCapability).mockResolvedValue(null)
|
||||
vi.stubEnv('SHOPIFY_CREDENTIALS_ENCRYPTION_KEY', 'test-key')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
describe('GET /status', () => {
|
||||
it('returns 401 without a user', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
supabase.auth.getUser.mockResolvedValue({ data: { user: null }, error: null })
|
||||
const res = await findRoute('GET', '/status').handler(
|
||||
makeRequest('GET'),
|
||||
makeContext(supabase),
|
||||
)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('prefers the active connection and reports configured', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null })
|
||||
enqueue({
|
||||
data: [
|
||||
{ id: 'c2', status: 'revoked' },
|
||||
{ id: 'c1', status: 'active', shop_domain: 'minbutik.myshopify.com' },
|
||||
],
|
||||
})
|
||||
const res = await findRoute('GET', '/status').handler(
|
||||
makeRequest('GET'),
|
||||
makeContext(supabase),
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.configured).toBe(true)
|
||||
expect(body.connection.id).toBe('c1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /connect', () => {
|
||||
it('returns 401 without a user', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
supabase.auth.getUser.mockResolvedValue({ data: { user: null }, error: null })
|
||||
const res = await findRoute('POST', '/connect').handler(
|
||||
makeRequest('POST', {}),
|
||||
makeContext(supabase),
|
||||
)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('blocks anonymous (sandbox) users before any external call', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
supabase.auth.getUser.mockResolvedValue({
|
||||
data: { user: { id: 'user-1', is_anonymous: true } },
|
||||
error: null,
|
||||
})
|
||||
const res = await findRoute('POST', '/connect').handler(
|
||||
makeRequest('POST', {}),
|
||||
makeContext(supabase),
|
||||
)
|
||||
expect(res.status).toBe(403)
|
||||
const body = await res.json()
|
||||
expect(body.sandbox_blocked).toBe(true)
|
||||
expect(testConnectionAndFetchShopInfo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 403 capability_blocked when not entitled', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null })
|
||||
enqueue({ data: { is_sandbox: false } })
|
||||
vi.mocked(requireCapability).mockResolvedValue(
|
||||
capabilityBlockedResponse(CAPABILITY.shopify_sync),
|
||||
)
|
||||
const res = await findRoute('POST', '/connect').handler(
|
||||
makeRequest('POST', VALID_CONNECT_BODY),
|
||||
makeContext(supabase),
|
||||
)
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
|
||||
it('rejects a non-myshopify.com domain with 400', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null })
|
||||
enqueue({ data: { is_sandbox: false } })
|
||||
const res = await findRoute('POST', '/connect').handler(
|
||||
makeRequest('POST', { ...VALID_CONNECT_BODY, shop_domain: 'https://minbutik.se' }),
|
||||
makeContext(supabase),
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('rejects missing client credentials with 400', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null })
|
||||
enqueue({ data: { is_sandbox: false } })
|
||||
const res = await findRoute('POST', '/connect').handler(
|
||||
makeRequest('POST', { shop_domain: 'minbutik.myshopify.com', client_id: 'x' }),
|
||||
makeContext(supabase),
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 409 when an active connection already exists', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null })
|
||||
enqueue({ data: { is_sandbox: false } })
|
||||
enqueue({ data: [{ id: 'conn-0', status: 'active' }] })
|
||||
const res = await findRoute('POST', '/connect').handler(
|
||||
makeRequest('POST', VALID_CONNECT_BODY),
|
||||
makeContext(supabase),
|
||||
)
|
||||
expect(res.status).toBe(409)
|
||||
expect(testConnectionAndFetchShopInfo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects with 400 when the credential probe fails', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null })
|
||||
enqueue({ data: { is_sandbox: false } })
|
||||
enqueue({ data: [] }) // no existing connection
|
||||
vi.mocked(testConnectionAndFetchShopInfo).mockRejectedValue(new Error('401'))
|
||||
const res = await findRoute('POST', '/connect').handler(
|
||||
makeRequest('POST', VALID_CONNECT_BODY),
|
||||
makeContext(supabase),
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('verifies, encrypts and activates on the happy path', async () => {
|
||||
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
|
||||
supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null })
|
||||
enqueue({ data: { is_sandbox: false } })
|
||||
enqueue({ data: [] }) // no existing connection
|
||||
enqueue({ data: { id: 'conn-1', shop_domain: 'minbutik.myshopify.com' } }) // insert
|
||||
vi.mocked(testConnectionAndFetchShopInfo).mockResolvedValue({
|
||||
name: 'Testbutiken',
|
||||
currency: 'SEK',
|
||||
})
|
||||
const ctx = makeContext(supabase)
|
||||
const res = await findRoute('POST', '/connect').handler(
|
||||
makeRequest('POST', { ...VALID_CONNECT_BODY, shop_domain: 'MinButik' }),
|
||||
ctx,
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
const inserted = findCall('shopify_connections', 'insert')?.[0] as Record<
|
||||
string,
|
||||
string
|
||||
>
|
||||
expect(inserted.status).toBe('active')
|
||||
expect(inserted.shop_name).toBe('Testbutiken')
|
||||
// The bare handle was normalized before probing and storing.
|
||||
expect(inserted.shop_domain).toBe('minbutik.myshopify.com')
|
||||
// Secrets never stored in plaintext, and they decrypt back.
|
||||
expect(inserted.client_id_encrypted).not.toContain('client-id')
|
||||
expect(decryptCredential(inserted.client_id_encrypted)).toBe('client-id')
|
||||
expect(decryptCredential(inserted.client_secret_encrypted)).toBe('client-secret')
|
||||
expect(ctx.emit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'shopify.connected' }),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /sync', () => {
|
||||
it('returns 401 without a user', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
supabase.auth.getUser.mockResolvedValue({ data: { user: null }, error: null })
|
||||
const res = await findRoute('POST', '/sync').handler(
|
||||
makeRequest('POST'),
|
||||
makeContext(supabase),
|
||||
)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 404 without an active connection', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null })
|
||||
enqueue({ data: null })
|
||||
const res = await findRoute('POST', '/sync').handler(
|
||||
makeRequest('POST'),
|
||||
makeContext(supabase),
|
||||
)
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('runs the sync on the service client and returns the summary', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null })
|
||||
enqueue({ data: { id: 'conn-1', status: 'active' } })
|
||||
vi.mocked(syncShopifyOrders).mockResolvedValue({
|
||||
fetched: 3,
|
||||
refundsFetched: 1,
|
||||
imported: 4,
|
||||
duplicates: 0,
|
||||
skippedLocked: 0,
|
||||
errors: 0,
|
||||
})
|
||||
const res = await findRoute('POST', '/sync').handler(
|
||||
makeRequest('POST'),
|
||||
makeContext(supabase),
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.transactions.imported).toBe(4)
|
||||
expect(vi.mocked(syncShopifyOrders).mock.calls[0][0]).toEqual({ service: true })
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /transaction-sync', () => {
|
||||
it('returns 401 without a user', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
supabase.auth.getUser.mockResolvedValue({ data: { user: null }, error: null })
|
||||
const res = await findRoute('POST', '/transaction-sync').handler(
|
||||
makeRequest('POST', { enabled: true }),
|
||||
makeContext(supabase),
|
||||
)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('rejects a non-boolean enabled with 400', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null })
|
||||
const res = await findRoute('POST', '/transaction-sync').handler(
|
||||
makeRequest('POST', { enabled: 'yes' }),
|
||||
makeContext(supabase),
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('persists the toggle for the active connection', async () => {
|
||||
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
|
||||
supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null })
|
||||
enqueue({ data: [{ id: 'conn-1' }] })
|
||||
const res = await findRoute('POST', '/transaction-sync').handler(
|
||||
makeRequest('POST', { enabled: false }),
|
||||
makeContext(supabase),
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
expect(findCall('shopify_connections', 'update')?.[0]).toEqual({
|
||||
transaction_sync_enabled: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('DELETE /disconnect', () => {
|
||||
it('returns 401 without a user', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
supabase.auth.getUser.mockResolvedValue({ data: { user: null }, error: null })
|
||||
const res = await findRoute('DELETE', '/disconnect').handler(
|
||||
makeRequest('DELETE', {}),
|
||||
makeContext(supabase),
|
||||
)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 404 when no connection exists', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null })
|
||||
enqueue({ data: [] })
|
||||
const res = await findRoute('DELETE', '/disconnect').handler(
|
||||
makeRequest('DELETE', {}),
|
||||
makeContext(supabase),
|
||||
)
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('revokes (never deletes), drops credentials and emits the audit event', async () => {
|
||||
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
|
||||
supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null })
|
||||
enqueue({
|
||||
data: [{ id: 'conn-1', status: 'active', shop_domain: 'minbutik.myshopify.com' }],
|
||||
})
|
||||
enqueue({ data: null }) // update
|
||||
const ctx = makeContext(supabase)
|
||||
const res = await findRoute('DELETE', '/disconnect').handler(
|
||||
makeRequest('DELETE', {}),
|
||||
ctx,
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
const updated = findCall('shopify_connections', 'update')?.[0] as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
expect(updated.status).toBe('revoked')
|
||||
expect(updated.client_id_encrypted).toBeNull()
|
||||
expect(updated.client_secret_encrypted).toBeNull()
|
||||
expect(ctx.emit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'shopify.disconnected' }),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import {
|
||||
encryptCredential,
|
||||
decryptCredential,
|
||||
credentialsOf,
|
||||
isShopifyConfigured,
|
||||
} from '../lib/credentials'
|
||||
|
||||
describe('shopify credentials encryption', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('SHOPIFY_CREDENTIALS_ENCRYPTION_KEY', 'test-key')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
it('round-trips a credential', () => {
|
||||
const ciphertext = encryptCredential('shpca_client_secret_123')
|
||||
expect(ciphertext).not.toContain('shpca')
|
||||
expect(decryptCredential(ciphertext)).toBe('shpca_client_secret_123')
|
||||
})
|
||||
|
||||
it('uses a fresh IV per encryption', () => {
|
||||
const a = encryptCredential('same-input')
|
||||
const b = encryptCredential('same-input')
|
||||
expect(a).not.toBe(b)
|
||||
expect(decryptCredential(a)).toBe('same-input')
|
||||
expect(decryptCredential(b)).toBe('same-input')
|
||||
})
|
||||
|
||||
it('rejects tampered ciphertext', () => {
|
||||
const ciphertext = encryptCredential('secret')
|
||||
const buf = Buffer.from(ciphertext, 'base64url')
|
||||
buf[buf.length - 1] ^= 0xff
|
||||
expect(() => decryptCredential(buf.toString('base64url'))).toThrow()
|
||||
})
|
||||
|
||||
it('throws without the env key', () => {
|
||||
vi.stubEnv('SHOPIFY_CREDENTIALS_ENCRYPTION_KEY', '')
|
||||
expect(isShopifyConfigured()).toBe(false)
|
||||
expect(() => encryptCredential('x')).toThrow(/SHOPIFY_CREDENTIALS_ENCRYPTION_KEY/)
|
||||
})
|
||||
|
||||
it('credentialsOf decrypts a connection and refuses missing credentials', () => {
|
||||
const creds = credentialsOf({
|
||||
shop_domain: 'minbutik.myshopify.com',
|
||||
client_id_encrypted: encryptCredential('client-id'),
|
||||
client_secret_encrypted: encryptCredential('client-secret'),
|
||||
})
|
||||
expect(creds).toEqual({
|
||||
shopDomain: 'minbutik.myshopify.com',
|
||||
clientId: 'client-id',
|
||||
clientSecret: 'client-secret',
|
||||
})
|
||||
expect(() =>
|
||||
credentialsOf({
|
||||
shop_domain: 'minbutik.myshopify.com',
|
||||
client_id_encrypted: null,
|
||||
client_secret_encrypted: null,
|
||||
}),
|
||||
).toThrow(/no stored credentials/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,522 @@
|
||||
import { describe, it, expect, vi, beforeEach, beforeAll, afterAll } from 'vitest'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
|
||||
const listOrdersPage = vi.fn()
|
||||
const createShopifySession = vi.fn()
|
||||
|
||||
vi.mock('../lib/api-client', () => ({
|
||||
listOrdersPage: (...args: unknown[]) => listOrdersPage(...args),
|
||||
createShopifySession: (...args: unknown[]) => createShopifySession(...args),
|
||||
isRevokedCredentialsError: (error: unknown) =>
|
||||
error instanceof Error && error.message === 'REVOKED',
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/transactions/ingest', () => ({
|
||||
ingestTransactions: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/cash-accounts/service', () => ({
|
||||
ensureManualCashAccount: vi.fn().mockResolvedValue('cash-account-1'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/import/account-sync', () => ({
|
||||
syncMappedAccounts: vi.fn().mockResolvedValue({ error: null }),
|
||||
}))
|
||||
|
||||
import { ingestTransactions } from '@/lib/transactions/ingest'
|
||||
import { ensureManualCashAccount } from '@/lib/cash-accounts/service'
|
||||
import { encryptCredential } from '../lib/credentials'
|
||||
import {
|
||||
SHOPIFY_IMPORT_SOURCE,
|
||||
SHOPIFY_LEDGER_ACCOUNT,
|
||||
mapOrder,
|
||||
mapRefund,
|
||||
orderQualifies,
|
||||
rowBehindLock,
|
||||
shopifyOrderExternalId,
|
||||
shopifyRefundExternalId,
|
||||
shopifyShopScope,
|
||||
syncShopifyOrders,
|
||||
} from '../lib/order-sync'
|
||||
import type { ShopifyConnection, ShopifyOrder, ShopifyRefund } from '../types'
|
||||
|
||||
beforeAll(() => {
|
||||
vi.stubEnv('SHOPIFY_CREDENTIALS_ENCRYPTION_KEY', 'test-key')
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
function makeConnection(overrides: Partial<ShopifyConnection> = {}): ShopifyConnection {
|
||||
return {
|
||||
id: 'conn-1',
|
||||
company_id: 'company-1',
|
||||
user_id: 'user-1',
|
||||
shop_domain: 'minbutik.myshopify.com',
|
||||
shop_name: 'Testbutiken',
|
||||
client_id_encrypted: encryptCredential('client-id'),
|
||||
client_secret_encrypted: encryptCredential('client-secret'),
|
||||
status: 'active',
|
||||
currency: 'SEK',
|
||||
transaction_sync_enabled: true,
|
||||
last_order_synced_at: null,
|
||||
error_message: null,
|
||||
connected_at: '2026-07-01T00:00:00.000Z',
|
||||
disconnected_at: null,
|
||||
created_at: '2026-07-01T00:00:00.000Z',
|
||||
updated_at: '2026-07-01T00:00:00.000Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function money(amount: string, currencyCode = 'SEK') {
|
||||
return { shopMoney: { amount, currencyCode } }
|
||||
}
|
||||
|
||||
function makeOrder(overrides: Partial<ShopifyOrder> = {}): ShopifyOrder {
|
||||
return {
|
||||
legacyResourceId: '1042',
|
||||
name: '#1042',
|
||||
test: false,
|
||||
processedAt: '2026-08-01T09:04:30Z',
|
||||
updatedAt: '2026-08-01T09:05:00Z',
|
||||
displayFinancialStatus: 'PAID',
|
||||
paymentGatewayNames: ['Klarna'],
|
||||
totalPriceSet: money('1250.00'),
|
||||
refunds: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/** One-page result helper; the loop terminates on hasNextPage: false. */
|
||||
function page(orders: ShopifyOrder[], hasNextPage = false, endCursor: string | null = null) {
|
||||
return { orders, hasNextPage, endCursor }
|
||||
}
|
||||
|
||||
/** Minimal chainable supabase mock covering the sync's query patterns. */
|
||||
function makeSupabaseMock(options: { lockThrough?: string | null } = {}) {
|
||||
const updates: Array<{ table: string; values: Record<string, unknown> }> = []
|
||||
const client = {
|
||||
from(table: string) {
|
||||
const builder = {
|
||||
select: () => builder,
|
||||
eq: () => builder,
|
||||
maybeSingle: async () => ({
|
||||
data:
|
||||
table === 'company_settings'
|
||||
? { bookkeeping_locked_through: options.lockThrough ?? null }
|
||||
: null,
|
||||
error: null,
|
||||
}),
|
||||
update: (values: Record<string, unknown>) => {
|
||||
updates.push({ table, values })
|
||||
return builder
|
||||
},
|
||||
}
|
||||
return builder
|
||||
},
|
||||
}
|
||||
return { client: client as unknown as SupabaseClient, updates }
|
||||
}
|
||||
|
||||
function cursorUpdates(updates: Array<{ table: string; values: Record<string, unknown> }>) {
|
||||
return updates.filter(
|
||||
(u) => u.table === 'shopify_connections' && 'last_order_synced_at' in u.values,
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
createShopifySession.mockResolvedValue({
|
||||
shopDomain: 'minbutik.myshopify.com',
|
||||
accessToken: 'token-1',
|
||||
})
|
||||
listOrdersPage.mockResolvedValue(page([]))
|
||||
vi.mocked(ingestTransactions).mockResolvedValue({
|
||||
imported: 0,
|
||||
duplicates: 0,
|
||||
errors: 0,
|
||||
} as Awaited<ReturnType<typeof ingestTransactions>>)
|
||||
})
|
||||
|
||||
describe('frozen external_id formats', () => {
|
||||
// ⚠️ These assert the exact persisted formats. If this test fails, you are
|
||||
// about to orphan every previously imported Shopify row: do not update the
|
||||
// expectation without a coordinated backfill (see order-sync.ts).
|
||||
it('order id format is frozen', () => {
|
||||
expect(shopifyOrderExternalId('minbutik.myshopify.com', '1042')).toBe(
|
||||
'shopify_minbutik.myshopify.com_order_1042',
|
||||
)
|
||||
})
|
||||
|
||||
it('refund id format is frozen', () => {
|
||||
expect(shopifyRefundExternalId('minbutik.myshopify.com', '77')).toBe(
|
||||
'shopify_minbutik.myshopify.com_refund_77',
|
||||
)
|
||||
})
|
||||
|
||||
it('shop scope is the stored shop domain', () => {
|
||||
expect(shopifyShopScope('minbutik.myshopify.com')).toBe('minbutik.myshopify.com')
|
||||
})
|
||||
|
||||
it('import source and ledger account are frozen', () => {
|
||||
expect(SHOPIFY_IMPORT_SOURCE).toBe('shopify')
|
||||
expect(SHOPIFY_LEDGER_ACCOUNT).toBe('1584')
|
||||
})
|
||||
})
|
||||
|
||||
describe('orderQualifies', () => {
|
||||
it('requires a paid financial status and excludes test orders', () => {
|
||||
expect(orderQualifies(makeOrder())).toBe(true)
|
||||
expect(orderQualifies(makeOrder({ displayFinancialStatus: 'PARTIALLY_REFUNDED' }))).toBe(true)
|
||||
expect(orderQualifies(makeOrder({ displayFinancialStatus: 'REFUNDED' }))).toBe(true)
|
||||
expect(orderQualifies(makeOrder({ displayFinancialStatus: 'PENDING' }))).toBe(false)
|
||||
expect(orderQualifies(makeOrder({ displayFinancialStatus: 'AUTHORIZED' }))).toBe(false)
|
||||
expect(orderQualifies(makeOrder({ displayFinancialStatus: 'PARTIALLY_PAID' }))).toBe(false)
|
||||
expect(orderQualifies(makeOrder({ displayFinancialStatus: null }))).toBe(false)
|
||||
expect(orderQualifies(makeOrder({ test: true }))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mapOrder', () => {
|
||||
it('maps a paid order to one gross row dated by processedAt', () => {
|
||||
const rows = mapOrder('minbutik.myshopify.com', makeOrder())
|
||||
expect(rows).toEqual([
|
||||
{
|
||||
date: '2026-08-01',
|
||||
description: 'Shopify-order #1042',
|
||||
amount: 1250,
|
||||
currency: 'SEK',
|
||||
external_id: 'shopify_minbutik.myshopify.com_order_1042',
|
||||
import_source: 'shopify',
|
||||
reference: 'Klarna',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('rounds string money to two decimals and uppercases the currency', () => {
|
||||
const rows = mapOrder('s', makeOrder({ totalPriceSet: money('99.995', 'eur') }))
|
||||
expect(rows[0].amount).toBe(100)
|
||||
expect(rows[0].currency).toBe('EUR')
|
||||
})
|
||||
|
||||
it('skips unpaid, test, zero-total and unparseable orders', () => {
|
||||
expect(mapOrder('s', makeOrder({ displayFinancialStatus: 'PENDING' }))).toEqual([])
|
||||
expect(mapOrder('s', makeOrder({ test: true }))).toEqual([])
|
||||
expect(mapOrder('s', makeOrder({ totalPriceSet: money('0.00') }))).toEqual([])
|
||||
expect(mapOrder('s', makeOrder({ totalPriceSet: money('not-a-number') }))).toEqual([])
|
||||
})
|
||||
|
||||
it('leaves the reference null when no gateways are reported', () => {
|
||||
expect(mapOrder('s', makeOrder({ paymentGatewayNames: [] }))[0].reference).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('mapRefund', () => {
|
||||
const refund: ShopifyRefund = {
|
||||
legacyResourceId: '77',
|
||||
createdAt: '2026-08-03T10:00:00Z',
|
||||
totalRefundedSet: money('250.00'),
|
||||
}
|
||||
|
||||
it('maps a refund to one negative row dated by the refund date', () => {
|
||||
const rows = mapRefund('minbutik.myshopify.com', makeOrder(), refund)
|
||||
expect(rows).toEqual([
|
||||
{
|
||||
date: '2026-08-03',
|
||||
description: 'Shopify-återbetalning order #1042',
|
||||
amount: -250,
|
||||
currency: 'SEK',
|
||||
external_id: 'shopify_minbutik.myshopify.com_refund_77',
|
||||
import_source: 'shopify',
|
||||
reference: null,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('skips zero-amount refunds', () => {
|
||||
expect(
|
||||
mapRefund('s', makeOrder(), { ...refund, totalRefundedSet: money('0') }),
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('rowBehindLock', () => {
|
||||
it('drops dates on/before the lock and keeps later ones', () => {
|
||||
expect(rowBehindLock('2026-06-30', '2026-06-30')).toBe(true)
|
||||
expect(rowBehindLock('2026-06-15', '2026-06-30')).toBe(true)
|
||||
expect(rowBehindLock('2026-07-01', '2026-06-30')).toBe(false)
|
||||
expect(rowBehindLock('2026-06-15', null)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('syncShopifyOrders', () => {
|
||||
it('ingests order and refund rows against the 1584 cash account and advances the cursor', async () => {
|
||||
const { client, updates } = makeSupabaseMock()
|
||||
const order = makeOrder({
|
||||
displayFinancialStatus: 'PARTIALLY_REFUNDED',
|
||||
refunds: [
|
||||
{
|
||||
legacyResourceId: '77',
|
||||
createdAt: '2026-08-03T10:00:00Z',
|
||||
totalRefundedSet: money('250.00'),
|
||||
},
|
||||
],
|
||||
})
|
||||
listOrdersPage.mockResolvedValueOnce(page([order]))
|
||||
vi.mocked(ingestTransactions).mockResolvedValueOnce({
|
||||
imported: 2,
|
||||
duplicates: 0,
|
||||
errors: 0,
|
||||
} as Awaited<ReturnType<typeof ingestTransactions>>)
|
||||
|
||||
const summary = await syncShopifyOrders(client, makeConnection())
|
||||
|
||||
expect(summary).toMatchObject({ fetched: 1, refundsFetched: 1, imported: 2, duplicates: 0 })
|
||||
expect(ensureManualCashAccount).toHaveBeenCalledWith(
|
||||
client,
|
||||
'company-1',
|
||||
'1584',
|
||||
'SEK',
|
||||
'Shopify-saldo',
|
||||
)
|
||||
expect(ingestTransactions).toHaveBeenCalledTimes(1)
|
||||
const [, companyId, userId, rows, ingestOptions] =
|
||||
vi.mocked(ingestTransactions).mock.calls[0]
|
||||
expect(companyId).toBe('company-1')
|
||||
expect(userId).toBe('user-1')
|
||||
expect((rows as Array<{ external_id: string }>).map((r) => r.external_id)).toEqual([
|
||||
'shopify_minbutik.myshopify.com_order_1042',
|
||||
'shopify_minbutik.myshopify.com_refund_77',
|
||||
])
|
||||
expect(ingestOptions).toEqual({ settlementAccount: '1584', skipAutoCategorization: true })
|
||||
|
||||
// Cursor persisted from the page's max updatedAt, and any stale
|
||||
// error_message is cleared on progress. A fully-listed window then
|
||||
// advances the watermark to the run start time.
|
||||
const cursors = cursorUpdates(updates)
|
||||
expect(cursors).toHaveLength(2)
|
||||
expect(cursors[0].values.last_order_synced_at).toBe('2026-08-01T09:05:00.000Z')
|
||||
expect(cursors[0].values.error_message).toBeNull()
|
||||
const watermarkMs = Date.parse(cursors[1].values.last_order_synced_at as string)
|
||||
expect(Math.abs(watermarkMs - Date.now())).toBeLessThan(60_000)
|
||||
// hasNextPage: false terminates without a second list call.
|
||||
expect(listOrdersPage).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('walks Relay cursors within one fixed window', async () => {
|
||||
const { client } = makeSupabaseMock()
|
||||
const first = makeOrder()
|
||||
const second = makeOrder({
|
||||
legacyResourceId: '1043',
|
||||
name: '#1043',
|
||||
updatedAt: '2026-08-02T08:00:00Z',
|
||||
})
|
||||
listOrdersPage
|
||||
.mockResolvedValueOnce(page([first], true, 'cursor-1'))
|
||||
.mockResolvedValueOnce(page([second]))
|
||||
|
||||
const summary = await syncShopifyOrders(client, makeConnection())
|
||||
|
||||
expect(summary.fetched).toBe(2)
|
||||
expect(listOrdersPage).toHaveBeenCalledTimes(2)
|
||||
const [firstArgs, secondArgs] = listOrdersPage.mock.calls.map((c) => c[1])
|
||||
expect(firstArgs.after).toBeNull()
|
||||
expect(secondArgs.after).toBe('cursor-1')
|
||||
// The window is fixed for the whole run; only the cursor moves.
|
||||
expect(secondArgs.updatedAtMin).toBe(firstArgs.updatedAtMin)
|
||||
})
|
||||
|
||||
it('re-polls with a 24h overlap from the persisted cursor', async () => {
|
||||
const { client } = makeSupabaseMock()
|
||||
await syncShopifyOrders(
|
||||
client,
|
||||
makeConnection({ last_order_synced_at: '2026-08-05T12:00:00.000Z' }),
|
||||
)
|
||||
expect(listOrdersPage.mock.calls[0][1].updatedAtMin).toBe('2026-08-04T12:00:00.000Z')
|
||||
})
|
||||
|
||||
it('drops rows dated on/before the bookkeeping lock on every run', async () => {
|
||||
const { client, updates } = makeSupabaseMock({ lockThrough: '2026-08-02' })
|
||||
// Order paid 2026-08-01 (behind lock), refund created 2026-08-03 (after).
|
||||
const order = makeOrder({
|
||||
displayFinancialStatus: 'PARTIALLY_REFUNDED',
|
||||
refunds: [
|
||||
{
|
||||
legacyResourceId: '77',
|
||||
createdAt: '2026-08-03T10:00:00Z',
|
||||
totalRefundedSet: money('250.00'),
|
||||
},
|
||||
],
|
||||
})
|
||||
listOrdersPage.mockResolvedValueOnce(page([order]))
|
||||
|
||||
const summary = await syncShopifyOrders(client, makeConnection())
|
||||
|
||||
expect(summary.skippedLocked).toBe(1)
|
||||
const [, , , rows] = vi.mocked(ingestTransactions).mock.calls[0]
|
||||
expect((rows as Array<{ external_id: string }>).map((r) => r.external_id)).toEqual([
|
||||
'shopify_minbutik.myshopify.com_refund_77',
|
||||
])
|
||||
// The cursor still advances (page + watermark): the drop is by design,
|
||||
// not a failure.
|
||||
expect(cursorUpdates(updates)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('holds the cursor below a page whose ingest reported errors', async () => {
|
||||
const { client, updates } = makeSupabaseMock()
|
||||
// Two orders so the assertion distinguishes "first updatedAt minus 1s"
|
||||
// (the floor rule) from "max updatedAt minus 1s".
|
||||
listOrdersPage.mockResolvedValueOnce(
|
||||
page([
|
||||
makeOrder(),
|
||||
makeOrder({ legacyResourceId: '1043', name: '#1043', updatedAt: '2026-08-02T08:00:00Z' }),
|
||||
]),
|
||||
)
|
||||
vi.mocked(ingestTransactions).mockResolvedValueOnce({
|
||||
imported: 0,
|
||||
duplicates: 0,
|
||||
errors: 1,
|
||||
} as Awaited<ReturnType<typeof ingestTransactions>>)
|
||||
|
||||
const summary = await syncShopifyOrders(client, makeConnection())
|
||||
|
||||
expect(summary.errors).toBe(1)
|
||||
// Page's FIRST updatedAt 09:05:00 minus 1s: the next run re-lists it.
|
||||
// The floor also caps the end-of-run watermark, so no second update.
|
||||
const cursors = cursorUpdates(updates)
|
||||
expect(cursors).toHaveLength(1)
|
||||
expect(cursors[0].values.last_order_synced_at).toBe('2026-08-01T09:04:59.000Z')
|
||||
})
|
||||
|
||||
it('falls back to the first order currency when the shop currency was unreadable', async () => {
|
||||
const { client } = makeSupabaseMock()
|
||||
listOrdersPage.mockResolvedValueOnce(
|
||||
page([makeOrder({ totalPriceSet: money('10.00', 'eur') })]),
|
||||
)
|
||||
|
||||
await syncShopifyOrders(client, makeConnection({ currency: null }))
|
||||
|
||||
expect(ensureManualCashAccount).toHaveBeenCalledWith(
|
||||
client,
|
||||
'company-1',
|
||||
'1584',
|
||||
'EUR',
|
||||
'Shopify-saldo',
|
||||
)
|
||||
})
|
||||
|
||||
it('surfaces a cash-account failure on the connection instead of failing silently', async () => {
|
||||
const { client, updates } = makeSupabaseMock()
|
||||
listOrdersPage.mockResolvedValueOnce(page([makeOrder()]))
|
||||
vi.mocked(ensureManualCashAccount).mockRejectedValueOnce(
|
||||
new Error('cash account 1584 exists with currency EUR'),
|
||||
)
|
||||
|
||||
await expect(syncShopifyOrders(client, makeConnection())).rejects.toThrow(
|
||||
/currency EUR/,
|
||||
)
|
||||
const errorUpdate = updates.find(
|
||||
(u) => u.table === 'shopify_connections' && 'error_message' in u.values,
|
||||
)
|
||||
expect(errorUpdate?.values.error_message).toMatch(/1584/)
|
||||
})
|
||||
|
||||
it('counts an unparseable order total as an error without stalling the cursor', async () => {
|
||||
const { client, updates } = makeSupabaseMock()
|
||||
listOrdersPage.mockResolvedValueOnce(
|
||||
page([makeOrder({ totalPriceSet: money('not-a-number') })]),
|
||||
)
|
||||
|
||||
const summary = await syncShopifyOrders(client, makeConnection())
|
||||
|
||||
expect(summary.errors).toBe(1)
|
||||
expect(ingestTransactions).not.toHaveBeenCalled()
|
||||
// Deliberate: a permanently corrupt total must not stall the feed
|
||||
// (page cursor + end-of-run watermark both persist).
|
||||
expect(cursorUpdates(updates)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('advances a watermark on an empty first run so quiet stores rotate in the cron', async () => {
|
||||
const { client, updates } = makeSupabaseMock()
|
||||
|
||||
const summary = await syncShopifyOrders(client, makeConnection())
|
||||
|
||||
expect(summary.fetched).toBe(0)
|
||||
// Without this, an empty store keeps a NULL cursor forever and the cron's
|
||||
// nullsFirst selection re-picks it every night ahead of everyone else.
|
||||
const cursors = cursorUpdates(updates)
|
||||
expect(cursors).toHaveLength(1)
|
||||
const watermarkMs = Date.parse(cursors[0].values.last_order_synced_at as string)
|
||||
expect(Math.abs(watermarkMs - Date.now())).toBeLessThan(60_000)
|
||||
})
|
||||
|
||||
it('does nothing for a connection without credentials or not active', async () => {
|
||||
const { client } = makeSupabaseMock()
|
||||
const summary = await syncShopifyOrders(
|
||||
client,
|
||||
makeConnection({ client_id_encrypted: null }),
|
||||
)
|
||||
expect(summary.fetched).toBe(0)
|
||||
expect(createShopifySession).not.toHaveBeenCalled()
|
||||
|
||||
const revokedSummary = await syncShopifyOrders(
|
||||
client,
|
||||
makeConnection({ status: 'revoked' }),
|
||||
)
|
||||
expect(revokedSummary.fetched).toBe(0)
|
||||
})
|
||||
|
||||
it('flips the connection to revoked when the token exchange rejects the credentials', async () => {
|
||||
const { client, updates } = makeSupabaseMock()
|
||||
createShopifySession.mockRejectedValueOnce(new Error('REVOKED'))
|
||||
|
||||
const summary = await syncShopifyOrders(client, makeConnection())
|
||||
|
||||
expect(summary.revoked).toBe(true)
|
||||
const revokeUpdate = updates.find((u) => u.table === 'shopify_connections')
|
||||
expect(revokeUpdate?.values.status).toBe('revoked')
|
||||
expect(revokeUpdate?.values.client_id_encrypted).toBeNull()
|
||||
})
|
||||
|
||||
it('flips the connection to revoked when the store rejects the token mid-run', async () => {
|
||||
const { client, updates } = makeSupabaseMock()
|
||||
listOrdersPage.mockRejectedValueOnce(new Error('REVOKED'))
|
||||
|
||||
const summary = await syncShopifyOrders(client, makeConnection())
|
||||
|
||||
expect(summary.revoked).toBe(true)
|
||||
expect(updates.find((u) => u.table === 'shopify_connections')?.values.status).toBe(
|
||||
'revoked',
|
||||
)
|
||||
})
|
||||
|
||||
it('stops before fetching when the deadline is already reached', async () => {
|
||||
const { client } = makeSupabaseMock()
|
||||
const summary = await syncShopifyOrders(
|
||||
client,
|
||||
makeConnection(),
|
||||
undefined,
|
||||
Date.now() - 1,
|
||||
)
|
||||
expect(summary.deadlineReached).toBe(true)
|
||||
expect(listOrdersPage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stops between pages on deadline with the processed pages cursored', async () => {
|
||||
const { client, updates } = makeSupabaseMock()
|
||||
const deadlineMs = Date.now() + 150
|
||||
listOrdersPage.mockImplementationOnce(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 200))
|
||||
return page([makeOrder()], true, 'cursor-1')
|
||||
})
|
||||
|
||||
const summary = await syncShopifyOrders(client, makeConnection(), undefined, deadlineMs)
|
||||
|
||||
expect(summary.deadlineReached).toBe(true)
|
||||
// The fetched page was fully processed and cursored; page two never ran.
|
||||
expect(listOrdersPage).toHaveBeenCalledTimes(1)
|
||||
expect(cursorUpdates(updates)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { serverErrorMessage, syncSummary, type ShopifySyncPayload } from '../lib/settings-actions'
|
||||
|
||||
describe('syncSummary', () => {
|
||||
const base = { fetched: 5, refundsFetched: 1, imported: 4, duplicates: 1, errors: 0 }
|
||||
|
||||
it('classifies a revoked run before anything else', () => {
|
||||
expect(syncSummary({ transactions: { ...base, revoked: true } })).toEqual({
|
||||
reason: 'revoked',
|
||||
})
|
||||
})
|
||||
|
||||
it('classifies a truncated run as partial, keeping the error count', () => {
|
||||
expect(
|
||||
syncSummary({ transactions: { ...base, errors: 2, deadlineReached: true } }),
|
||||
).toEqual({ reason: 'partial', values: { fetched: 5, imported: 4, errors: 2 } })
|
||||
})
|
||||
|
||||
it('classifies an empty window as its own outcome', () => {
|
||||
expect(
|
||||
syncSummary({ transactions: { ...base, fetched: 0, imported: 0 } }),
|
||||
).toEqual({ reason: 'empty' })
|
||||
})
|
||||
|
||||
it('reports both halves when rows landed and rows failed', () => {
|
||||
expect(syncSummary({ transactions: { ...base, errors: 2 } })).toEqual({
|
||||
reason: 'errors',
|
||||
values: { fetched: 5, imported: 4, errors: 2 },
|
||||
})
|
||||
})
|
||||
|
||||
it('classifies a clean run as feed', () => {
|
||||
expect(syncSummary({ transactions: base })).toEqual({
|
||||
reason: 'feed',
|
||||
values: { fetched: 5, imported: 4 },
|
||||
})
|
||||
})
|
||||
|
||||
it('classifies an unreadable body as unknown', () => {
|
||||
expect(syncSummary(null)).toEqual({ reason: 'unknown' })
|
||||
expect(syncSummary({} as ShopifySyncPayload)).toEqual({ reason: 'unknown' })
|
||||
expect(syncSummary({ transactions: {} })).toEqual({ reason: 'unknown' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('serverErrorMessage', () => {
|
||||
it('prefers route copy over the generic map', () => {
|
||||
expect(serverErrorMessage({ error: 'Ingen ansluten Shopify-butik.' }, 404, 'sv')).toBe(
|
||||
'Ingen ansluten Shopify-butik.',
|
||||
)
|
||||
})
|
||||
|
||||
it('prefers the English variant when present and the locale is en', () => {
|
||||
expect(
|
||||
serverErrorMessage({ error: 'Fel.', error_en: 'An error.' }, 500, 'en'),
|
||||
).toBe('An error.')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,414 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { ApiRouteDefinition, ExtensionContext } from '@/lib/extensions/types'
|
||||
import { checkRateLimit } from '@/lib/auth/rate-limit-http'
|
||||
import { requireCapability } from '@/lib/entitlements/has-capability'
|
||||
import { CAPABILITY } from '@/lib/entitlements/keys'
|
||||
import { guardSandbox, sandboxBlockedResponse } from '@/lib/sandbox/guard'
|
||||
import { createServiceClientNoCookies } from '@/lib/auth/api-keys'
|
||||
import { isShopifyConfigured, encryptCredential } from './lib/credentials'
|
||||
import { normalizeShopDomain, testConnectionAndFetchShopInfo } from './lib/api-client'
|
||||
import { syncShopifyOrders } from './lib/order-sync'
|
||||
import type { ShopifyConnection, ShopifyStatusResponse } from './types'
|
||||
|
||||
// Per-user limits: connect probes the merchant's store, sync pages its
|
||||
// order history.
|
||||
const RATE_LIMIT_CONNECT = { maxRequests: 10, windowMs: 60_000 }
|
||||
const RATE_LIMIT_DISCONNECT = { maxRequests: 10, windowMs: 60_000 }
|
||||
const RATE_LIMIT_SYNC = { maxRequests: 10, windowMs: 60_000 }
|
||||
|
||||
const NOT_CONFIGURED_MESSAGE =
|
||||
'Shopify-integrationen är inte konfigurerad på den här installationen.'
|
||||
|
||||
/** Columns safe to hand to the browser: never the encrypted credentials. */
|
||||
const STATUS_COLUMNS =
|
||||
'id, status, shop_domain, shop_name, currency, error_message, connected_at, transaction_sync_enabled, last_order_synced_at'
|
||||
|
||||
type AuthedContext = {
|
||||
supabase: ExtensionContext['supabase']
|
||||
userId: string
|
||||
isAnonymous: boolean
|
||||
companyId: string
|
||||
}
|
||||
|
||||
/** Shared auth preamble: cookie user + company context, or an error response. */
|
||||
async function requireUserAndCompany(
|
||||
ctx: ExtensionContext | undefined,
|
||||
): Promise<AuthedContext | NextResponse> {
|
||||
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
if (!ctx?.companyId) {
|
||||
return NextResponse.json({ error: 'Company context required' }, { status: 400 })
|
||||
}
|
||||
return {
|
||||
supabase,
|
||||
userId: user.id,
|
||||
isAnonymous: Boolean(user.is_anonymous),
|
||||
companyId: ctx.companyId,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect guards: sandbox users never reach external stores (same doctrine as
|
||||
* the Stripe/WooCommerce connects), and the feed is a paid capability
|
||||
* (shopify_sync).
|
||||
*/
|
||||
async function guardConnectPreconditions(auth: AuthedContext): Promise<NextResponse | null> {
|
||||
if (auth.isAnonymous) return sandboxBlockedResponse()
|
||||
const sandboxBlocked = await guardSandbox(auth.supabase, auth.companyId)
|
||||
if (sandboxBlocked) return sandboxBlocked
|
||||
return requireCapability(auth.supabase, auth.companyId, CAPABILITY.shopify_sync)
|
||||
}
|
||||
|
||||
export const shopifyApiRoutes: ApiRouteDefinition[] = [
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/status',
|
||||
handler: async (_request: Request, ctx?: ExtensionContext) => {
|
||||
const auth = await requireUserAndCompany(ctx)
|
||||
if (auth instanceof NextResponse) return auth
|
||||
|
||||
// Prefer the active connection; otherwise surface the most recent row
|
||||
// so the panel can show error/revoked states.
|
||||
const { data: rows } = await auth.supabase
|
||||
.from('shopify_connections')
|
||||
.select(STATUS_COLUMNS)
|
||||
.eq('company_id', auth.companyId)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(10)
|
||||
|
||||
const connection = rows?.find((r) => r.status === 'active') ?? rows?.[0] ?? null
|
||||
const payload: ShopifyStatusResponse = {
|
||||
configured: isShopifyConfigured(),
|
||||
connection,
|
||||
}
|
||||
return NextResponse.json(payload)
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/connect',
|
||||
handler: async (request: Request, ctx?: ExtensionContext) => {
|
||||
const log = ctx?.log ?? console
|
||||
const auth = await requireUserAndCompany(ctx)
|
||||
if (auth instanceof NextResponse) return auth
|
||||
|
||||
const blocked = await guardConnectPreconditions(auth)
|
||||
if (blocked) return blocked
|
||||
|
||||
const rl = await checkRateLimit({
|
||||
prefix: 'shopify:connect',
|
||||
identifier: auth.userId,
|
||||
...RATE_LIMIT_CONNECT,
|
||||
})
|
||||
if (!rl.ok) return rl.response!
|
||||
|
||||
if (!isShopifyConfigured()) {
|
||||
return NextResponse.json({ error: NOT_CONFIGURED_MESSAGE }, { status: 503 })
|
||||
}
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as {
|
||||
shop_domain?: unknown
|
||||
client_id?: unknown
|
||||
client_secret?: unknown
|
||||
}
|
||||
const shopDomain =
|
||||
typeof body.shop_domain === 'string' ? normalizeShopDomain(body.shop_domain) : null
|
||||
const clientId = typeof body.client_id === 'string' ? body.client_id.trim() : ''
|
||||
const clientSecret =
|
||||
typeof body.client_secret === 'string' ? body.client_secret.trim() : ''
|
||||
if (!shopDomain) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Ange butikens myshopify.com-adress, t.ex. minbutik.myshopify.com.' },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
if (!clientId || !clientSecret) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Ange både appens klient-ID och klienthemlighet.' },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
const { data: existing } = await auth.supabase
|
||||
.from('shopify_connections')
|
||||
.select('id, status')
|
||||
.eq('company_id', auth.companyId)
|
||||
.eq('status', 'active')
|
||||
if (existing && existing.length > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Företaget har redan en ansluten Shopify-butik. Koppla från den först.' },
|
||||
{ status: 409 },
|
||||
)
|
||||
}
|
||||
|
||||
// Verify before storing: a typo'd secret or missing read_orders scope
|
||||
// must fail here, not at 03:15.
|
||||
let shopInfo
|
||||
try {
|
||||
shopInfo = await testConnectionAndFetchShopInfo({
|
||||
shopDomain,
|
||||
clientId,
|
||||
clientSecret,
|
||||
})
|
||||
} catch (probeError) {
|
||||
log.warn('[shopify] Credential probe failed', {
|
||||
companyId: auth.companyId,
|
||||
message: probeError instanceof Error ? probeError.message : String(probeError),
|
||||
})
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'Kunde inte ansluta till butiken med de angivna uppgifterna. Kontrollera adressen och att appen har behörigheten read_orders.',
|
||||
},
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
const { data: created, error: insertError } = await auth.supabase
|
||||
.from('shopify_connections')
|
||||
.insert({
|
||||
company_id: auth.companyId,
|
||||
user_id: auth.userId,
|
||||
shop_domain: shopDomain,
|
||||
shop_name: shopInfo.name,
|
||||
currency: shopInfo.currency,
|
||||
client_id_encrypted: encryptCredential(clientId),
|
||||
client_secret_encrypted: encryptCredential(clientSecret),
|
||||
status: 'active',
|
||||
connected_at: new Date().toISOString(),
|
||||
transaction_sync_enabled: true,
|
||||
})
|
||||
.select('id, shop_domain')
|
||||
.single()
|
||||
|
||||
if (insertError || !created) {
|
||||
const isConflict = insertError?.code === '23505'
|
||||
log.error('[shopify] Failed to create connection', {
|
||||
message: insertError?.message,
|
||||
code: insertError?.code,
|
||||
companyId: auth.companyId,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: isConflict
|
||||
? 'Butiken är redan ansluten till ett företag.'
|
||||
: 'Kunde inte spara anslutningen. Försök igen.',
|
||||
},
|
||||
{ status: isConflict ? 409 : 500 },
|
||||
)
|
||||
}
|
||||
|
||||
if (ctx?.emit) {
|
||||
try {
|
||||
await ctx.emit({
|
||||
type: 'shopify.connected',
|
||||
payload: {
|
||||
connectionId: created.id,
|
||||
shopDomain: created.shop_domain,
|
||||
userId: auth.userId,
|
||||
companyId: auth.companyId,
|
||||
},
|
||||
})
|
||||
} catch {
|
||||
// Audit event failure must not block the connect itself.
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, connection_id: created.id })
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/sync',
|
||||
handler: async (_request: Request, ctx?: ExtensionContext) => {
|
||||
const log = ctx?.log ?? console
|
||||
const auth = await requireUserAndCompany(ctx)
|
||||
if (auth instanceof NextResponse) return auth
|
||||
|
||||
const capabilityBlocked = await requireCapability(
|
||||
auth.supabase,
|
||||
auth.companyId,
|
||||
CAPABILITY.shopify_sync,
|
||||
)
|
||||
if (capabilityBlocked) return capabilityBlocked
|
||||
|
||||
const rl = await checkRateLimit({
|
||||
prefix: 'shopify:sync',
|
||||
identifier: auth.userId,
|
||||
...RATE_LIMIT_SYNC,
|
||||
})
|
||||
if (!rl.ok) return rl.response!
|
||||
|
||||
// Membership-scoped lookup via the user client; the sync itself runs on
|
||||
// the service client (cursor updates and ingest are service paths). The
|
||||
// manual button ignores transaction_sync_enabled (that flag gates the
|
||||
// nightly cron): pressing it IS the opt-in.
|
||||
const { data: connection } = await auth.supabase
|
||||
.from('shopify_connections')
|
||||
.select('*')
|
||||
.eq('company_id', auth.companyId)
|
||||
.eq('status', 'active')
|
||||
.maybeSingle()
|
||||
|
||||
if (!connection) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Ingen ansluten Shopify-butik.' },
|
||||
{ status: 404 },
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const serviceClient = createServiceClientNoCookies()
|
||||
// Bounded like the cron: without a deadline a huge first sync would be
|
||||
// killed at the dispatcher's maxDuration with no cursor persisted;
|
||||
// with one it stops cleanly, reports a partial sync and resumes where
|
||||
// it stopped on the next press.
|
||||
const summary = await syncShopifyOrders(
|
||||
serviceClient,
|
||||
connection as ShopifyConnection,
|
||||
undefined,
|
||||
Date.now() + 240_000,
|
||||
)
|
||||
return NextResponse.json({ success: true, transactions: summary })
|
||||
} catch (error) {
|
||||
log.error('[shopify] Manual sync failed', {
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
connection_id: connection.id,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'Synkroniseringen misslyckades. Försök igen.' },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/transaction-sync',
|
||||
handler: async (request: Request, ctx?: ExtensionContext) => {
|
||||
const auth = await requireUserAndCompany(ctx)
|
||||
if (auth instanceof NextResponse) return auth
|
||||
|
||||
const capabilityBlocked = await requireCapability(
|
||||
auth.supabase,
|
||||
auth.companyId,
|
||||
CAPABILITY.shopify_sync,
|
||||
)
|
||||
if (capabilityBlocked) return capabilityBlocked
|
||||
|
||||
const rl = await checkRateLimit({
|
||||
prefix: 'shopify:transaction-sync-toggle',
|
||||
identifier: auth.userId,
|
||||
...RATE_LIMIT_SYNC,
|
||||
})
|
||||
if (!rl.ok) return rl.response!
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as { enabled?: unknown }
|
||||
if (typeof body.enabled !== 'boolean') {
|
||||
return NextResponse.json({ error: 'enabled (boolean) krävs.' }, { status: 400 })
|
||||
}
|
||||
|
||||
const { data: updated, error: updateError } = await auth.supabase
|
||||
.from('shopify_connections')
|
||||
.update({ transaction_sync_enabled: body.enabled })
|
||||
.eq('company_id', auth.companyId)
|
||||
.eq('status', 'active')
|
||||
.select('id')
|
||||
|
||||
if (updateError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Kunde inte spara inställningen. Försök igen.' },
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
if (!updated || updated.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Ingen ansluten Shopify-butik.' },
|
||||
{ status: 404 },
|
||||
)
|
||||
}
|
||||
return NextResponse.json({ success: true, enabled: body.enabled })
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'DELETE',
|
||||
path: '/disconnect',
|
||||
handler: async (request: Request, ctx?: ExtensionContext) => {
|
||||
const log = ctx?.log ?? console
|
||||
const auth = await requireUserAndCompany(ctx)
|
||||
if (auth instanceof NextResponse) return auth
|
||||
|
||||
const rl = await checkRateLimit({
|
||||
prefix: 'shopify:disconnect',
|
||||
identifier: auth.userId,
|
||||
...RATE_LIMIT_DISCONNECT,
|
||||
})
|
||||
if (!rl.ok) return rl.response!
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as { connection_id?: string }
|
||||
const base = auth.supabase
|
||||
.from('shopify_connections')
|
||||
.select('id, status, shop_domain')
|
||||
.eq('company_id', auth.companyId)
|
||||
const query = body.connection_id
|
||||
? base.eq('id', body.connection_id).limit(1)
|
||||
: base.neq('status', 'revoked').order('created_at', { ascending: false }).limit(1)
|
||||
const { data: rows, error: findError } = await query
|
||||
const connection = rows?.[0]
|
||||
|
||||
if (findError || !connection) {
|
||||
return NextResponse.json({ error: 'Connection not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// There is no remote revoke API for a merchant-owned custom app: the
|
||||
// app lives in the merchant's Dev Dashboard and only they can delete it
|
||||
// or rotate its secret. We drop our copy of the credentials outright
|
||||
// (nothing reads them after revoke, and a reconnect inserts a fresh
|
||||
// row); the audit row keeps shop_domain and the connect/disconnect
|
||||
// timestamps. The panel tells the user to remove the app in Shopify too.
|
||||
const { error: updateError } = await auth.supabase
|
||||
.from('shopify_connections')
|
||||
.update({
|
||||
status: 'revoked',
|
||||
client_id_encrypted: null,
|
||||
client_secret_encrypted: null,
|
||||
disconnected_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', connection.id)
|
||||
.eq('company_id', auth.companyId)
|
||||
|
||||
if (updateError) {
|
||||
log.error('[shopify] Failed to mark connection revoked', {
|
||||
message: updateError.message,
|
||||
connection_id: connection.id,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'Kunde inte koppla från. Försök igen.' },
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
|
||||
if (ctx?.emit) {
|
||||
try {
|
||||
await ctx.emit({
|
||||
type: 'shopify.disconnected',
|
||||
payload: {
|
||||
connectionId: connection.id,
|
||||
shopDomain: connection.shop_domain ?? null,
|
||||
reason: 'user',
|
||||
userId: auth.userId,
|
||||
companyId: auth.companyId,
|
||||
},
|
||||
})
|
||||
} catch {
|
||||
// Audit event failure must not block the disconnect itself.
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,413 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useLocale, useTranslations } from 'next-intl'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { useFormat } from '@/lib/hooks/use-format'
|
||||
import { failureDescription } from '@/lib/browser/action-failure'
|
||||
import type { ErrorLocale } from '@/lib/errors/get-error-message'
|
||||
import { KeyRound, Loader2, RefreshCw, ShoppingBag, Unlink } from 'lucide-react'
|
||||
import {
|
||||
shopifyRequest,
|
||||
syncSummary,
|
||||
SHOPIFY_CONNECT_TIMEOUT_MS,
|
||||
SHOPIFY_SYNC_TIMEOUT_MS,
|
||||
type ShopifySyncPayload,
|
||||
} from '../lib/settings-actions'
|
||||
import type { ShopifyStatusResponse } from '../types'
|
||||
|
||||
type ConnectionInfo = NonNullable<ShopifyStatusResponse['connection']>
|
||||
|
||||
const STATUS_VARIANT: Record<ConnectionInfo['status'], 'success' | 'secondary' | 'destructive' | 'warning'> = {
|
||||
active: 'success',
|
||||
pending: 'secondary',
|
||||
revoked: 'warning',
|
||||
error: 'destructive',
|
||||
}
|
||||
|
||||
export default function ShopifySettingsPanel() {
|
||||
const t = useTranslations('shopify')
|
||||
const tCommon = useTranslations('common')
|
||||
const locale = useLocale() as ErrorLocale
|
||||
const { toast } = useToast()
|
||||
const { formatDateLong } = useFormat()
|
||||
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadFailed, setLoadFailed] = useState(false)
|
||||
const [configured, setConfigured] = useState(false)
|
||||
const [connection, setConnection] = useState<ConnectionInfo | null>(null)
|
||||
const [shopDomain, setShopDomain] = useState('')
|
||||
const [clientId, setClientId] = useState('')
|
||||
const [clientSecret, setClientSecret] = useState('')
|
||||
const [connecting, setConnecting] = useState(false)
|
||||
const [disconnecting, setDisconnecting] = useState(false)
|
||||
const [confirmDisconnect, setConfirmDisconnect] = useState(false)
|
||||
const [syncing, setSyncing] = useState(false)
|
||||
const [togglingTransactionSync, setTogglingTransactionSync] = useState(false)
|
||||
|
||||
const failureCopy = { timeout: t('action_timeout'), network: t('action_network') }
|
||||
|
||||
const loadStatus = useCallback(async () => {
|
||||
// A failed status read must never render as "not configured" (see the
|
||||
// Stripe panel: that copy sends the user to an administrator for nothing).
|
||||
const result = await shopifyRequest<ShopifyStatusResponse>({
|
||||
url: '/api/extensions/ext/shopify/status',
|
||||
method: 'GET',
|
||||
locale,
|
||||
})
|
||||
setLoading(false)
|
||||
if (!result.ok || !result.data) {
|
||||
setLoadFailed(true)
|
||||
return
|
||||
}
|
||||
setLoadFailed(false)
|
||||
setConfigured(result.data.configured)
|
||||
setConnection(result.data.connection)
|
||||
}, [locale])
|
||||
|
||||
useEffect(() => {
|
||||
void loadStatus()
|
||||
}, [loadStatus])
|
||||
|
||||
function retryLoadStatus() {
|
||||
setLoading(true)
|
||||
void loadStatus()
|
||||
}
|
||||
|
||||
async function handleConnect() {
|
||||
if (connecting) return
|
||||
setConnecting(true)
|
||||
try {
|
||||
const result = await shopifyRequest({
|
||||
url: '/api/extensions/ext/shopify/connect',
|
||||
body: {
|
||||
shop_domain: shopDomain,
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
},
|
||||
locale,
|
||||
timeoutMs: SHOPIFY_CONNECT_TIMEOUT_MS,
|
||||
})
|
||||
if (!result.ok) {
|
||||
toast({
|
||||
title: t('connect_failed_title'),
|
||||
description: failureDescription(result, failureCopy),
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
toast({ title: t('connected_toast_title'), description: t('connected_toast_description') })
|
||||
setClientId('')
|
||||
setClientSecret('')
|
||||
await loadStatus()
|
||||
} finally {
|
||||
setConnecting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSyncNow() {
|
||||
if (syncing) return
|
||||
setSyncing(true)
|
||||
try {
|
||||
const result = await shopifyRequest<ShopifySyncPayload>({
|
||||
url: '/api/extensions/ext/shopify/sync',
|
||||
locale,
|
||||
timeoutMs: SHOPIFY_SYNC_TIMEOUT_MS,
|
||||
})
|
||||
if (!result.ok) {
|
||||
toast({
|
||||
title: t('sync_failed_title'),
|
||||
description: failureDescription(result, failureCopy),
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
const summary = syncSummary(result.data)
|
||||
if (summary.reason === 'revoked') {
|
||||
toast({
|
||||
title: t('sync_failed_title'),
|
||||
description: t('sync_revoked'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
} else if (summary.reason === 'partial') {
|
||||
toast({ title: t('sync_partial_title'), description: t('sync_partial', summary.values) })
|
||||
} else if (summary.reason === 'empty') {
|
||||
toast({ title: t('sync_done_title'), description: t('sync_done_empty') })
|
||||
} else if (summary.reason === 'errors') {
|
||||
toast({ title: t('sync_done_title'), description: t('sync_done_feed_errors', summary.values) })
|
||||
} else if (summary.reason === 'feed') {
|
||||
toast({ title: t('sync_done_title'), description: t('sync_done_feed', summary.values) })
|
||||
} else {
|
||||
toast({ title: t('sync_done_title') })
|
||||
}
|
||||
await loadStatus()
|
||||
} finally {
|
||||
setSyncing(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleTransactionSync(enabled: boolean) {
|
||||
if (togglingTransactionSync) return
|
||||
setTogglingTransactionSync(true)
|
||||
try {
|
||||
const result = await shopifyRequest({
|
||||
url: '/api/extensions/ext/shopify/transaction-sync',
|
||||
body: { enabled },
|
||||
locale,
|
||||
})
|
||||
if (!result.ok) {
|
||||
toast({
|
||||
title: t('transaction_sync_toggle_failed'),
|
||||
description: failureDescription(result, failureCopy),
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
toast({
|
||||
title: enabled
|
||||
? t('transaction_sync_enabled_toast')
|
||||
: t('transaction_sync_disabled_toast'),
|
||||
})
|
||||
await loadStatus()
|
||||
} finally {
|
||||
setTogglingTransactionSync(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDisconnect() {
|
||||
if (!connection || disconnecting) return
|
||||
setDisconnecting(true)
|
||||
try {
|
||||
const result = await shopifyRequest({
|
||||
url: '/api/extensions/ext/shopify/disconnect',
|
||||
method: 'DELETE',
|
||||
body: { connection_id: connection.id },
|
||||
locale,
|
||||
})
|
||||
if (!result.ok) {
|
||||
toast({
|
||||
title: t('disconnect_failed_title'),
|
||||
description: failureDescription(result, failureCopy),
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
// The app still exists in the merchant's Shopify Dev Dashboard; only
|
||||
// they can delete it there, so the toast says so.
|
||||
toast({ title: t('disconnected_toast_title'), description: t('disconnected_toast_description') })
|
||||
setConfirmDisconnect(false)
|
||||
await loadStatus()
|
||||
} finally {
|
||||
setDisconnecting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="space-y-3 p-6">
|
||||
<Skeleton className="h-5 w-48" />
|
||||
<Skeleton className="h-4 w-72" />
|
||||
<Skeleton className="h-10 w-40" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (loadFailed) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t('title')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 pt-0">
|
||||
<p className="text-sm text-destructive">{t('load_failed')}</p>
|
||||
<Button variant="outline" size="sm" onClick={retryLoadStatus}>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
{tCommon('retry')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (!configured) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t('title')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<p className="text-sm text-muted-foreground">{t('not_configured')}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const isActive = connection?.status === 'active'
|
||||
const showConnectForm = !connection || !isActive
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t('title')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6 pt-0">
|
||||
<p className="text-sm text-muted-foreground">{t('description')}</p>
|
||||
|
||||
{connection && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 rounded-lg border border-border p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<ShoppingBag className="h-5 w-5 text-muted-foreground" />
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">
|
||||
{connection.shop_name || connection.shop_domain || t('unnamed_store')}
|
||||
</span>
|
||||
<Badge variant={STATUS_VARIANT[connection.status]}>
|
||||
{t(`status_${connection.status}`)}
|
||||
</Badge>
|
||||
</div>
|
||||
{connection.shop_name && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">{connection.shop_domain}</p>
|
||||
)}
|
||||
{isActive && connection.connected_at && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t('connected_since', { date: formatDateLong(connection.connected_at) })}
|
||||
</p>
|
||||
)}
|
||||
{connection.error_message && (
|
||||
// Shown for active connections too: a sync that cannot run
|
||||
// (e.g. cash-account currency conflict) must not hide
|
||||
// behind a healthy-looking "Ansluten" badge.
|
||||
<p className="mt-1 text-sm text-destructive">{connection.error_message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isActive && (
|
||||
confirmDisconnect ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={handleDisconnect}
|
||||
disabled={disconnecting}
|
||||
>
|
||||
{t('disconnect_confirm')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setConfirmDisconnect(false)}
|
||||
disabled={disconnecting}
|
||||
>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleSyncNow} disabled={syncing}>
|
||||
{syncing ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{syncing ? t('syncing') : t('sync_now')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setConfirmDisconnect(true)}>
|
||||
<Unlink className="mr-2 h-4 w-4" />
|
||||
{t('disconnect')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showConnectForm && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">{t('connect_hint')}</p>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="shopify-shop-domain">{t('shop_domain_label')}</Label>
|
||||
<Input
|
||||
id="shopify-shop-domain"
|
||||
inputMode="url"
|
||||
placeholder="minbutik.myshopify.com"
|
||||
value={shopDomain}
|
||||
onChange={(e) => setShopDomain(e.target.value)}
|
||||
disabled={connecting}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="shopify-client-id">{t('client_id_label')}</Label>
|
||||
<Input
|
||||
id="shopify-client-id"
|
||||
autoComplete="off"
|
||||
value={clientId}
|
||||
onChange={(e) => setClientId(e.target.value)}
|
||||
disabled={connecting}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="shopify-client-secret">{t('client_secret_label')}</Label>
|
||||
<Input
|
||||
id="shopify-client-secret"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={clientSecret}
|
||||
onChange={(e) => setClientSecret(e.target.value)}
|
||||
disabled={connecting}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleConnect}
|
||||
disabled={connecting || !shopDomain || !clientId || !clientSecret}
|
||||
>
|
||||
{connecting ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<KeyRound className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{connecting ? t('connecting') : t('connect')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isActive && connection && (
|
||||
<div className="flex flex-wrap items-start justify-between gap-4 rounded-lg border border-border p-4">
|
||||
<div className="min-w-0 max-w-prose space-y-1">
|
||||
<p className="text-sm font-medium">{t('transaction_sync_title')}</p>
|
||||
<p className="text-sm text-muted-foreground">{t('transaction_sync_description')}</p>
|
||||
{connection.transaction_sync_enabled ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{connection.last_order_synced_at
|
||||
? t('transaction_sync_last_synced', {
|
||||
date: formatDateLong(connection.last_order_synced_at),
|
||||
})
|
||||
: t('transaction_sync_never_synced')}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('transaction_sync_backfill_note')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Switch
|
||||
checked={connection.transaction_sync_enabled}
|
||||
onCheckedChange={handleToggleTransactionSync}
|
||||
disabled={togglingTransactionSync}
|
||||
aria-label={t('transaction_sync_title')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { Extension } from '@/lib/extensions/types'
|
||||
import { shopifyApiRoutes } from './api-routes'
|
||||
|
||||
/**
|
||||
* Shopify extension
|
||||
*
|
||||
* Connects a company's Shopify store via a merchant-created Dev Dashboard
|
||||
* custom app (client credentials grant; the revealable shpat_ token flow was
|
||||
* discontinued 2026-01-01) and imports the store's paid orders and refunds
|
||||
* into the transactions inbox as a bank-style feed on the 1584 cash account.
|
||||
* Feed-only (same doctrine as the Stripe and WooCommerce feeds): nothing is
|
||||
* auto-booked, and Shopify Payments payout/fee reconciliation is out of
|
||||
* scope for now (phase 2).
|
||||
*
|
||||
* Required environment variables:
|
||||
* - SHOPIFY_CREDENTIALS_ENCRYPTION_KEY (at-rest key for client id/secret)
|
||||
*/
|
||||
export const shopifyExtension: Extension = {
|
||||
id: 'shopify',
|
||||
name: 'Shopify',
|
||||
version: '1.0.0',
|
||||
sector: 'general',
|
||||
|
||||
settingsPanel: {
|
||||
label: 'Shopify',
|
||||
path: '/import?mode=shopify',
|
||||
},
|
||||
|
||||
apiRoutes: shopifyApiRoutes,
|
||||
}
|
||||
|
||||
export default shopifyExtension
|
||||
@@ -0,0 +1,349 @@
|
||||
import type { ShopifyOrder, ShopifyShopInfo } from '../types'
|
||||
|
||||
/**
|
||||
* Minimal Shopify GraphQL Admin API client for the order feed.
|
||||
*
|
||||
* Auth is the client credentials grant: the merchant creates a custom app in
|
||||
* their own Shopify Dev Dashboard (the admin-created custom apps with
|
||||
* revealable shpat_ tokens were discontinued 2026-01-01) and pastes the app's
|
||||
* client id/secret; the server exchanges those for a ~24h access token at the
|
||||
* start of every run. REST is legacy since 2024, so everything here is
|
||||
* GraphQL against a pinned API version.
|
||||
*
|
||||
* Rate limiting is cost-based (points/second leaky bucket); a throttled query
|
||||
* comes back as HTTP 200 with a THROTTLED GraphQL error, so both that and
|
||||
* plain 429/5xx get a short backoff before the error is surfaced.
|
||||
*/
|
||||
|
||||
/** Pinned Admin API version; bump quarterly (supported >= 12 months). */
|
||||
export const SHOPIFY_API_VERSION = '2026-07'
|
||||
/** Orders per page; the API caps `first` at 250, 100 keeps query cost low. */
|
||||
export const SHOPIFY_PAGE_SIZE = 100
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 30_000
|
||||
const RETRYABLE_STATUS = new Set([429, 502, 503, 504])
|
||||
const RETRY_DELAYS_MS = [1_000, 3_000]
|
||||
|
||||
export interface ShopifyCredentials {
|
||||
/** Normalized myshopify.com domain. */
|
||||
shopDomain: string
|
||||
clientId: string
|
||||
clientSecret: string
|
||||
}
|
||||
|
||||
/** A run-scoped session: the exchanged token lives ~24h and is never stored. */
|
||||
export interface ShopifySession {
|
||||
shopDomain: string
|
||||
accessToken: string
|
||||
}
|
||||
|
||||
export class ShopifyApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
/** HTTP status, or 0 for network-level / GraphQL-level failures. */
|
||||
readonly status: number,
|
||||
/** GraphQL error code (e.g. ACCESS_DENIED) or OAuth error, if any. */
|
||||
readonly code: string | null = null,
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'ShopifyApiError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an API error means the credentials themselves are dead (app deleted
|
||||
* or secret rotated in the Dev Dashboard), as opposed to a transient failure.
|
||||
* Used to flip a connection to status 'revoked' so the UI offers a reconnect
|
||||
* instead of the cron retrying forever.
|
||||
*/
|
||||
export function isRevokedCredentialsError(error: unknown): boolean {
|
||||
if (!(error instanceof ShopifyApiError)) return false
|
||||
return error.status === 401 || error.status === 403
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize user input to a bare myshopify.com domain. Accepts the domain
|
||||
* with or without scheme/path, the bare store handle, and a pasted admin URL
|
||||
* (admin.shopify.com/store/<handle>). Anything that does not resolve to
|
||||
* <handle>.myshopify.com returns null: the Admin API only lives there, and
|
||||
* refusing arbitrary hosts doubles as the SSRF guard (the server never
|
||||
* fetches a user-controlled hostname).
|
||||
*/
|
||||
export function normalizeShopDomain(input: string): string | null {
|
||||
let value = input.trim().toLowerCase()
|
||||
if (!value) return null
|
||||
const adminMatch =
|
||||
/^(?:https?:\/\/)?admin\.shopify\.com\/store\/([a-z0-9][a-z0-9-]*)(?:[/?#]|$)/.exec(value)
|
||||
if (adminMatch) return `${adminMatch[1]}.myshopify.com`
|
||||
value = value.replace(/^https?:\/\//, '')
|
||||
value = value.split(/[/?#]/)[0]
|
||||
if (!value) return null
|
||||
if (!value.includes('.')) value = `${value}.myshopify.com`
|
||||
return /^[a-z0-9][a-z0-9-]*\.myshopify\.com$/.test(value) ? value : null
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
async function postJson(url: string, body: unknown, headers: Record<string, string>) {
|
||||
return fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...headers },
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange the custom app's client id/secret for an access token
|
||||
* (client credentials grant; expires_in ~86400s). Any 4xx here means the
|
||||
* credentials are unusable (invalid_client, app uninstalled, plan gate), so
|
||||
* it is reported as status 401 and classified as revoked; 429/5xx retry on
|
||||
* the normal backoff schedule.
|
||||
*/
|
||||
export async function exchangeAccessToken(creds: ShopifyCredentials): Promise<string> {
|
||||
let lastError: unknown
|
||||
for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt++) {
|
||||
let response: Response
|
||||
try {
|
||||
response = await postJson(
|
||||
`https://${creds.shopDomain}/admin/oauth/access_token`,
|
||||
{
|
||||
client_id: creds.clientId,
|
||||
client_secret: creds.clientSecret,
|
||||
grant_type: 'client_credentials',
|
||||
},
|
||||
{},
|
||||
)
|
||||
} catch (err) {
|
||||
lastError = new ShopifyApiError(
|
||||
`Shopify token exchange failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
0,
|
||||
)
|
||||
if (attempt < RETRY_DELAYS_MS.length) {
|
||||
await sleep(RETRY_DELAYS_MS[attempt])
|
||||
continue
|
||||
}
|
||||
throw lastError
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
const body = (await response.json().catch(() => null)) as {
|
||||
access_token?: unknown
|
||||
} | null
|
||||
if (typeof body?.access_token === 'string' && body.access_token) {
|
||||
return body.access_token
|
||||
}
|
||||
throw new ShopifyApiError('Shopify token exchange returned no access token', 0)
|
||||
}
|
||||
|
||||
if (RETRYABLE_STATUS.has(response.status) && attempt < RETRY_DELAYS_MS.length) {
|
||||
lastError = new ShopifyApiError(
|
||||
`Shopify token exchange ${response.status}`,
|
||||
response.status,
|
||||
)
|
||||
await sleep(RETRY_DELAYS_MS[attempt])
|
||||
continue
|
||||
}
|
||||
|
||||
const errorBody = (await response.json().catch(() => null)) as {
|
||||
error?: string
|
||||
error_description?: string
|
||||
} | null
|
||||
// Only non-retryable 4xx means the credentials are unusable. A 429 that
|
||||
// survived every retry is still throttling, not revocation: mapping it to
|
||||
// 401 would classify as revoked and delete the stored credentials.
|
||||
const credentialFailure =
|
||||
response.status >= 400 &&
|
||||
response.status < 500 &&
|
||||
!RETRYABLE_STATUS.has(response.status)
|
||||
throw new ShopifyApiError(
|
||||
`Shopify token exchange ${response.status}${
|
||||
errorBody?.error_description ? `: ${errorBody.error_description}` : ''
|
||||
}`,
|
||||
credentialFailure ? 401 : response.status,
|
||||
errorBody?.error ?? null,
|
||||
)
|
||||
}
|
||||
throw lastError instanceof Error
|
||||
? lastError
|
||||
: new ShopifyApiError('Shopify token exchange failed', 0)
|
||||
}
|
||||
|
||||
/** Exchange the credentials for a run-scoped session. */
|
||||
export async function createShopifySession(
|
||||
creds: ShopifyCredentials,
|
||||
): Promise<ShopifySession> {
|
||||
return { shopDomain: creds.shopDomain, accessToken: await exchangeAccessToken(creds) }
|
||||
}
|
||||
|
||||
interface GraphQLErrorShape {
|
||||
message?: string
|
||||
extensions?: { code?: string }
|
||||
}
|
||||
|
||||
/**
|
||||
* POST one GraphQL query. Retries THROTTLED (HTTP 200 + GraphQL error code),
|
||||
* 429/5xx and network errors with a short backoff; 401/403 (token expired
|
||||
* mid-run, app revoked) and ACCESS_DENIED (scope missing) throw a
|
||||
* ShopifyApiError that classifies as revoked.
|
||||
*/
|
||||
export async function shopifyGraphQL<T>(
|
||||
session: ShopifySession,
|
||||
query: string,
|
||||
variables: Record<string, unknown> = {},
|
||||
): Promise<T> {
|
||||
const url = `https://${session.shopDomain}/admin/api/${SHOPIFY_API_VERSION}/graphql.json`
|
||||
let lastError: unknown
|
||||
for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt++) {
|
||||
let response: Response
|
||||
try {
|
||||
response = await postJson(url, { query, variables }, {
|
||||
'X-Shopify-Access-Token': session.accessToken,
|
||||
})
|
||||
} catch (err) {
|
||||
lastError = new ShopifyApiError(
|
||||
`Shopify request failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
0,
|
||||
)
|
||||
if (attempt < RETRY_DELAYS_MS.length) {
|
||||
await sleep(RETRY_DELAYS_MS[attempt])
|
||||
continue
|
||||
}
|
||||
throw lastError
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (RETRYABLE_STATUS.has(response.status) && attempt < RETRY_DELAYS_MS.length) {
|
||||
lastError = new ShopifyApiError(`Shopify API ${response.status}`, response.status)
|
||||
await sleep(RETRY_DELAYS_MS[attempt])
|
||||
continue
|
||||
}
|
||||
throw new ShopifyApiError(`Shopify API ${response.status}`, response.status)
|
||||
}
|
||||
|
||||
const body = (await response.json().catch(() => null)) as {
|
||||
data?: T
|
||||
errors?: GraphQLErrorShape[]
|
||||
} | null
|
||||
|
||||
if (body?.errors && body.errors.length > 0) {
|
||||
const throttled = body.errors.some((e) => e.extensions?.code === 'THROTTLED')
|
||||
if (throttled && attempt < RETRY_DELAYS_MS.length) {
|
||||
lastError = new ShopifyApiError('Shopify API throttled', 0, 'THROTTLED')
|
||||
await sleep(RETRY_DELAYS_MS[attempt])
|
||||
continue
|
||||
}
|
||||
const accessDenied = body.errors.some((e) => e.extensions?.code === 'ACCESS_DENIED')
|
||||
const message = body.errors[0]?.message ?? 'unknown GraphQL error'
|
||||
throw new ShopifyApiError(
|
||||
`Shopify GraphQL error: ${message}`,
|
||||
accessDenied ? 403 : 0,
|
||||
body.errors[0]?.extensions?.code ?? null,
|
||||
)
|
||||
}
|
||||
|
||||
if (!body?.data) {
|
||||
throw new ShopifyApiError('Shopify API returned no data', 0)
|
||||
}
|
||||
return body.data
|
||||
}
|
||||
throw lastError instanceof Error
|
||||
? lastError
|
||||
: new ShopifyApiError('Shopify request failed', 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Order fields for the feed. Deliberately NO customer/PII fields (customer,
|
||||
* email, addresses): they are gated behind Shopify's protected customer data
|
||||
* program, and a bookkeeping feed does not need them; the verifikat reference
|
||||
* is the order name/id. Refunds come inline (plain list, not a connection),
|
||||
* so no per-order follow-up requests are needed.
|
||||
*/
|
||||
const ORDERS_QUERY = `
|
||||
query OrdersFeed($first: Int!, $after: String, $query: String) {
|
||||
orders(first: $first, after: $after, query: $query, sortKey: UPDATED_AT) {
|
||||
pageInfo { hasNextPage endCursor }
|
||||
nodes {
|
||||
legacyResourceId
|
||||
name
|
||||
test
|
||||
processedAt
|
||||
updatedAt
|
||||
displayFinancialStatus
|
||||
paymentGatewayNames
|
||||
totalPriceSet { shopMoney { amount currencyCode } }
|
||||
refunds {
|
||||
legacyResourceId
|
||||
createdAt
|
||||
totalRefundedSet { shopMoney { amount currencyCode } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
export interface OrdersPage {
|
||||
orders: ShopifyOrder[]
|
||||
hasNextPage: boolean
|
||||
endCursor: string | null
|
||||
}
|
||||
|
||||
export interface ListOrdersOptions {
|
||||
/** ISO timestamp; orders with updated_at >= this are listed. */
|
||||
updatedAtMin: string
|
||||
/** Relay cursor from the previous page's endCursor, or null for page one. */
|
||||
after: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* One page of orders updated on/after the window start, oldest-updated first
|
||||
* so the caller's cursor advances chronologically. Relay cursor pagination is
|
||||
* stable across same-second ties, so no offset fallback is needed (unlike the
|
||||
* WooCommerce client).
|
||||
*/
|
||||
export async function listOrdersPage(
|
||||
session: ShopifySession,
|
||||
options: ListOrdersOptions,
|
||||
): Promise<OrdersPage> {
|
||||
const data = await shopifyGraphQL<{
|
||||
orders: {
|
||||
pageInfo: { hasNextPage: boolean; endCursor: string | null }
|
||||
nodes: ShopifyOrder[]
|
||||
}
|
||||
}>(session, ORDERS_QUERY, {
|
||||
first: SHOPIFY_PAGE_SIZE,
|
||||
after: options.after,
|
||||
query: `updated_at:>='${options.updatedAtMin}'`,
|
||||
})
|
||||
return {
|
||||
orders: data.orders.nodes,
|
||||
hasNextPage: data.orders.pageInfo.hasNextPage,
|
||||
endCursor: data.orders.pageInfo.endCursor,
|
||||
}
|
||||
}
|
||||
|
||||
const PROBE_QUERY = `
|
||||
query ConnectProbe {
|
||||
shop { name currencyCode }
|
||||
orders(first: 1) { nodes { legacyResourceId } }
|
||||
}`
|
||||
|
||||
/**
|
||||
* Verify credentials and read shop metadata. The one-order probe inside the
|
||||
* query is the authoritative check: it exercises the read_orders scope the
|
||||
* feed needs, so an app created without that scope fails here (ACCESS_DENIED)
|
||||
* instead of at 03:15.
|
||||
*/
|
||||
export async function testConnectionAndFetchShopInfo(
|
||||
creds: ShopifyCredentials,
|
||||
): Promise<ShopifyShopInfo> {
|
||||
const session = await createShopifySession(creds)
|
||||
const data = await shopifyGraphQL<{
|
||||
shop: { name: string | null; currencyCode: string | null }
|
||||
}>(session, PROBE_QUERY)
|
||||
return {
|
||||
name: data.shop?.name ?? null,
|
||||
currency: data.shop?.currencyCode ? data.shop.currencyCode.toUpperCase() : null,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import crypto from 'crypto'
|
||||
|
||||
/**
|
||||
* At-rest encryption for Shopify custom-app client id/secret.
|
||||
*
|
||||
* AES-256-GCM with a dedicated env key, mirroring the WooCommerce credential
|
||||
* store (extensions/general/woocommerce/lib/credentials.ts) and the
|
||||
* Skatteverket token store: 12-byte IV, 16-byte auth tag, layout
|
||||
* iv|tag|ciphertext, base64url encoded. The key is deployment-wide (not
|
||||
* per-tenant); what makes rows useless off-server is that
|
||||
* SHOPIFY_CREDENTIALS_ENCRYPTION_KEY never leaves the environment.
|
||||
*/
|
||||
|
||||
const ALGORITHM = 'aes-256-gcm'
|
||||
|
||||
/** Whether the integration is configured on this deployment. */
|
||||
export function isShopifyConfigured(): boolean {
|
||||
return Boolean(process.env.SHOPIFY_CREDENTIALS_ENCRYPTION_KEY)
|
||||
}
|
||||
|
||||
function getEncryptionKey(): Buffer {
|
||||
const key = process.env.SHOPIFY_CREDENTIALS_ENCRYPTION_KEY
|
||||
if (!key) throw new Error('SHOPIFY_CREDENTIALS_ENCRYPTION_KEY is required')
|
||||
return crypto.createHash('sha256').update(key).digest()
|
||||
}
|
||||
|
||||
export function encryptCredential(plaintext: string): string {
|
||||
const key = getEncryptionKey()
|
||||
const iv = crypto.randomBytes(12)
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, key, iv)
|
||||
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
|
||||
const tag = cipher.getAuthTag()
|
||||
return Buffer.concat([iv, tag, encrypted]).toString('base64url')
|
||||
}
|
||||
|
||||
export function decryptCredential(ciphertext: string): string {
|
||||
const key = getEncryptionKey()
|
||||
const combined = Buffer.from(ciphertext, 'base64url')
|
||||
const iv = combined.subarray(0, 12)
|
||||
const tag = combined.subarray(12, 28)
|
||||
const encrypted = combined.subarray(28)
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv)
|
||||
decipher.setAuthTag(tag)
|
||||
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8')
|
||||
}
|
||||
|
||||
/** Decrypted API credentials for an active connection. */
|
||||
export function credentialsOf(connection: {
|
||||
shop_domain: string
|
||||
client_id_encrypted: string | null
|
||||
client_secret_encrypted: string | null
|
||||
}): { shopDomain: string; clientId: string; clientSecret: string } {
|
||||
if (!connection.client_id_encrypted || !connection.client_secret_encrypted) {
|
||||
throw new Error('Connection has no stored credentials')
|
||||
}
|
||||
return {
|
||||
shopDomain: connection.shop_domain,
|
||||
clientId: decryptCredential(connection.client_id_encrypted),
|
||||
clientSecret: decryptCredential(connection.client_secret_encrypted),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,564 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { ingestTransactions } from '@/lib/transactions/ingest'
|
||||
import { ensureManualCashAccount } from '@/lib/cash-accounts/service'
|
||||
import { syncMappedAccounts } from '@/lib/import/account-sync'
|
||||
import { createLogger, type Logger } from '@/lib/logger'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import type { RawTransaction } from '@/types'
|
||||
import {
|
||||
createShopifySession,
|
||||
isRevokedCredentialsError,
|
||||
listOrdersPage,
|
||||
} from './api-client'
|
||||
import { credentialsOf } from './credentials'
|
||||
import type { ShopifyConnection, ShopifyOrder, ShopifyRefund } from '../types'
|
||||
|
||||
const defaultLog = createLogger('shopify/order-sync')
|
||||
|
||||
/**
|
||||
* Shopify order sync: the store's paid orders and refunds treated as a
|
||||
* bank-style feed.
|
||||
*
|
||||
* The store becomes a cash account on ledger 1584 (Fordringar Shopify
|
||||
* Payments in the 158x sub-account convention: money the payment gateways owe
|
||||
* the merchant), and orders land in the transactions inbox exactly like PSD2
|
||||
* bank rows: deduped on external_id, bound to the cash account so booking
|
||||
* settles against 1584, and categorized/booked by the user through the normal
|
||||
* flows. Nothing here auto-books (feed-only doctrine, same as the Stripe and
|
||||
* WooCommerce feeds). 1680 and 1686 are owned by those feeds, and
|
||||
* cash_accounts enforces one account per ledger per company.
|
||||
*
|
||||
* Row model: a paid order produces one positive row for its gross total; each
|
||||
* refund produces one negative row. Payment-processor fees never appear in
|
||||
* this feed: order-level fee data only exists for Shopify Payments and payout
|
||||
* reconciliation is a separate concern (phase 2); external gateways (Klarna,
|
||||
* Stripe) report no fees through Shopify at all. The gateway names ride along
|
||||
* as the row reference for later gateway-side reconciliation.
|
||||
*
|
||||
* Pagination: one fixed updated_at window per run, walked with Relay cursors
|
||||
* (sortKey UPDATED_AT ascending). Cursors are stable across same-second ties,
|
||||
* so there is no offset fallback (unlike the WooCommerce sync). The persisted
|
||||
* cursor is shopify_connections.last_order_synced_at: per page the max
|
||||
* updatedAt processed, and after a fully-listed window the run's start time
|
||||
* (a scanned-through watermark, so quiet and empty-first-run stores still
|
||||
* rotate to the back of the cron's oldest-first selection). Re-polled with a
|
||||
* 24h overlap; (company_id, external_id) dedup makes overlaps no-ops. It
|
||||
* never advances past failed work: a page with ingest errors caps the
|
||||
* persisted cursor just below the page's first updatedAt, so the next run
|
||||
* re-lists exactly the orders whose rows are incomplete. First run fetches
|
||||
* BACKFILL_DAYS back.
|
||||
*
|
||||
* Lock-date guard: the window selects on updatedAt, but rows are dated by
|
||||
* processedAt / refund createdAt, which can be arbitrarily older (a refund
|
||||
* bumps updatedAt long after payment). Rows dated on or before
|
||||
* company_settings.bookkeeping_locked_through are therefore dropped at map
|
||||
* time on EVERY run: the enforce_company_lock_date trigger makes them
|
||||
* permanently unbookable, and feed rows are undeletable by design, so
|
||||
* importing them would create permanent inbox noise. Dropped rows are counted
|
||||
* in skippedLocked and logged.
|
||||
*/
|
||||
|
||||
/** BAS ledger account for the Shopify store cash account. */
|
||||
export const SHOPIFY_LEDGER_ACCOUNT = '1584'
|
||||
/** 158x sub-account name (e-handel convention); used for the chart account. */
|
||||
const SHOPIFY_LEDGER_ACCOUNT_NAME = 'Fordringar Shopify Payments'
|
||||
/** transactions.import_source for Shopify feed rows. */
|
||||
export const SHOPIFY_IMPORT_SOURCE = 'shopify'
|
||||
/** First-run backfill window (matches the WooCommerce/Enable Banking convention). */
|
||||
export const BACKFILL_DAYS = 90
|
||||
/** Cursor re-poll overlap; external_id dedup makes duplicates no-ops. */
|
||||
const CURSOR_OVERLAP_MS = 24 * 60 * 60 * 1000
|
||||
/**
|
||||
* Safety cap on orders per run (matches the Stripe/WooCommerce feeds). The
|
||||
* real bound is the caller's deadline; hitting this cap is logged loudly
|
||||
* because a silent cap reads as "covered everything" when it did not. The
|
||||
* cursor resumes where a truncated run stopped.
|
||||
*/
|
||||
const MAX_ORDERS_PER_RUN = 10_000
|
||||
|
||||
/**
|
||||
* ⚠️ STORED-KEY FORMATS. These are persisted to transactions.external_id and
|
||||
* dedup compares stored ids byte-for-byte, exactly like the Stripe, Enable
|
||||
* Banking and WooCommerce schemes. Changing a template silently orphans every
|
||||
* prior row and re-imports the whole feed on the next sync. Locked by the
|
||||
* frozen-format test in order-sync.test.ts; any change MUST ship a
|
||||
* coordinated backfill.
|
||||
*
|
||||
* The scope is the store's normalized myshopify.com domain, NOT the
|
||||
* connection id, so a disconnect/reconnect of the same store keeps every
|
||||
* previously imported row deduped. The ids are Shopify's numeric
|
||||
* legacyResourceIds, matching the shopify_order_{id} convention the MCP
|
||||
* agent path already uses (different prefix, so the two paths never collide
|
||||
* on the same rows by accident).
|
||||
*/
|
||||
export function shopifyShopScope(shopDomain: string): string {
|
||||
return shopDomain
|
||||
}
|
||||
|
||||
export function shopifyOrderExternalId(shopScope: string, orderId: string): string {
|
||||
return `shopify_${shopScope}_order_${orderId}`
|
||||
}
|
||||
|
||||
export function shopifyRefundExternalId(shopScope: string, refundId: string): string {
|
||||
return `shopify_${shopScope}_refund_${refundId}`
|
||||
}
|
||||
|
||||
export interface ShopifySyncSummary {
|
||||
/** Orders listed from the store (all statuses in the window). */
|
||||
fetched: number
|
||||
/** Refund objects seen on qualifying orders in the window. */
|
||||
refundsFetched: number
|
||||
/** New inbox rows inserted. */
|
||||
imported: number
|
||||
/** Rows skipped by external_id / content dedup. */
|
||||
duplicates: number
|
||||
/** Rows dropped because they are dated on/before the bookkeeping lock. */
|
||||
skippedLocked: number
|
||||
errors: number
|
||||
/** Set when the caller's time budget ran out before all pages processed. */
|
||||
deadlineReached?: boolean
|
||||
/** Set when the store reported the credentials revoked (401/403). */
|
||||
revoked?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Money fields arrive as decimal strings in major units for every currency
|
||||
* (GraphQL MoneyV2; zero-decimal currencies like JPY included, so never
|
||||
* divide by 100). Unparseable input returns null so callers can tell a
|
||||
* corrupt total (counted + logged) from a legitimate zero (silently skipped).
|
||||
*/
|
||||
function parseAmount(value: string): number | null {
|
||||
const parsed = Number.parseFloat(value)
|
||||
return Number.isFinite(parsed) ? roundOre(parsed) : null
|
||||
}
|
||||
|
||||
/** Whether a qualifying order's total cannot be read as money. */
|
||||
export function orderAmountUnparseable(order: Pick<ShopifyOrder, 'totalPriceSet'>): boolean {
|
||||
return parseAmount(order.totalPriceSet.shopMoney.amount) === null
|
||||
}
|
||||
|
||||
/** Date part of an ISO timestamp. */
|
||||
function isoDateOf(timestamp: string): string {
|
||||
return timestamp.split('T')[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Financial statuses that mean the order has been paid (possibly later
|
||||
* refunded). AUTHORIZED/PENDING/PARTIALLY_PAID orders carry no settled
|
||||
* revenue yet and EXPIRED/VOIDED never will; they re-surface via updatedAt
|
||||
* once payment captures. REFUNDED stays IN: a fully refunded order was still
|
||||
* paid, and its refunds land as separate negative rows so the pair nets to
|
||||
* zero instead of the gross silently disappearing.
|
||||
*/
|
||||
const PAID_STATUSES = new Set(['PAID', 'PARTIALLY_REFUNDED', 'REFUNDED'])
|
||||
|
||||
/**
|
||||
* Whether an order belongs in the feed: it must have been paid and not be a
|
||||
* test-gateway order (dev stores, Bogus Gateway: never real revenue).
|
||||
*/
|
||||
export function orderQualifies(
|
||||
order: Pick<ShopifyOrder, 'test' | 'displayFinancialStatus'>,
|
||||
): boolean {
|
||||
return !order.test && PAID_STATUSES.has(order.displayFinancialStatus ?? '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a paid order to its gross feed row. Dates use processedAt (when the
|
||||
* money event happened), not createdAt: booked entries, invoice matching, and
|
||||
* month boundaries all want the payment date. Descriptions are deterministic
|
||||
* from immutable data (order names never change) because the content-dedup
|
||||
* bridge keys off them.
|
||||
*/
|
||||
export function mapOrder(shopScope: string, order: ShopifyOrder): RawTransaction[] {
|
||||
if (!orderQualifies(order)) return []
|
||||
const amount = parseAmount(order.totalPriceSet.shopMoney.amount)
|
||||
if (amount === null || amount === 0) return []
|
||||
return [
|
||||
{
|
||||
date: isoDateOf(order.processedAt),
|
||||
description: `Shopify-order ${order.name}`,
|
||||
amount,
|
||||
currency: order.totalPriceSet.shopMoney.currencyCode.toUpperCase(),
|
||||
external_id: shopifyOrderExternalId(shopScope, order.legacyResourceId),
|
||||
import_source: SHOPIFY_IMPORT_SOURCE,
|
||||
reference: order.paymentGatewayNames.join(', ') || null,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/** Map one refund of a paid order to its negative feed row. */
|
||||
export function mapRefund(
|
||||
shopScope: string,
|
||||
order: Pick<ShopifyOrder, 'name'>,
|
||||
refund: ShopifyRefund,
|
||||
): RawTransaction[] {
|
||||
const amount = parseAmount(refund.totalRefundedSet.shopMoney.amount)
|
||||
if (amount === null || amount === 0) return []
|
||||
return [
|
||||
{
|
||||
date: isoDateOf(refund.createdAt),
|
||||
description: `Shopify-återbetalning order ${order.name}`,
|
||||
amount: -amount,
|
||||
currency: refund.totalRefundedSet.shopMoney.currencyCode.toUpperCase(),
|
||||
external_id: shopifyRefundExternalId(shopScope, refund.legacyResourceId),
|
||||
import_source: SHOPIFY_IMPORT_SOURCE,
|
||||
reference: null,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/** Company lock date (YYYY-MM-DD) or null; read once per run. */
|
||||
async function fetchLockThrough(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
): Promise<string | null> {
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('bookkeeping_locked_through')
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
return (
|
||||
(settings as { bookkeeping_locked_through?: string | null } | null)
|
||||
?.bookkeeping_locked_through ?? null
|
||||
)
|
||||
}
|
||||
|
||||
/** Whether a feed-row date is on/before the lock date (=> never bookable). */
|
||||
export function rowBehindLock(rowDate: string, lockThrough: string | null): boolean {
|
||||
return lockThrough !== null && rowDate <= lockThrough
|
||||
}
|
||||
|
||||
/**
|
||||
* Window start (ISO, UTC) for the updated_at filter. With a cursor: cursor
|
||||
* minus the 24h overlap. First run: BACKFILL_DAYS back. (The lock date does
|
||||
* not floor the window: it selects on updatedAt while rows are dated by
|
||||
* processedAt, so the real guard is rowBehindLock at map time, every run.)
|
||||
*/
|
||||
function resolveWindowStartIso(connection: ShopifyConnection): string {
|
||||
if (connection.last_order_synced_at) {
|
||||
const cursorMs = Date.parse(connection.last_order_synced_at)
|
||||
return new Date(Math.max(0, cursorMs - CURSOR_OVERLAP_MS)).toISOString()
|
||||
}
|
||||
return new Date(Date.now() - BACKFILL_DAYS * 86_400_000).toISOString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Make sure the store cash account exists (ledger 1584, source manual so a
|
||||
* later remap/promotion follows the normal cash-account rules) and, on the
|
||||
* first run, that 1584 exists in the chart of accounts: the booking dialog
|
||||
* and AccountPicker only list chart accounts.
|
||||
*
|
||||
* Currency comes from the shop settings read at connect time, falling back to
|
||||
* the first fetched order's real currency (guessing SEK for an EUR store
|
||||
* would poison the account). A conflict with an existing 1584 cash account
|
||||
* throws; the caller surfaces that on the connection so the panel shows why
|
||||
* nothing syncs.
|
||||
*/
|
||||
async function ensureStoreAccount(
|
||||
supabase: SupabaseClient,
|
||||
connection: ShopifyConnection,
|
||||
fallbackCurrency: string | undefined,
|
||||
firstRun: boolean,
|
||||
log: Logger,
|
||||
): Promise<void> {
|
||||
const currency =
|
||||
connection.currency?.toUpperCase() || fallbackCurrency?.toUpperCase() || 'SEK'
|
||||
try {
|
||||
await ensureManualCashAccount(
|
||||
supabase,
|
||||
connection.company_id,
|
||||
SHOPIFY_LEDGER_ACCOUNT,
|
||||
currency,
|
||||
'Shopify-saldo',
|
||||
)
|
||||
} catch (accountError) {
|
||||
// Typically a currency conflict with an existing 1584 cash account. Made
|
||||
// visible on the connection: without this the panel shows a healthy
|
||||
// "Ansluten" store that silently never syncs.
|
||||
await supabase
|
||||
.from('shopify_connections')
|
||||
.update({
|
||||
error_message:
|
||||
'Kassakontot för butiken (1584) kunde inte skapas. Kontrollera att befintligt konto 1584 har samma valuta som butiken.',
|
||||
})
|
||||
.eq('id', connection.id)
|
||||
throw accountError
|
||||
}
|
||||
if (firstRun) {
|
||||
const sync = await syncMappedAccounts(
|
||||
supabase,
|
||||
connection.company_id,
|
||||
connection.user_id,
|
||||
[
|
||||
{
|
||||
sourceAccount: SHOPIFY_LEDGER_ACCOUNT,
|
||||
sourceName: SHOPIFY_LEDGER_ACCOUNT_NAME,
|
||||
targetAccount: SHOPIFY_LEDGER_ACCOUNT,
|
||||
targetName: SHOPIFY_LEDGER_ACCOUNT_NAME,
|
||||
confidence: 1,
|
||||
matchType: 'exact',
|
||||
isOverride: false,
|
||||
},
|
||||
],
|
||||
false,
|
||||
)
|
||||
if (sync.error) {
|
||||
// Rows still import and bind to the cash account; only the chart
|
||||
// listing is affected (the account can be added manually), so this is
|
||||
// deliberately non-fatal.
|
||||
log.warn('chart sync for 1584 failed', {
|
||||
companyId: connection.company_id,
|
||||
error: sync.error,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Rows for one page of orders: gross rows plus inline refund rows. */
|
||||
function buildPageRows(
|
||||
shopScope: string,
|
||||
orders: ShopifyOrder[],
|
||||
lockThrough: string | null,
|
||||
summary: ShopifySyncSummary,
|
||||
log: Logger,
|
||||
): RawTransaction[] {
|
||||
const rows: RawTransaction[] = []
|
||||
|
||||
const push = (mapped: RawTransaction[]) => {
|
||||
for (const row of mapped) {
|
||||
if (rowBehindLock(row.date, lockThrough)) {
|
||||
summary.skippedLocked += 1
|
||||
continue
|
||||
}
|
||||
rows.push(row)
|
||||
}
|
||||
}
|
||||
|
||||
for (const order of orders) {
|
||||
// A corrupt total is counted and logged, never silently identical to a
|
||||
// zero-total order. Deliberately NOT held via the cursor: a permanently
|
||||
// corrupt total would stall the whole feed forever, where a skipped row
|
||||
// plus a loud error can be followed up.
|
||||
if (orderQualifies(order) && orderAmountUnparseable(order)) {
|
||||
summary.errors += 1
|
||||
log.warn('unparseable order total; row skipped', {
|
||||
orderId: order.legacyResourceId,
|
||||
total: order.totalPriceSet.shopMoney.amount,
|
||||
})
|
||||
}
|
||||
push(mapOrder(shopScope, order))
|
||||
// Refunds only exist in the feed for qualifying (paid) orders: a refund
|
||||
// row without its gross counterpart would be an unexplainable negative in
|
||||
// the inbox. They come inline on the order (no follow-up request).
|
||||
if (!orderQualifies(order)) continue
|
||||
for (const refund of order.refunds) {
|
||||
summary.refundsFetched += 1
|
||||
if (parseAmount(refund.totalRefundedSet.shopMoney.amount) === null) {
|
||||
summary.errors += 1
|
||||
log.warn('unparseable refund amount; row skipped', {
|
||||
orderId: order.legacyResourceId,
|
||||
refundId: refund.legacyResourceId,
|
||||
amount: refund.totalRefundedSet.shopMoney.amount,
|
||||
})
|
||||
}
|
||||
push(mapRefund(shopScope, order, refund))
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
export async function syncShopifyOrders(
|
||||
supabase: SupabaseClient,
|
||||
connection: ShopifyConnection,
|
||||
log: Logger = defaultLog,
|
||||
/**
|
||||
* Absolute deadline (epoch ms) from the caller's time budget. Enforced
|
||||
* between pages: the cursor advances only over fully-processed pages, so
|
||||
* the next run resumes exactly where this one stopped.
|
||||
*/
|
||||
deadlineMs?: number,
|
||||
): Promise<ShopifySyncSummary> {
|
||||
const summary: ShopifySyncSummary = {
|
||||
fetched: 0,
|
||||
refundsFetched: 0,
|
||||
imported: 0,
|
||||
duplicates: 0,
|
||||
skippedLocked: 0,
|
||||
errors: 0,
|
||||
}
|
||||
if (
|
||||
connection.status !== 'active' ||
|
||||
!connection.client_id_encrypted ||
|
||||
!connection.client_secret_encrypted
|
||||
) {
|
||||
return summary
|
||||
}
|
||||
|
||||
const shopScope = shopifyShopScope(connection.shop_domain)
|
||||
const firstRun = !connection.last_order_synced_at
|
||||
const lockThrough = await fetchLockThrough(supabase, connection.company_id)
|
||||
|
||||
const runStartMs = Date.now()
|
||||
const updatedAtMin = resolveWindowStartIso(connection)
|
||||
let after: string | null = null
|
||||
let prevCursorMs = connection.last_order_synced_at
|
||||
? Date.parse(connection.last_order_synced_at)
|
||||
: 0
|
||||
// Earliest incomplete work this run; the persisted cursor never passes it.
|
||||
let failureFloorMs = Number.POSITIVE_INFINITY
|
||||
// True once the whole window was listed to its end (empty page or last page).
|
||||
let windowExhausted = false
|
||||
let accountEnsured = false
|
||||
|
||||
try {
|
||||
// Token exchange happens up front (the token lives ~24h, far longer than
|
||||
// any run); a dead client secret surfaces here as a revoked-classified
|
||||
// error before any paging starts.
|
||||
const session = await createShopifySession(credentialsOf(connection))
|
||||
|
||||
for (;;) {
|
||||
if (deadlineMs !== undefined && Date.now() >= deadlineMs) {
|
||||
summary.deadlineReached = true
|
||||
log.info('time budget exhausted; stopping order sync', {
|
||||
connectionId: connection.id,
|
||||
processed: summary.imported + summary.duplicates,
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
const page = await listOrdersPage(session, { updatedAtMin, after })
|
||||
if (page.orders.length === 0) {
|
||||
windowExhausted = true
|
||||
break
|
||||
}
|
||||
summary.fetched += page.orders.length
|
||||
|
||||
// Deferred until the window is known non-empty so a quiet store costs
|
||||
// one API call and zero DB writes; also gives us a real order currency
|
||||
// as the fallback when the shop currency was unreadable at connect.
|
||||
if (!accountEnsured) {
|
||||
await ensureStoreAccount(
|
||||
supabase,
|
||||
connection,
|
||||
page.orders[0].totalPriceSet.shopMoney.currencyCode,
|
||||
firstRun,
|
||||
log,
|
||||
)
|
||||
accountEnsured = true
|
||||
}
|
||||
|
||||
const rows = buildPageRows(shopScope, page.orders, lockThrough, summary, log)
|
||||
|
||||
const firstMs = Date.parse(page.orders[0].updatedAt)
|
||||
const lastMs = Date.parse(page.orders[page.orders.length - 1].updatedAt)
|
||||
|
||||
if (rows.length > 0) {
|
||||
// Auto-categorization is skipped on purpose: booking Shopify money is
|
||||
// a human decision in the inbox (feed-only doctrine, same as the
|
||||
// Stripe and WooCommerce feeds). Invoice matching still runs
|
||||
// (suggestions only), and FX enrichment covers non-SEK stores.
|
||||
const result = await ingestTransactions(
|
||||
supabase,
|
||||
connection.company_id,
|
||||
connection.user_id,
|
||||
rows,
|
||||
{ settlementAccount: SHOPIFY_LEDGER_ACCOUNT, skipAutoCategorization: true },
|
||||
)
|
||||
summary.imported += result.imported
|
||||
summary.duplicates += result.duplicates
|
||||
summary.errors += result.errors
|
||||
if (result.errors > 0) {
|
||||
// Failed inserts are dropped inside ingest; hold the cursor below
|
||||
// this page so the next run re-lists and retries it rather than
|
||||
// turning a transient DB error into permanently missing rows.
|
||||
failureFloorMs = Math.min(failureFloorMs, firstMs - 1000)
|
||||
}
|
||||
}
|
||||
|
||||
// Persist the cursor after each page: monotonic (never regresses below
|
||||
// the pre-run cursor) and capped by the failure floor. error_message is
|
||||
// cleared on progress so a resolved incident stops showing in the panel.
|
||||
const candidateMs = Math.min(lastMs, failureFloorMs)
|
||||
if (candidateMs > prevCursorMs) {
|
||||
const cursorIso = new Date(candidateMs).toISOString()
|
||||
await supabase
|
||||
.from('shopify_connections')
|
||||
.update({ last_order_synced_at: cursorIso, error_message: null })
|
||||
.eq('id', connection.id)
|
||||
connection.last_order_synced_at = cursorIso
|
||||
prevCursorMs = candidateMs
|
||||
}
|
||||
|
||||
if (!page.hasNextPage) {
|
||||
windowExhausted = true
|
||||
break
|
||||
}
|
||||
after = page.endCursor
|
||||
|
||||
if (summary.fetched >= MAX_ORDERS_PER_RUN) {
|
||||
log.warn('order cap reached; remaining orders resume next run', {
|
||||
connectionId: connection.id,
|
||||
cap: MAX_ORDERS_PER_RUN,
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Watermark advance: a fully-listed window means "scanned through run
|
||||
// start", even when it produced no rows. Without this, an empty first run
|
||||
// keeps a NULL cursor forever, and the cron's oldest-first selection
|
||||
// (nullsFirst, limit 50) lets quiet stores permanently occupy the batch
|
||||
// and starve other connections. Capped by the failure floor like every
|
||||
// other cursor write; the 24h overlap re-poll still covers updates that
|
||||
// landed while the run was in flight.
|
||||
if (windowExhausted) {
|
||||
const watermarkMs = Math.min(runStartMs, failureFloorMs)
|
||||
if (watermarkMs > prevCursorMs) {
|
||||
const cursorIso = new Date(watermarkMs).toISOString()
|
||||
await supabase
|
||||
.from('shopify_connections')
|
||||
.update({ last_order_synced_at: cursorIso, error_message: null })
|
||||
.eq('id', connection.id)
|
||||
connection.last_order_synced_at = cursorIso
|
||||
prevCursorMs = watermarkMs
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (isRevokedCredentialsError(err)) {
|
||||
// The app was deleted or its secret rotated in the Dev Dashboard: flip
|
||||
// the connection so the UI offers a reconnect instead of the cron
|
||||
// retrying forever.
|
||||
summary.revoked = true
|
||||
await supabase
|
||||
.from('shopify_connections')
|
||||
.update({
|
||||
status: 'revoked',
|
||||
error_message: 'Butiken avvisade appens uppgifter. Anslut butiken igen.',
|
||||
// The store already rejected these; keeping decryptable dead
|
||||
// credentials would be pure data retention (same as /disconnect).
|
||||
client_id_encrypted: null,
|
||||
client_secret_encrypted: null,
|
||||
disconnected_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', connection.id)
|
||||
.eq('status', 'active')
|
||||
log.warn('credentials revoked upstream; connection flipped to revoked', {
|
||||
connectionId: connection.id,
|
||||
})
|
||||
return summary
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
if (summary.skippedLocked > 0) {
|
||||
log.info('rows behind the bookkeeping lock were skipped', {
|
||||
connectionId: connection.id,
|
||||
skippedLocked: summary.skippedLocked,
|
||||
})
|
||||
}
|
||||
log.info('shopify order sync done', {
|
||||
connectionId: connection.id,
|
||||
...summary,
|
||||
})
|
||||
return summary
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* The Shopify settings panel's server calls, each classified into exactly one
|
||||
* outcome. Same doctrine as the Stripe and WooCommerce panels'
|
||||
* settings-actions (see the doc blocks there): never throw, one toast
|
||||
* sentence per click, and the classification lives outside the component
|
||||
* because component logic has no tests in this repo.
|
||||
*/
|
||||
|
||||
import { fetchWithTimeout, isTimeoutError } from '@/lib/http/fetch-with-timeout'
|
||||
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
|
||||
import type { ActionFailure } from '@/lib/browser/action-failure'
|
||||
|
||||
/**
|
||||
* Deadline for the quick calls (status, toggle, disconnect). Connect probes
|
||||
* the merchant's store (token exchange + orders probe, with retries), so it
|
||||
* gets a longer one.
|
||||
*/
|
||||
export const SHOPIFY_ACTION_TIMEOUT_MS = 15_000
|
||||
export const SHOPIFY_CONNECT_TIMEOUT_MS = 120_000
|
||||
|
||||
/**
|
||||
* Deadline for "Synka nu": the route's own ceiling (maxDuration 300 on the
|
||||
* extension dispatcher) plus margin, same reasoning as the Stripe/WooCommerce
|
||||
* panels. A first sync backfills 90 days and legitimately takes minutes; the
|
||||
* server keeps working and advances the cursor even if we aborted, so
|
||||
* aborting early would misreport a sync that landed.
|
||||
*/
|
||||
export const SHOPIFY_SYNC_TIMEOUT_MS = 310_000
|
||||
|
||||
export type ShopifyRequestResult<T> =
|
||||
/** 2xx. `data` is null when the body was not readable JSON. */
|
||||
| { ok: true; data: T | null }
|
||||
| ActionFailure
|
||||
|
||||
export interface ShopifyRequestOptions {
|
||||
url: string
|
||||
method?: 'GET' | 'POST' | 'DELETE'
|
||||
body?: unknown
|
||||
locale?: ErrorLocale
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
|
||||
/** The one sentence for a non-2xx; route copy wins over the generic map. */
|
||||
export function serverErrorMessage(
|
||||
body: unknown,
|
||||
status: number,
|
||||
locale: ErrorLocale,
|
||||
): string {
|
||||
if (isRecord(body)) {
|
||||
if (locale === 'en' && typeof body.error_en === 'string' && body.error_en.trim()) {
|
||||
return body.error_en.trim()
|
||||
}
|
||||
if (typeof body.error === 'string' && body.error.trim()) {
|
||||
return body.error.trim()
|
||||
}
|
||||
}
|
||||
return getErrorMessage(body, { statusCode: status, locale })
|
||||
}
|
||||
|
||||
/** Call one of the panel's endpoints and report exactly why it failed. */
|
||||
export async function shopifyRequest<T>({
|
||||
url,
|
||||
method = 'POST',
|
||||
body,
|
||||
locale = 'sv',
|
||||
timeoutMs = SHOPIFY_ACTION_TIMEOUT_MS,
|
||||
}: ShopifyRequestOptions): Promise<ShopifyRequestResult<T>> {
|
||||
try {
|
||||
const res = await fetchWithTimeout(
|
||||
url,
|
||||
body === undefined
|
||||
? { method }
|
||||
: {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
{ timeoutMs, description: `${method} ${url}` },
|
||||
)
|
||||
|
||||
const payload = await res.json().catch(() => null)
|
||||
|
||||
if (!res.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'server',
|
||||
status: res.status,
|
||||
message: serverErrorMessage(payload, res.status, locale),
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, data: payload as T | null }
|
||||
} catch (err) {
|
||||
if (isTimeoutError(err)) return { ok: false, reason: 'timeout' }
|
||||
return { ok: false, reason: 'network', message: getErrorMessage(err, { locale }) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Success body of POST /api/extensions/ext/shopify/sync. */
|
||||
export interface ShopifySyncPayload {
|
||||
success?: boolean
|
||||
/** `ShopifySyncSummary` from lib/order-sync.ts, over the wire. */
|
||||
transactions?: {
|
||||
fetched?: number
|
||||
refundsFetched?: number
|
||||
imported?: number
|
||||
duplicates?: number
|
||||
errors?: number
|
||||
revoked?: boolean
|
||||
deadlineReached?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
type SyncCounts = {
|
||||
fetched: number
|
||||
imported: number
|
||||
}
|
||||
|
||||
export type ShopifySyncOutcome =
|
||||
/** The store rejected the credentials; the connection was flipped to revoked. */
|
||||
| { reason: 'revoked' }
|
||||
/** The window genuinely held nothing. A real answer, not a silent success. */
|
||||
| { reason: 'empty' }
|
||||
/**
|
||||
* The time budget ran out with orders still unfetched. Reported before the
|
||||
* count-based outcomes so a truncated run never reads as a complete one;
|
||||
* the cursor persisted, so pressing sync again continues where it stopped.
|
||||
* Carries the error count too: a truncated run can also have failed rows,
|
||||
* and dropping that number would repeat the silent-partial mistake.
|
||||
*/
|
||||
| { reason: 'partial'; values: SyncCounts & { errors: number } }
|
||||
/** Rows landed, and some rows did not. Both halves get said. */
|
||||
| { reason: 'errors'; values: SyncCounts & { errors: number } }
|
||||
/** Rows landed. */
|
||||
| { reason: 'feed'; values: SyncCounts }
|
||||
/** 2xx whose body could not be read: the sync ran, the counts are unknown. */
|
||||
| { reason: 'unknown' }
|
||||
|
||||
/** Turn the sync route's success body into the single sentence the user gets. */
|
||||
export function syncSummary(payload: ShopifySyncPayload | null): ShopifySyncOutcome {
|
||||
const summary = payload?.transactions
|
||||
if (!summary) return { reason: 'unknown' }
|
||||
if (summary.revoked === true) return { reason: 'revoked' }
|
||||
if (typeof summary.fetched !== 'number') return { reason: 'unknown' }
|
||||
|
||||
const fetched = summary.fetched
|
||||
const imported = typeof summary.imported === 'number' ? summary.imported : 0
|
||||
const errors = typeof summary.errors === 'number' ? summary.errors : 0
|
||||
|
||||
if (summary.deadlineReached === true) {
|
||||
return { reason: 'partial', values: { fetched, imported, errors } }
|
||||
}
|
||||
if (fetched === 0) return { reason: 'empty' }
|
||||
if (errors > 0) return { reason: 'errors', values: { fetched, imported, errors } }
|
||||
return { reason: 'feed', values: { fetched, imported } }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"id": "shopify",
|
||||
"sector": "general",
|
||||
"exportName": "shopifyExtension",
|
||||
"entryPoint": "@/extensions/general/shopify",
|
||||
"workspace": null,
|
||||
"requiredEnvVars": ["SHOPIFY_CREDENTIALS_ENCRYPTION_KEY"],
|
||||
"optionalEnvVars": [],
|
||||
"npmDependencies": [],
|
||||
"definition": {
|
||||
"name": "Shopify",
|
||||
"category": "import",
|
||||
"icon": "ShoppingBag",
|
||||
"dataPattern": "manual",
|
||||
"hasOwnData": true,
|
||||
"description": "Hämta betalda ordrar och återbetalningar från din Shopify-butik till transaktionsinkorgen",
|
||||
"longDescription": "Anslut din Shopify-butik så hämtas betalda ordrar och återbetalningar automatiskt varje natt till transaktionsinkorgen, som ett bankflöde för butiken. Inget bokförs automatiskt: du bokför raderna själv precis som vanliga banktransaktioner."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/** Row shape of public.shopify_connections. */
|
||||
export interface ShopifyConnection {
|
||||
id: string
|
||||
company_id: string
|
||||
user_id: string
|
||||
/** Normalized myshopify.com domain, lowercased, no scheme/path. */
|
||||
shop_domain: string
|
||||
shop_name: string | null
|
||||
/**
|
||||
* Dev Dashboard custom-app credentials (client credentials grant). Shopify
|
||||
* discontinued revealable admin API tokens for new custom apps on
|
||||
* 2026-01-01; the merchant pastes the app's client id/secret instead and
|
||||
* the server exchanges them for a short-lived (24h) access token per run.
|
||||
*/
|
||||
client_id_encrypted: string | null
|
||||
client_secret_encrypted: string | null
|
||||
status: 'pending' | 'active' | 'revoked' | 'error'
|
||||
/** ISO 4217 shop currency read at connect time. */
|
||||
currency: string | null
|
||||
/** Opt-in: nightly order-feed cron (the manual sync button ignores it). */
|
||||
transaction_sync_enabled: boolean
|
||||
/** Order-polling cursor (max updatedAt processed). */
|
||||
last_order_synced_at: string | null
|
||||
error_message: string | null
|
||||
connected_at: string | null
|
||||
disconnected_at: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/** Status payload returned by GET /api/extensions/ext/shopify/status. */
|
||||
export interface ShopifyStatusResponse {
|
||||
configured: boolean
|
||||
connection: Pick<
|
||||
ShopifyConnection,
|
||||
| 'id'
|
||||
| 'status'
|
||||
| 'shop_domain'
|
||||
| 'shop_name'
|
||||
| 'currency'
|
||||
| 'error_message'
|
||||
| 'connected_at'
|
||||
| 'transaction_sync_enabled'
|
||||
| 'last_order_synced_at'
|
||||
> | null
|
||||
}
|
||||
|
||||
/**
|
||||
* GraphQL MoneyBag, shop-currency side only. Amounts are decimal strings in
|
||||
* major units for every currency (also zero-decimal ones like JPY), so
|
||||
* parseFloat is correct universally: never divide by 100.
|
||||
*/
|
||||
export interface ShopifyMoneyBag {
|
||||
shopMoney: { amount: string; currencyCode: string }
|
||||
}
|
||||
|
||||
/** Minimal refund shape; Shopify returns refunds inline on the order. */
|
||||
export interface ShopifyRefund {
|
||||
legacyResourceId: string
|
||||
createdAt: string
|
||||
totalRefundedSet: ShopifyMoneyBag
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal GraphQL Admin API order shape consumed by the feed. All timestamps
|
||||
* are ISO 8601 UTC with a Z suffix.
|
||||
*/
|
||||
export interface ShopifyOrder {
|
||||
/** Numeric order id as a string; stable join key for external_id. */
|
||||
legacyResourceId: string
|
||||
/** Display name incl. the # prefix (e.g. "#1042"); plugins can renumber. */
|
||||
name: string
|
||||
/** Test-gateway order (dev stores, Bogus Gateway); never real revenue. */
|
||||
test: boolean
|
||||
/** When payment was captured; the feed's row date. */
|
||||
processedAt: string
|
||||
updatedAt: string
|
||||
displayFinancialStatus: string | null
|
||||
/** Gateway display names; join key for gateway-side reconciliation. */
|
||||
paymentGatewayNames: string[]
|
||||
/** Grand total actually charged (gross, incl. tax and shipping). */
|
||||
totalPriceSet: ShopifyMoneyBag
|
||||
refunds: ShopifyRefund[]
|
||||
}
|
||||
|
||||
/** Shop metadata read at connect time. */
|
||||
export interface ShopifyShopInfo {
|
||||
name: string | null
|
||||
/** ISO 4217 shop currency. */
|
||||
currency: string | null
|
||||
}
|
||||
Reference in New Issue
Block a user