Community PR #2416 by @olofpinzke, adopted and finished by maintainers (rebased so every commit is signed). Why the problem occurred: no Zettle integration; POS sales only reached the books as bank descriptors while Woo/Shopify already had order underlag via webshop_orders. The contributor's version also failed at the database (platform CHECKs listed only woocommerce/shopify), which the mocked unit tests never saw. What was simplified: reused the Orders/book/invoice path instead of a new inbox; Finance API payouts/fees deferred. Sales the one-account, revenue-per-rate model cannot book (split tender, gift cards, tips) import unbookable with a "bokför manuellt" title instead of guessing accounts. Reset parity uses the rename-and-wrap pattern instead of re-issuing the reset body. Why this solution: per-purchase rows give the radunderlag BFL verifikat need and the bulk-book path exists; daily kassarapport aggregation and Finance API fees/payouts are the follow-up (DECISIONS.md). Skeptic-refuted paths fixed before merge: concurrent refresh-token rotation (sync claim), cron offset paging (candidate snapshot), platform CHECKs, writer-role gate, migration-reset parity, white-label return origin re-validated at callback, VAT net from product rows. Not live until ZETTLE_CLIENT_ID / ZETTLE_CLIENT_SECRET / ZETTLE_CREDENTIALS_ENCRYPTION_KEY are set on Vercel and a Zettle developer app is registered with the callback redirect URI. Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WtYqzKPoTSRHskYYdf7MwB
189 lines
7.2 KiB
TypeScript
189 lines
7.2 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
|
|
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) }
|
|
})
|
|
|
|
vi.mock('../lib/oauth', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('../lib/oauth')>()
|
|
return {
|
|
...actual,
|
|
refreshAccessToken: vi.fn(),
|
|
disconnectApplication: vi.fn().mockResolvedValue(undefined),
|
|
}
|
|
})
|
|
|
|
vi.mock('../lib/order-sync', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('../lib/order-sync')>()
|
|
return { ...actual, syncZettlePurchases: vi.fn() }
|
|
})
|
|
|
|
vi.mock('@/lib/auth/api-keys', () => ({
|
|
createServiceClientNoCookies: vi.fn(() => ({ service: true })),
|
|
}))
|
|
|
|
vi.mock('@/lib/auth/oauth-flows', () => ({
|
|
resolveOAuthOrigin: vi.fn().mockResolvedValue('https://brand.testbrand.example'),
|
|
}))
|
|
|
|
import { zettleExtension } from '../index'
|
|
import { requireCapability, capabilityBlockedResponse } from '@/lib/entitlements/has-capability'
|
|
import { CAPABILITY } from '@/lib/entitlements/keys'
|
|
import { syncZettlePurchases } from '../lib/order-sync'
|
|
import { resolveOAuthOrigin } from '@/lib/auth/oauth-flows'
|
|
import { createQueuedMockSupabase } from '@/tests/helpers'
|
|
import type { ExtensionContext } from '@/lib/extensions/types'
|
|
|
|
function findRoute(method: string, path: string) {
|
|
const route = zettleExtension.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/zettle/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: 'zettle',
|
|
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 }
|
|
|
|
describe('zettle extension routes', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
vi.stubEnv('ZETTLE_CLIENT_ID', 'cid')
|
|
vi.stubEnv('ZETTLE_CLIENT_SECRET', 'csecret')
|
|
vi.stubEnv('ZETTLE_CREDENTIALS_ENCRYPTION_KEY', 'test-key')
|
|
vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://app.example.com')
|
|
vi.mocked(requireCapability).mockResolvedValue(null)
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllEnvs()
|
|
})
|
|
|
|
it('GET /status returns configured + connection', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null })
|
|
enqueue({
|
|
data: [
|
|
{
|
|
id: 'c1',
|
|
status: 'active',
|
|
organization_uuid: 'org-1',
|
|
organization_name: null,
|
|
currency: 'SEK',
|
|
error_message: null,
|
|
connected_at: '2026-09-01T00:00:00.000Z',
|
|
transaction_sync_enabled: true,
|
|
last_order_synced_at: null,
|
|
},
|
|
],
|
|
})
|
|
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')
|
|
})
|
|
|
|
it('POST /connect stages pending and returns authorize url', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null })
|
|
enqueue({ data: { is_sandbox: false } }) // guardSandbox
|
|
enqueue({ data: [] }) // no active connection
|
|
enqueue({ data: [] }) // clear stale pending
|
|
enqueue({ data: { id: 'pending-1' } }) // insert pending
|
|
const res = await findRoute('POST', '/connect').handler(makeRequest('POST'), makeContext(supabase))
|
|
expect(res.status).toBe(200)
|
|
const body = await res.json()
|
|
const url = new URL(body.url)
|
|
expect(url.origin + url.pathname).toBe('https://oauth.zettle.com/authorize')
|
|
expect(url.searchParams.get('client_id')).toBe('cid')
|
|
expect(url.searchParams.get('scope')).toBe('READ:PURCHASE READ:USERINFO')
|
|
// The validated brand/app origin is frozen on the pending row so the
|
|
// callback can send the browser back to the domain it started on.
|
|
expect(resolveOAuthOrigin).toHaveBeenCalledTimes(1)
|
|
expect(supabase.from).toHaveBeenCalledWith('zettle_connections')
|
|
expect(requireCapability).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
'company-1',
|
|
CAPABILITY.zettle_sync,
|
|
)
|
|
})
|
|
|
|
it('POST /connect invalidates a prior pending row before staging a new one', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null })
|
|
enqueue({ data: { is_sandbox: false } }) // guardSandbox
|
|
enqueue({ data: [] }) // no active connection
|
|
enqueue({ data: [] }) // clear stale pending (status -> error)
|
|
enqueue({ data: { id: 'pending-2' } }) // insert replacement pending
|
|
const res = await findRoute('POST', '/connect').handler(makeRequest('POST'), makeContext(supabase))
|
|
expect(res.status).toBe(200)
|
|
// Second connect must leave the abandoned oauth_state unusable so a late
|
|
// callback for the first flow cannot activate that row (see callback test).
|
|
expect(supabase.from).toHaveBeenCalledWith('zettle_connections')
|
|
})
|
|
|
|
it('POST /connect refuses when capability is blocked', async () => {
|
|
vi.mocked(requireCapability).mockResolvedValue(capabilityBlockedResponse(CAPABILITY.zettle_sync))
|
|
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'), makeContext(supabase))
|
|
expect([402, 403]).toContain(res.status)
|
|
})
|
|
|
|
it('POST /sync calls syncZettlePurchases', async () => {
|
|
vi.mocked(syncZettlePurchases).mockResolvedValue({
|
|
fetched: 1,
|
|
refundsFetched: 0,
|
|
inserted: 1,
|
|
updated: 0,
|
|
unchanged: 0,
|
|
frozenFlagged: 0,
|
|
crossMarked: 0,
|
|
errors: 0,
|
|
needsReview: 0,
|
|
skippedUnsupported: 0,
|
|
})
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null })
|
|
enqueue({
|
|
data: {
|
|
id: 'c1',
|
|
status: 'active',
|
|
company_id: 'company-1',
|
|
user_id: 'user-1',
|
|
organization_uuid: 'org-1',
|
|
refresh_token_encrypted: 'enc',
|
|
},
|
|
})
|
|
const res = await findRoute('POST', '/sync').handler(makeRequest('POST'), makeContext(supabase))
|
|
expect(res.status).toBe(200)
|
|
expect(syncZettlePurchases).toHaveBeenCalled()
|
|
})
|
|
})
|