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
246 lines
8.5 KiB
TypeScript
246 lines
8.5 KiB
TypeScript
import { createServiceClient } from '@/lib/supabase/server'
|
|
import { NextResponse } from 'next/server'
|
|
import { ensureInitialized } from '@/lib/init'
|
|
import { loadExtensions } from '@/lib/extensions/loader'
|
|
import { extensionRegistry } from '@/lib/extensions/registry'
|
|
import { eventBus } from '@/lib/events/bus'
|
|
import { hashAuthCode } from '@/lib/auth/oauth-codes'
|
|
import {
|
|
requireFlowInitiator,
|
|
FLOW_INITIATOR_MISMATCH_MESSAGE,
|
|
} from '@/lib/auth/oauth-flow-binding'
|
|
import { encryptCredential } from '@/extensions/general/zettle/lib/credentials'
|
|
import { validateReturnOrigin } from '@/extensions/general/zettle/lib/return-origin'
|
|
import {
|
|
exchangeCodeForTokens,
|
|
fetchUserSelf,
|
|
} from '@/extensions/general/zettle/lib/oauth'
|
|
|
|
// This route emits zettle.connected (audit trail). ensureInitialized() must
|
|
// run at module load so the event_log handler has subscribed before the first
|
|
// emit on a cold instance.
|
|
ensureInitialized()
|
|
|
|
/**
|
|
* GET /api/extensions/zettle/callback
|
|
*
|
|
* OAuth callback for Zettle partner authorization. Must be a real Next.js
|
|
* route (not an extension dispatcher handler) because Zettle redirects the
|
|
* user's browser to this URL directly.
|
|
*/
|
|
export async function GET(request: Request) {
|
|
// Physical route: refuse (503) when the extension is not enabled instead
|
|
// of quietly activating connections for a feature the deployment turned off.
|
|
loadExtensions()
|
|
if (!extensionRegistry.get('zettle')) {
|
|
return NextResponse.json(
|
|
{ error: 'Zettle extension is not enabled', code: 'EXTENSION_DISABLED' },
|
|
{ status: 503 },
|
|
)
|
|
}
|
|
|
|
const { searchParams } = new URL(request.url)
|
|
|
|
const code = searchParams.get('code')
|
|
const state = searchParams.get('state')
|
|
const error = searchParams.get('error')
|
|
const errorDescription = searchParams.get('error_description')
|
|
|
|
const appBase = (process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000').replace(/\/$/, '')
|
|
// Return the browser to the origin the connect flow started on. Zettle
|
|
// redirects to the one registered callback URL, so a white-label user
|
|
// would otherwise land on the canonical app domain. The stored value is
|
|
// re-validated here (members can update the row through RLS): the app
|
|
// origin or a brand domain, never an arbitrary URL.
|
|
const returnUrlFor = async (origin: string | null | undefined) =>
|
|
`${await validateReturnOrigin(origin, appBase)}/import?mode=zettle`
|
|
let returnUrl = `${appBase}/import?mode=zettle`
|
|
|
|
if (error) {
|
|
const errorMessage = errorDescription || error
|
|
const logDenied = error === 'access_denied' ? console.warn : console.error
|
|
logDenied('[zettle] OAuth authorization denied', {
|
|
error,
|
|
error_description: errorDescription,
|
|
has_state: !!state,
|
|
})
|
|
|
|
if (state) {
|
|
try {
|
|
const supabase = await createServiceClient()
|
|
const { data: denied } = await supabase
|
|
.from('zettle_connections')
|
|
.update({ status: 'error', error_message: errorMessage, oauth_state: null })
|
|
.eq('oauth_state', state)
|
|
.eq('status', 'pending')
|
|
.select('return_origin')
|
|
.maybeSingle()
|
|
returnUrl = await returnUrlFor(denied?.return_origin)
|
|
} catch (cleanupError) {
|
|
console.error('[zettle] Failed to clean up pending connection:', cleanupError)
|
|
}
|
|
}
|
|
|
|
return NextResponse.redirect(
|
|
`${returnUrl}&zettle_error=${encodeURIComponent(errorMessage)}`,
|
|
)
|
|
}
|
|
|
|
if (!code || !state) {
|
|
return NextResponse.redirect(`${returnUrl}&zettle_error=missing_parameters`)
|
|
}
|
|
|
|
const supabase = await createServiceClient()
|
|
|
|
try {
|
|
const { data: pendingConnection, error: findError } = await supabase
|
|
.from('zettle_connections')
|
|
.select('id, user_id, company_id, return_origin')
|
|
.eq('oauth_state', state)
|
|
.eq('status', 'pending')
|
|
.single()
|
|
|
|
if (pendingConnection) {
|
|
returnUrl = await returnUrlFor(pendingConnection.return_origin)
|
|
}
|
|
|
|
if (findError || !pendingConnection) {
|
|
console.error('[zettle] No pending connection for oauth_state', {
|
|
findError: findError
|
|
? { message: findError.message, code: findError.code }
|
|
: null,
|
|
hasCode: !!code,
|
|
})
|
|
return NextResponse.redirect(
|
|
`${returnUrl}&zettle_error=${encodeURIComponent('invalid_state')}`,
|
|
)
|
|
}
|
|
|
|
const initiator = await requireFlowInitiator(request, pendingConnection.user_id, {
|
|
flow: 'zettle.callback',
|
|
})
|
|
if (!initiator.ok) {
|
|
if (initiator.reason === 'no_session') {
|
|
return initiator.response
|
|
}
|
|
return NextResponse.redirect(
|
|
`${returnUrl}&zettle_error=${encodeURIComponent(FLOW_INITIATOR_MISMATCH_MESSAGE)}`,
|
|
)
|
|
}
|
|
|
|
const { error: replayError } = await supabase
|
|
.from('oauth_used_codes')
|
|
.insert({ code_hash: hashAuthCode(code) })
|
|
if (replayError) {
|
|
console.error('[zettle] Authorization code already used', {
|
|
connectionId: pendingConnection.id,
|
|
code: replayError.code,
|
|
})
|
|
return NextResponse.redirect(
|
|
`${returnUrl}&zettle_error=${encodeURIComponent('invalid_state')}`,
|
|
)
|
|
}
|
|
|
|
const tokens = await exchangeCodeForTokens(code)
|
|
const userSelf = await fetchUserSelf(tokens.access_token)
|
|
|
|
// Require the row still be the original pending state. POST /connect can
|
|
// invalidate this row between lookup and activate; filtering only by id
|
|
// would revive the abandoned flow and attach the wrong Zettle org.
|
|
const { data: updatedConnection, error: updateError } = await supabase
|
|
.from('zettle_connections')
|
|
.update({
|
|
organization_uuid: userSelf.organizationUuid,
|
|
organization_name: null,
|
|
refresh_token_encrypted: encryptCredential(tokens.refresh_token),
|
|
status: 'active',
|
|
connected_at: new Date().toISOString(),
|
|
error_message: null,
|
|
oauth_state: null,
|
|
transaction_sync_enabled: true,
|
|
})
|
|
.eq('id', pendingConnection.id)
|
|
.eq('status', 'pending')
|
|
.eq('oauth_state', state)
|
|
.select('id, company_id, user_id, organization_uuid')
|
|
.maybeSingle()
|
|
|
|
if (updateError) {
|
|
const isConflict = updateError.code === '23505'
|
|
console.error('[zettle] Failed to activate connection', {
|
|
connectionId: pendingConnection.id,
|
|
error: { message: updateError.message, code: updateError.code },
|
|
})
|
|
await supabase
|
|
.from('zettle_connections')
|
|
.update({
|
|
status: 'error',
|
|
error_message: isConflict
|
|
? 'Zettle-organisationen är redan ansluten till ett företag.'
|
|
: 'Anslutningen kunde inte slutföras.',
|
|
oauth_state: null,
|
|
refresh_token_encrypted: null,
|
|
})
|
|
.eq('id', pendingConnection.id)
|
|
.eq('status', 'pending')
|
|
return NextResponse.redirect(
|
|
`${returnUrl}&zettle_error=${encodeURIComponent(
|
|
isConflict ? 'account_already_connected' : 'activation_failed',
|
|
)}`,
|
|
)
|
|
}
|
|
|
|
if (!updatedConnection) {
|
|
console.error('[zettle] Pending connection invalidated before activation', {
|
|
connectionId: pendingConnection.id,
|
|
})
|
|
return NextResponse.redirect(
|
|
`${returnUrl}&zettle_error=${encodeURIComponent('invalid_state')}`,
|
|
)
|
|
}
|
|
|
|
try {
|
|
await eventBus.emit({
|
|
type: 'zettle.connected',
|
|
payload: {
|
|
connectionId: updatedConnection.id,
|
|
organizationUuid: updatedConnection.organization_uuid!,
|
|
userId: updatedConnection.user_id,
|
|
companyId: updatedConnection.company_id,
|
|
},
|
|
})
|
|
} catch (emitError) {
|
|
console.error('[zettle] Failed to emit zettle.connected event', {
|
|
connectionId: updatedConnection.id,
|
|
error: emitError instanceof Error ? emitError.message : String(emitError),
|
|
})
|
|
}
|
|
|
|
return NextResponse.redirect(`${returnUrl}&zettle_connected=true`)
|
|
} catch (error) {
|
|
console.error('[zettle] Callback error', {
|
|
message: error instanceof Error ? error.message : String(error),
|
|
name: error instanceof Error ? error.name : undefined,
|
|
hasCode: !!code,
|
|
})
|
|
|
|
try {
|
|
await supabase
|
|
.from('zettle_connections')
|
|
.update({
|
|
status: 'error',
|
|
error_message: 'Anslutningen kunde inte slutföras.',
|
|
oauth_state: null,
|
|
})
|
|
.eq('oauth_state', state)
|
|
.eq('status', 'pending')
|
|
} catch (cleanupError) {
|
|
console.error('[zettle] Callback cleanup failed:', cleanupError)
|
|
}
|
|
|
|
return NextResponse.redirect(
|
|
`${returnUrl}&zettle_error=${encodeURIComponent('connection_failed')}`,
|
|
)
|
|
}
|
|
}
|