Files
accounted/extensions/general/zettle/lib/oauth.ts
T
MattssonandClaude Fable 5.1 6ea92f3152 feat(zettle): sync paid purchases into webshop_orders (#2445)
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
2026-09-09 11:19:39 +02:00

170 lines
5.7 KiB
TypeScript

/**
* Zettle partner-hosted OAuth 2.0 (authorization code grant).
*
* Scopes: READ:PURCHASE (Purchase API) + READ:USERINFO (users/self for the
* organization UUID that becomes store_scope). Refresh tokens rotate: every
* refresh returns a new refresh token that MUST replace the previous one.
*/
import {
fetchWithTimeout,
OAUTH_TIMEOUT_MS,
OAUTH_REVOKE_TIMEOUT_MS,
} from '@/lib/http/fetch-with-timeout'
import { isZettleConfigured } from './credentials'
import type { ZettleTokenPair, ZettleUserSelf } from '../types'
const AUTH_ENDPOINT = 'https://oauth.zettle.com/authorize'
const TOKEN_ENDPOINT = 'https://oauth.zettle.com/token'
const USERS_SELF_ENDPOINT = 'https://oauth.zettle.com/users/self'
const DISCONNECT_ENDPOINT = 'https://oauth.zettle.com/application-connections/self'
/** Space-separated scopes requested at authorize time. */
export const ZETTLE_OAUTH_SCOPES = 'READ:PURCHASE READ:USERINFO'
export function getZettleClientCredentials(): { clientId: string; clientSecret: string } {
const clientId = process.env.ZETTLE_CLIENT_ID
const clientSecret = process.env.ZETTLE_CLIENT_SECRET
if (!clientId || !clientSecret) {
throw new Error('Zettle OAuth is not configured: set ZETTLE_CLIENT_ID and ZETTLE_CLIENT_SECRET')
}
return { clientId, clientSecret }
}
export function getZettleRedirectUri(): string {
const base = process.env.NEXT_PUBLIC_APP_URL
if (!base) {
throw new Error('NEXT_PUBLIC_APP_URL is required for Zettle OAuth')
}
return `${base.replace(/\/$/, '')}/api/extensions/zettle/callback`
}
export function buildAuthorizeUrl(state: string): string {
if (!isZettleConfigured()) {
throw new Error('Zettle is not configured')
}
const { clientId } = getZettleClientCredentials()
const params = new URLSearchParams({
response_type: 'code',
scope: ZETTLE_OAUTH_SCOPES,
client_id: clientId,
redirect_uri: getZettleRedirectUri(),
state,
})
return `${AUTH_ENDPOINT}?${params.toString()}`
}
async function postToken(body: URLSearchParams): Promise<ZettleTokenPair> {
const res = await fetchWithTimeout(
TOKEN_ENDPOINT,
{
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
body: body.toString(),
// Node fetch can replay POST bodies across 307/308 redirects, including
// cross-origin. Refuse redirects so client_secret never leaves oauth.zettle.com.
redirect: 'error',
},
{ timeoutMs: OAUTH_TIMEOUT_MS, description: 'Zettle token exchange' },
)
if (!res.ok) {
const errText = await res.text().catch(() => '')
throw new ZettleOAuthError(
`Zettle token exchange failed: ${res.status}${errText ? ` ${errText}` : ''}`,
res.status,
)
}
const json = (await res.json()) as Partial<ZettleTokenPair>
if (typeof json.access_token !== 'string' || !json.access_token) {
throw new ZettleOAuthError('Zettle token exchange returned no access token', 0)
}
if (typeof json.refresh_token !== 'string' || !json.refresh_token) {
throw new ZettleOAuthError('Zettle token exchange returned no refresh token', 0)
}
return {
access_token: json.access_token,
refresh_token: json.refresh_token,
expires_in: typeof json.expires_in === 'number' ? json.expires_in : 7200,
}
}
export async function exchangeCodeForTokens(code: string): Promise<ZettleTokenPair> {
const { clientId, clientSecret } = getZettleClientCredentials()
const body = new URLSearchParams({
grant_type: 'authorization_code',
code,
client_id: clientId,
client_secret: clientSecret,
redirect_uri: getZettleRedirectUri(),
})
return postToken(body)
}
/**
* Exchange a refresh token for a new access + refresh pair. Callers MUST
* persist the new refresh_token (Zettle rotates it on every refresh).
*/
export async function refreshAccessToken(refreshToken: string): Promise<ZettleTokenPair> {
const { clientId, clientSecret } = getZettleClientCredentials()
const body = new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: clientId,
client_secret: clientSecret,
})
return postToken(body)
}
export async function fetchUserSelf(accessToken: string): Promise<ZettleUserSelf> {
const res = await fetchWithTimeout(
USERS_SELF_ENDPOINT,
{
method: 'GET',
headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' },
},
{ timeoutMs: OAUTH_TIMEOUT_MS, description: 'Zettle users/self' },
)
if (!res.ok) {
throw new ZettleOAuthError(`Zettle users/self failed: ${res.status}`, res.status)
}
const json = (await res.json()) as Partial<ZettleUserSelf>
if (typeof json.organizationUuid !== 'string' || !json.organizationUuid) {
throw new ZettleOAuthError('Zettle users/self returned no organizationUuid', 0)
}
return {
uuid: typeof json.uuid === 'string' ? json.uuid : '',
organizationUuid: json.organizationUuid,
}
}
/** Best-effort remote revoke; local revoke still proceeds if this fails. */
export async function disconnectApplication(accessToken: string): Promise<void> {
try {
await fetchWithTimeout(
DISCONNECT_ENDPOINT,
{
method: 'DELETE',
headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' },
},
{ timeoutMs: OAUTH_REVOKE_TIMEOUT_MS, description: 'Zettle disconnect' },
)
} catch {
// Remote revoke is best-effort.
}
}
export class ZettleOAuthError extends Error {
constructor(
message: string,
readonly status: number,
) {
super(message)
this.name = 'ZettleOAuthError'
}
}
export function isRevokedOAuthError(error: unknown): boolean {
if (!(error instanceof ZettleOAuthError)) return false
return error.status === 400 || error.status === 401 || error.status === 403
}