Files
accounted/extensions/general/shopify/lib/api-client.ts
T
Mattsson c187fabf92 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>
2026-08-09 12:44:08 +02:00

350 lines
12 KiB
TypeScript

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,
}
}