import { SecretsManagerClient, GetSecretValueCommand, PutSecretValueCommand, } from '@aws-sdk/client-secrets-manager'; const secretsClient = new SecretsManagerClient({}); const QBO_SECRET_ARN = process.env.QBO_SECRET_ARN!; const QBO_CLIENT_ID = process.env.QBO_CLIENT_ID!; const QBO_CLIENT_SECRET = process.env.QBO_CLIENT_SECRET!; const REDIRECT_URI = process.env.REDIRECT_URI!; // https://bot.seahaven.com/qbo/callback // Intuit OAuth endpoints const AUTHORIZE_URL = 'https://appcenter.intuit.com/connect/oauth2'; const TOKEN_URL = 'https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer'; const REVOKE_URL = 'https://developer.api.intuit.com/v2/oauth2/tokens/revoke'; // Scopes needed for vendor queries const SCOPES = 'com.intuit.quickbooks.accounting'; interface APIGatewayEvent { requestContext: { http: { method: string; path: string } }; queryStringParameters?: Record; headers: Record; } interface APIGatewayResponse { statusCode: number; headers?: Record; body: string; } function redirect(url: string): APIGatewayResponse { return { statusCode: 302, headers: { Location: url }, body: '' }; } function html(title: string, message: string): APIGatewayResponse { return { statusCode: 200, headers: { 'Content-Type': 'text/html' }, body: ` ${title} — Sea Haven Industries

${title}

${message}

`, }; } // ── /qbo/connect — redirect to Intuit OAuth ────────────────────────────────── function handleConnect(): APIGatewayResponse { // Generate a simple state parameter for CSRF protection const state = crypto.randomUUID(); const params = new URLSearchParams({ client_id: QBO_CLIENT_ID, response_type: 'code', scope: SCOPES, redirect_uri: REDIRECT_URI, state, }); return redirect(`${AUTHORIZE_URL}?${params.toString()}`); } // ── /qbo/callback — exchange code for tokens, store in Secrets Manager ─────── async function handleCallback( query: Record, ): Promise { const { code, realmId } = query; if (!code || !realmId) { return html('Connection Failed', 'Missing authorization code or company ID from Intuit. Please try connecting again.'); } const credentials = Buffer.from(`${QBO_CLIENT_ID}:${QBO_CLIENT_SECRET}`).toString('base64'); const tokenRes = await fetch(TOKEN_URL, { method: 'POST', headers: { Authorization: `Basic ${credentials}`, 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json', }, body: new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: REDIRECT_URI, }).toString(), }); if (!tokenRes.ok) { const err = await tokenRes.text(); console.error('Token exchange failed:', err); return html('Connection Failed', 'Could not exchange authorization code for tokens. Please try again.'); } const tokens = (await tokenRes.json()) as { access_token: string; refresh_token: string; expires_in: number; x_refresh_token_expires_in: number; }; // Store in Secrets Manager — same structure the qbo-lookup Lambda expects await secretsClient.send( new PutSecretValueCommand({ SecretId: QBO_SECRET_ARN, SecretString: JSON.stringify({ clientId: QBO_CLIENT_ID, clientSecret: QBO_CLIENT_SECRET, refreshToken: tokens.refresh_token, realmId, }), }), ); console.log('QBO OAuth tokens stored successfully for realmId:', realmId); return redirect('/qbo/launch'); } // ── /qbo/disconnect — revoke token and clear secret ────────────────────────── async function handleDisconnect(): Promise { let refreshToken: string | undefined; try { const res = await secretsClient.send( new GetSecretValueCommand({ SecretId: QBO_SECRET_ARN }), ); const secret = JSON.parse(res.SecretString!); refreshToken = secret.refreshToken; } catch { // Secret may not exist or be empty — that's fine } // Revoke the token at Intuit if we have one if (refreshToken) { const credentials = Buffer.from(`${QBO_CLIENT_ID}:${QBO_CLIENT_SECRET}`).toString('base64'); try { await fetch(REVOKE_URL, { method: 'POST', headers: { Authorization: `Basic ${credentials}`, 'Content-Type': 'application/json', Accept: 'application/json', }, body: JSON.stringify({ token: refreshToken }), }); } catch (err) { console.error('Token revocation failed (non-fatal):', err); } // Clear the stored secret await secretsClient.send( new PutSecretValueCommand({ SecretId: QBO_SECRET_ARN, SecretString: JSON.stringify({ clientId: QBO_CLIENT_ID, clientSecret: QBO_CLIENT_SECRET, refreshToken: '', realmId: '', }), }), ); } return html( 'Disconnected', 'Your QuickBooks account has been disconnected from Sea Haven Industries. You can reconnect at any time.', ); } // ── /qbo/launch — success landing page ─────────────────────────────────────── function handleLaunch(): APIGatewayResponse { return html( 'Connected', 'Your QuickBooks account is connected to Sea Haven Industries. You can close this window.', ); } // ── Router ─────────────────────────────────────────────────────────────────── export const handler = async (event: APIGatewayEvent): Promise => { const path = event.requestContext.http.path; const query = event.queryStringParameters ?? {}; switch (path) { case '/qbo/connect': return handleConnect(); case '/qbo/callback': return handleCallback(query); case '/qbo/disconnect': return handleDisconnect(); case '/qbo/launch': return handleLaunch(); default: return { statusCode: 404, body: 'Not found' }; } };