/** * Amazon Ads API client (Billing / Invoices) — the Sponsored Ads billing centre * ("Billing & payments" in the Ads console) as an API. * * Endpoints used (Advertising Billing OpenAPI 3.0, 3P): * GET /v2/profiles → advertising profiles (one per marketplace) * GET /invoices?invoiceStatuses=&startDate=&endDate=&count=&cursor= * → invoice summaries (Accept application/vnd.invoices.v1.1+json) * GET /invoices/{invoiceId} → campaign lines, tax detail, issuer/payer (application/vnd.invoice.v1.1+json) * GET /billing/documents/{invoiceId}?docType=INVOICE * → { availableDocuments[{ storagePath, fileName }] } = the console PDF * * Every call needs `Amazon-Advertising-API-ClientId` (the LWA client id), * `Amazon-Advertising-API-Scope` (the profile id) and a Bearer access token * minted from the LWA refresh token. The refresh token comes from a ONE-TIME * OAuth consent with scope `advertising::campaign_management` (routes * accounting/amazon-ads/connect + oauth/amazon-ads/callback) and is stored in * the adsStore (or AMAZON_ADS_REFRESH_TOKEN as env override). * * Access to the Ads API is a SEPARATE approval from SP-API: the LWA security * profile must be linked to an approved Amazon Ads API application (the SP-API * LWA client id/secret can be reused by copying them into AMAZON_ADS_CLIENT_ID/ * _SECRET once that link exists). Until AMAZON_ADS_CLIENT_ID/_SECRET are set, * `isAdsApiConfigured()` is false and the page runs in PDF-upload mode. */ import { getAdsSetting, setAdsSetting } from './adsStore' import type { AdsInvoice, AdsInvoiceLine } from './types' import { PAYMENT_METHOD_LABELS, round2 } from './types' const LWA_TOKEN_URL = 'https://api.amazon.com/auth/o2/token' const API_BASE: Record = { eu: 'https://advertising-api-eu.amazon.com', na: 'https://advertising-api.amazon.com', fe: 'https://advertising-api-fe.amazon.com' } const OAUTH_BASE: Record = { eu: 'https://eu.account.amazon.com/ap/oa', na: 'https://www.amazon.com/ap/oa', fe: 'https://apac.account.amazon.com/ap/oa' } export const ADS_SCOPE = 'advertising::campaign_management' export interface AdsConfig { clientId: string clientSecret: string refreshToken: string | null profileId: string | null region: string redirectUri: string | null vendorBpartnerId: number defaultChargeId: number | null defaultFeeChargeId: number | null } export const getAdsConfig = (event?: any): AdsConfig => { const cfg: any = useRuntimeConfig() const api = cfg.api || {} const region = String(api.amazonAdsRegion || 'eu').toLowerCase() let redirectUri: string | null = api.amazonAdsRedirectUri || null if (!redirectUri && event) { try { const proto = getRequestHeader(event, 'x-forwarded-proto') || 'https' const host = getRequestHeader(event, 'x-forwarded-host') || getRequestHeader(event, 'host') if (host) redirectUri = `${proto}://${host}/api/oauth/amazon-ads/callback` } catch {} } return { // explicit only — the SP-API LWA app is NOT assumed to be Ads-API-linked; once the // security profile is linked, copy its client id/secret into AMAZON_ADS_CLIENT_ID/_SECRET clientId: String(api.amazonAdsClientId || ''), clientSecret: String(api.amazonAdsClientSecret || ''), refreshToken: getAdsSetting('refresh_token') || api.amazonAdsRefreshToken || null, profileId: getAdsSetting('profile_id') || api.amazonAdsProfileId || null, region: API_BASE[region] ? region : 'eu', redirectUri, vendorBpartnerId: Number(api.amazonAdsVendorBpartnerId) > 0 ? Number(api.amazonAdsVendorBpartnerId) : 1043801, defaultChargeId: Number(api.amazonAdsChargeId) > 0 ? Number(api.amazonAdsChargeId) : null, defaultFeeChargeId: Number(api.amazonFeeChargeId) > 0 ? Number(api.amazonFeeChargeId) : null } } /** client id + secret present → the OAuth connect can be offered */ export const isAdsApiConfigured = (event?: any) => { const c = getAdsConfig(event); return !!(c.clientId && c.clientSecret) } /** …and a refresh token exists → invoices can be listed */ export const isAdsApiConnected = (event?: any) => { const c = getAdsConfig(event); return !!(c.clientId && c.clientSecret && c.refreshToken) } export const buildAdsAuthorizeUrl = (event: any, state: string) => { const c = getAdsConfig(event) if (!c.clientId || !c.redirectUri) return null const u = new URL(OAUTH_BASE[c.region] || OAUTH_BASE.eu) u.searchParams.set('client_id', c.clientId) u.searchParams.set('scope', ADS_SCOPE) u.searchParams.set('response_type', 'code') u.searchParams.set('redirect_uri', c.redirectUri) u.searchParams.set('state', state) return u.toString() } export const exchangeAdsAuthCode = async (event: any, code: string) => { const c = getAdsConfig(event) const res = await fetch(LWA_TOKEN_URL, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: String(c.redirectUri), client_id: c.clientId, client_secret: c.clientSecret }) }) const json: any = await res.json().catch(() => ({})) if (!res.ok || !json?.refresh_token) throw new Error(json?.error_description || json?.error || `LWA token exchange failed (${res.status})`) setAdsSetting('refresh_token', json.refresh_token) setAdsSetting('connected_at', new Date().toISOString()) accessCache = null return json } /* ------------------------------ access token ------------------------------ */ let accessCache: { token: string; exp: number; key: string } | null = null export const getAdsAccessToken = async (event?: any): Promise => { const c = getAdsConfig(event) if (!c.clientId || !c.clientSecret) throw new Error('Amazon Ads API ist nicht konfiguriert (AMAZON_ADS_CLIENT_ID / AMAZON_ADS_CLIENT_SECRET).') if (!c.refreshToken) throw new Error('Amazon Ads API ist nicht verbunden — bitte zuerst "Mit Amazon Ads verbinden" ausführen.') const key = c.clientId + '|' + c.refreshToken.slice(-12) if (accessCache && accessCache.key === key && accessCache.exp > Date.now()) return accessCache.token const res = await fetch(LWA_TOKEN_URL, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: c.refreshToken, client_id: c.clientId, client_secret: c.clientSecret }) }) const json: any = await res.json().catch(() => ({})) if (!res.ok || !json?.access_token) throw new Error(`Amazon LWA refresh failed (${res.status}): ${json?.error_description || json?.error || 'unknown'}`) accessCache = { token: json.access_token, exp: Date.now() + Math.max(60, (Number(json.expires_in) || 3600) - 300) * 1000, key } return json.access_token } /* ------------------------------ raw fetch ------------------------------ */ export const adsFetch = async (event: any, path: string, opts: { method?: string; accept?: string; body?: any; profileId?: string | null; withScope?: boolean } = {}) => { const c = getAdsConfig(event) const token = await getAdsAccessToken(event) const headers: Record = { 'Authorization': `Bearer ${token}`, 'Amazon-Advertising-API-ClientId': c.clientId, 'Accept': opts.accept || 'application/json' } const profileId = opts.profileId ?? c.profileId if (opts.withScope !== false && profileId) headers['Amazon-Advertising-API-Scope'] = String(profileId) if (opts.body) headers['Content-Type'] = opts.accept || 'application/json' const base = API_BASE[c.region] || API_BASE.eu for (let attempt = 0; attempt < 3; attempt++) { const res = await fetch(base + path, { method: opts.method || 'GET', headers, body: opts.body ? JSON.stringify(opts.body) : undefined }) if (res.status === 429 || res.status === 503) { const retry = Number(res.headers.get('retry-after')) || (attempt + 1) * 2 await new Promise(r => setTimeout(r, Math.min(retry, 15) * 1000)) continue } const text = await res.text() let json: any = {} try { json = text ? JSON.parse(text) : {} } catch { json = { raw: text } } if (!res.ok) { const msg = json?.message || json?.details || json?.errors?.[0]?.message || json?.code || text || res.statusText const err: any = new Error(`Amazon Ads API ${res.status}: ${msg}`) err.status = res.status err.ads = json throw err } return json } throw new Error('Amazon Ads API: rate limited (429) — bitte später erneut versuchen.') } /* ------------------------------ profiles ------------------------------ */ export const listAdsProfiles = async (event: any) => { const res: any = await adsFetch(event, '/v2/profiles', { withScope: false }) return (Array.isArray(res) ? res : []).map((p: any) => ({ profileId: String(p.profileId), countryCode: p.countryCode, currencyCode: p.currencyCode, timezone: p.timezone, type: p.accountInfo?.type, name: p.accountInfo?.name, marketplaceId: p.accountInfo?.marketplaceStringId, sellerId: p.accountInfo?.id, label: `${p.countryCode} · ${p.accountInfo?.name || p.accountInfo?.id || ''} (${p.accountInfo?.type || ''}) · ${p.profileId}` })) } /* ------------------------------ invoices ------------------------------ */ const ymd = (s: any): string | null => { if (!s) return null const str = String(s) const m = str.match(/^(\d{4})(\d{2})(\d{2})$/) if (m) return `${m[1]}-${m[2]}-${m[3]}` return str.length >= 10 ? str.slice(0, 10) : null } const amt = (o: any): number => round2(o?.amount ?? o ?? 0) export const listAdsInvoiceSummaries = async (event: any, opts: { statuses?: string[]; startDate?: string | null; endDate?: string | null; max?: number } = {}) => { const out: any[] = [] let cursor: string | null = null const max = opts.max || 1000 do { const q = new URLSearchParams() for (const s of (opts.statuses || [])) q.append('invoiceStatuses', s) if (opts.startDate) q.set('startDate', opts.startDate) if (opts.endDate) q.set('endDate', opts.endDate) if (cursor) q.set('cursor', cursor); else q.set('count', '100') const res: any = await adsFetch(event, `/invoices?${q.toString()}`, { accept: 'application/vnd.invoices.v1.1+json' }) const list: any[] = res?.invoiceSummaries || [] out.push(...list) cursor = res?.nextCursor || res?.cursor || null if (!list.length) break } while (cursor && out.length < max) return out } export const getAdsInvoiceDetail = async (event: any, invoiceId: string) => adsFetch(event, `/invoices/${encodeURIComponent(invoiceId)}`, { accept: 'application/vnd.invoice.v1.1+json' }) /** Console PDF (INVOICE / CREDIT_NOTE) for an invoice → bytes + file name, or null when Amazon has none. */ export const downloadAdsInvoicePdf = async (event: any, invoiceId: string, docType: 'INVOICE' | 'CREDIT_MEMO' = 'INVOICE'): Promise<{ buffer: Buffer; fileName: string } | null> => { const res: any = await adsFetch(event, `/billing/documents/${encodeURIComponent(invoiceId)}?docType=${docType}`, { accept: 'application/vnd.billingDocuments.v1+json' }) const doc = (res?.availableDocuments || []).find((d: any) => d?.storagePath) if (!doc) return null const r = await fetch(doc.storagePath) if (!r.ok) throw new Error(`Amazon Ads document download failed (${r.status})`) const buffer = Buffer.from(await r.arrayBuffer()) return { buffer, fileName: doc.fileName || `INVOICE-${invoiceId}.pdf` } } /** Map an API summary (+ optional detail) to the normalised AdsInvoice. */ export const mapAdsApiInvoice = (summary: any, detail: any | null, countryCode?: string | null): AdsInvoice => { const s = summary?.invoiceSummary || summary || {} const d = detail?.invoice || detail || null const gross = amt(s.amountDue) const tax = amt(s.taxAmountDue) const net = round2(gross - tax) const lines: AdsInvoiceLine[] = ((d?.invoiceLines || []) as any[]).map((l: any) => ({ campaignId: l.campaignId != null ? String(l.campaignId) : null, campaignName: l.campaignName || l.name || 'Kampagne', program: l.programName || null, costEventType: l.costEventType || null, costEventCount: l.costEventCount != null ? Number(l.costEventCount) : null, costPerUnit: l.costPerUnit != null ? Number(l.costPerUnit) : null, amount: amt(l.cost) })) const addr = (ci: any) => ci?.address ? { address1: [ci.address.addressLine1, ci.address.addressLine2].filter(Boolean).join(' ') || undefined, postal: ci.address.postalCode || undefined, city: ci.address.city || undefined, countryCode: ci.address.countryCode || undefined, name: ci.address.name || ci.address.companyName || undefined, vatId: ci.address.taxRegistrationNumber || ci.taxRegistrationNumber || undefined } : null const currency = s.amountDue?.currencyCode || s.remainingAmountDue?.currencyCode || 'EUR' const pm = s.paymentMethod || null return { invoiceNo: String(s.id), invoiceDate: ymd(s.invoiceDate) || ymd(s.toDate) || new Date().toISOString().slice(0, 10), periodFrom: ymd(s.fromDate), periodTo: ymd(s.toDate), dueDate: ymd(s.dueDate), currency, net, tax, gross, taxRate: s.taxRate != null ? Number(s.taxRate) : (net > 0 ? Math.round((tax / net) * 1000) / 10 : null), paymentMethod: pm, paymentMethodLabel: pm ? (PAYMENT_METHOD_LABELS[pm] || pm) : null, status: s.status || null, countryCode: countryCode || (s.fees?.[0]?.feeIdentifiers?.countryCode) || null, issuer: addr(d?.issuerContactInfo), payer: addr(d?.payerContactInfo), lines, source: 'api', documentAvailable: Array.isArray(s.downloadableDocuments) ? s.downloadableDocuments.includes('INVOICE') : true, fileName: `INVOICE-${s.id}.pdf` } }