import { MedusaService } from "@medusajs/framework/utils" import { recordMetricAtomic } from "./utils/metrics" import { paypalFetch } from "./utils/paypal-fetch" import { decryptSecret, encryptSecret } from "./utils/secret-crypto" import PayPalConnection from "./models/paypal_connection" import PayPalMetric from "./models/paypal_metric" import PayPalSettings from "./models/paypal_settings" import PayPalWebhookEvent from "./models/paypal_webhook_event" import { getPayPalConfig } from "./types/config" import { getPayPalApiBase } from "./utils/paypal-auth" import { normalizeCurrencyCode } from "./utils/currencies" type Environment = "sandbox" | "live" type Status = | "disconnected" | "pending" | "pending_credentials" | "connected" | "revoked" const SENSITIVE_KEY_RE = /secret|password|client_secret|access_token|refresh_token|authorization|auth_code|api[_-]?key/i /** * Defensive redaction for audit logs: replace values of keys that look * sensitive with "[REDACTED]". Bounded depth/breadth so a pathological payload * can't blow up logging. */ function redactSensitive(value: unknown, depth = 0): unknown { if (depth > 6 || value === null || typeof value !== "object") { return value } if (Array.isArray(value)) { return value.slice(0, 50).map((v) => redactSensitive(v, depth + 1)) } const out: Record = {} for (const [k, v] of Object.entries(value as Record)) { out[k] = SENSITIVE_KEY_RE.test(k) ? "[REDACTED]" : redactSensitive(v, depth + 1) } return out } /** * Application-container service for the PayPal module. * * Owns the merchant's PayPal connection (partner onboarding, seller * credentials, cached app access token), the plugin settings singleton, * webhook registration/event records, metrics, and audit logging. The payment * providers run in the isolated payment-module container and therefore read * the same tables through `PayPalCredentialResolver` instead of this service. */ class PayPalModuleService extends MedusaService({ PayPalConnection, PayPalMetric, PayPalSettings, PayPalWebhookEvent, }) { protected cfg = getPayPalConfig() private tokenRefreshPromise: Promise | null = null // Raw knex connection for the atomic metric upsert; null when the container // doesn't expose it (recordMetric then falls back to the ORM path). private pgForMetrics: { raw: (sql: string, bindings: unknown[]) => Promise } | null = null // Only probe PayPal for a stale legacy webhook URL once per process per // environment — this runs on admin status reads and must stay cheap. private webhookUrlMigrationChecked: Record = {} private webhookHealScheduled: Record = {} constructor(...args: any[]) { // MedusaService's generated constructor signature varies across framework // versions — pass everything through untouched. super(...args) try { const cradle = (args[0] || {}) as Record const pg = cradle.__pg_connection__ ?? cradle.pgConnection ?? null this.pgForMetrics = pg && typeof pg.raw === "function" ? pg : null } catch { this.pgForMetrics = null } } private get bnCode(): string { return this.cfg.bnCode || "MBJTechnolabs_SI_SPB" } private async getSettingsData() { const settings = await this.getSettings() return (settings?.data || {}) as Record } private async ensureSettingsDefaults() { const data = await this.getSettingsData() const onboarding = { ...(data.onboarding_config || {}) } as Record< string, any > const apiDetails = { ...(data.api_details || {}) } as Record let changed = false if (!onboarding.partner_service_url) { onboarding.partner_service_url = this.cfg.partnerServiceUrl changed = true } if (!onboarding.partner_js_url) { onboarding.partner_js_url = this.cfg.partnerJsUrl changed = true } if (!onboarding.backend_url) { onboarding.backend_url = this.cfg.backendUrl changed = true } if (!onboarding.seller_nonce) { onboarding.seller_nonce = this.cfg.sellerNonce changed = true } if (!onboarding.bn_code && this.cfg.bnCode) { onboarding.bn_code = this.cfg.bnCode changed = true } if (!onboarding.partner_merchant_id_sandbox) { onboarding.partner_merchant_id_sandbox = this.cfg.partnerMerchantIdSandbox changed = true } if (!onboarding.partner_merchant_id_live) { onboarding.partner_merchant_id_live = this.cfg.partnerMerchantIdLive changed = true } if (!apiDetails.currency_code) { const raw = (process.env.PAYPAL_CURRENCY || "").trim() apiDetails.currency_code = raw ? normalizeCurrencyCode(raw) : "EUR" changed = true } if (!apiDetails.storefront_url) { const storeUrl = process.env.STOREFRONT_URL || process.env.STORE_URL if (storeUrl) { apiDetails.storefront_url = storeUrl changed = true } } if (changed) { await this.saveSettings({ onboarding_config: onboarding, api_details: apiDetails, }) } return { onboarding, apiDetails } } /** * Onboarding + API-detail settings with plugin defaults filled in (and * persisted) for any key that is still unset. */ async getApiDetails() { const { onboarding, apiDetails } = await this.ensureSettingsDefaults() return { onboarding, apiDetails, } } private getAlertWebhookUrls() { return (this.cfg.alertWebhookUrls || []) .map((url) => url.trim()) .filter(Boolean) } private async getPartnerMerchantId(env: Environment) { const { onboarding } = await this.ensureSettingsDefaults() return env === "live" ? onboarding.partner_merchant_id_live : onboarding.partner_merchant_id_sandbox } private async getCurrentRow(): Promise { const rows = await this.listPayPalConnections( {}, { take: 1, order: { created_at: "DESC" } } ) return rows?.[0] ?? null } private async getCurrentEnvironment(): Promise { try { const row = await this.getCurrentRow() const env = (row?.environment as Environment) || "live" return env === "sandbox" ? "sandbox" : "live" } catch { return "live" } } private getEnvCreds( row: any, env: Environment ): { clientId?: string clientSecret?: string sellerMerchantId?: string sellerEmail?: string } { const meta = (row?.metadata || {}) as any const creds = meta?.credentials?.[env] || {} return { clientId: creds.client_id || creds.clientId || undefined, // Secrets are encrypted at rest when PAYPAL_ENCRYPTION_KEY is set; decrypt // here so every consumer (auth, status) sees plaintext. Legacy plaintext // values pass through unchanged. clientSecret: decryptSecret(creds.client_secret || creds.clientSecret) || undefined, sellerMerchantId: creds.seller_merchant_id || creds.sellerMerchantId || creds.payer_id || creds.merchant_id || creds.merchantId || undefined, sellerEmail: creds.seller_email || creds.sellerEmail || undefined, } } private extractSellerEmail(...candidates: unknown[]): string | null { const queue = [...candidates] const seen = new WeakSet() const MAX_ITERATIONS = 500 let iterations = 0 while (queue.length > 0 && iterations < MAX_ITERATIONS) { iterations++ const value = queue.shift() if (!value) { continue } if (typeof value === "string") { const trimmed = value.trim() if (trimmed && trimmed.includes("@")) { return trimmed } continue } if (typeof value !== "object") { continue } if (seen.has(value as object)) { continue } seen.add(value as object) if (Array.isArray(value)) { queue.push(...value) continue } const obj = value as Record const prioritized = [ obj.email, obj.primary_email, obj.merchant_email, obj.email_address, obj.account_email, obj.contact_email, obj.value, obj.address, ] queue.push(...prioritized) for (const [k, v] of Object.entries(obj)) { const key = String(k).toLowerCase() if (key.includes("email") || key.includes("address")) { queue.push(v) } } queue.push(...Object.values(obj)) } return null } private async fetchMerchantIntegrationDetails( env: Environment, merchantId: string, accessTokenOverride?: string ) { const partnerMerchantId = await this.getPartnerMerchantId(env) if (!partnerMerchantId) { throw new Error("Missing PayPal partner merchant id configuration.") } const baseUrl = getPayPalApiBase(env) const accessToken = accessTokenOverride ?? (await this.getAppAccessToken()) const resp = await paypalFetch( `${baseUrl}/v1/customer/partners/${encodeURIComponent( partnerMerchantId )}/merchant-integrations/${encodeURIComponent(merchantId)}`, { method: "GET", headers: { "Content-Type": "application/json", Authorization: `Bearer ${accessToken}`, "PayPal-Partner-Attribution-Id": this.bnCode, }, } ) const text = await resp.text().catch(() => "") let json: any = {} try { json = text ? JSON.parse(text) : {} } catch (e: any) { console.warn( "[PayPal] Failed to parse response JSON — using empty object:", e?.message ) } if (!resp.ok) { throw new Error( `PayPal merchant integration lookup failed (${resp.status}): ${text || JSON.stringify(json)}` ) } return json } private async getAppAccessTokenForCredentials( env: Environment, credentials: { clientId: string; clientSecret: string } ) { const baseUrl = getPayPalApiBase(env) const basic = Buffer.from( `${credentials.clientId}:${credentials.clientSecret}` ).toString("base64") const body = new URLSearchParams() body.set("grant_type", "client_credentials") const res = await paypalFetch(`${baseUrl}/v1/oauth2/token`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", Authorization: `Basic ${basic}`, "PayPal-Partner-Attribution-Id": this.bnCode, }, body, }) const text = await res.text().catch(() => "") let json: any = {} try { json = text ? JSON.parse(text) : {} } catch (e: any) { console.warn( "[PayPal] Failed to parse app token response JSON:", e?.message ) } if (!res.ok) { throw new Error( `PayPal client_credentials failed (${res.status}): ${text || JSON.stringify(json)}` ) } const accessToken = String(json.access_token || "") if (!accessToken) { throw new Error( "PayPal client_credentials succeeded but access_token is missing." ) } return { accessToken, tokenPayload: json } } private async fetchSellerProfileFromDirectCredentials( env: Environment, credentials?: { clientId: string; clientSecret: string } ): Promise<{ sellerMerchantId: string | null; sellerEmail: string | null }> { const baseUrl = getPayPalApiBase(env) const partnerMerchantId = await this.getPartnerMerchantId(env) let tokenPayload: Record | null = null let accessToken = "" if (credentials) { const tokenResp = await this.getAppAccessTokenForCredentials(env, { clientId: credentials.clientId, clientSecret: credentials.clientSecret, }) accessToken = tokenResp.accessToken tokenPayload = tokenResp.tokenPayload } else { accessToken = await this.getAppAccessToken() } let sellerEmail = this.extractSellerEmail(tokenPayload || undefined) let sellerMerchantId = String( tokenPayload?.merchant_id || tokenPayload?.payer_id || tokenPayload?.account_id || "" ).trim() || null try { const userInfoResp = await paypalFetch( `${baseUrl}/v1/identity/oauth2/userinfo?schema=paypalv1`, { method: "GET", headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json", Accept: "application/json", "PayPal-Partner-Attribution-Id": this.bnCode, }, } ) if (userInfoResp.ok) { const userInfo = await userInfoResp.json().catch(() => ({})) sellerEmail = sellerEmail || this.extractSellerEmail(userInfo) sellerMerchantId = sellerMerchantId || String( userInfo?.merchant_id || userInfo?.payer_id || userInfo?.user_id || userInfo?.sub || "" ).trim() || null } } catch (e: any) { console.warn("[PayPal] userinfo lookup failed:", e?.message || e) } if (partnerMerchantId) { try { const credsResp = await paypalFetch( `${baseUrl}/v1/customer/partners/${encodeURIComponent( partnerMerchantId )}/merchant-integrations/credentials/`, { method: "GET", headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json", "PayPal-Partner-Attribution-Id": this.bnCode, }, } ) if (credsResp.ok) { const credsJson = await credsResp.json().catch(() => ({})) sellerEmail = sellerEmail || this.extractSellerEmail(credsJson) sellerMerchantId = sellerMerchantId || String(credsJson?.merchant_id || "").trim() || null } } catch (e: any) { console.warn( "[PayPal] direct credential profile lookup failed:", e?.message || e ) } } const hydrated = await this.hydrateSellerMetadataFromCredentials(env, { accessToken, sellerMerchantId, sellerEmail, }) return { sellerMerchantId: hydrated.sellerMerchantId, sellerEmail: hydrated.sellerEmail, } } private async hydrateSellerMetadataFromCredentials( env: Environment, input: { accessToken?: string sellerMerchantId?: string | null sellerEmail?: string | null metadataCandidates?: any[] } ): Promise<{ sellerMerchantId: string | null; sellerEmail: string | null }> { let sellerMerchantId = (input.sellerMerchantId || "").trim() || null let sellerEmail = (input.sellerEmail || "").trim() || null if (!sellerEmail && input.metadataCandidates?.length) { sellerEmail = this.extractSellerEmail(...input.metadataCandidates) } if (sellerMerchantId && !sellerEmail) { try { const details = await this.fetchMerchantIntegrationDetails( env, sellerMerchantId, input.accessToken ) sellerEmail = this.extractSellerEmail(details) } catch (e: any) { console.warn( "[PayPal] merchant integration lookup failed:", e?.message || e ) } } return { sellerMerchantId, sellerEmail } } private async syncRowFieldsFromMetadata(row: any, env: Environment) { const c = this.getEnvCreds(row, env) await this.updatePayPalConnections({ id: row.id, status: c.clientId && c.clientSecret ? "connected" : "disconnected", seller_client_id: c.clientId || null, // c.clientSecret is decrypted (from getEnvCreds); re-encrypt for the column. seller_client_secret: encryptSecret(c.clientSecret) || null, seller_merchant_id: c.sellerMerchantId || null, seller_email: c.sellerEmail || null, metadata: { ...(row.metadata || {}), active_environment: env, }, }) } /** * Switch the active environment (`sandbox` | `live`). Credentials are stored * per environment, so switching never discards the other environment's * credentials — it only changes which set is active and clears the cached * app token. */ async setEnvironment(env: Environment) { const nextEnv: Environment = env === "sandbox" ? "sandbox" : "live" const row = await this.getCurrentRow() const previousEnv = (row?.environment as Environment) || "live" if (!row) { const created = await this.createPayPalConnections({ environment: nextEnv, status: "disconnected", shared_id: null, auth_code: null, seller_client_id: null, seller_client_secret: null, seller_merchant_id: null, seller_email: null, app_access_token: null, app_access_token_expires_at: null, metadata: { credentials: {}, active_environment: nextEnv }, }) await this.recordAuditEvent("environment_switched", { previous_environment: previousEnv, environment: nextEnv, }) return created } await this.updatePayPalConnections({ id: row.id, environment: nextEnv, app_access_token: null, app_access_token_expires_at: null, metadata: { ...(row.metadata || {}), active_environment: nextEnv, }, }) const updated = await this.getCurrentRow() if (updated) { await this.syncRowFieldsFromMetadata(updated, nextEnv) } await this.recordAuditEvent("environment_switched", { previous_environment: previousEnv, environment: nextEnv, }) return await this.getCurrentRow() } /** * Request a PayPal partner-referral onboarding URL from the partner service. * Returns the URL to open in PayPal's mini-browser plus the `return_url` the * popup lands on afterwards. */ async createOnboardingLink(input?: { email?: string products?: string[] env?: Environment }) { const { onboarding } = await this.ensureSettingsDefaults() // Honor an explicit environment from the caller so the generated link can't // depend on a racing "set environment" request having landed first. const env = input?.env === "sandbox" || input?.env === "live" ? input.env : await this.getCurrentEnvironment() // The popup lands here after onboarding, as a plain top-level navigation with // no auth token — so it must be the PUBLIC store bridge route, not an // /admin/* route (which would 401 and leave the popup stuck). The bridge // exchanges the auth code for seller credentials server-side, relays the // result to the opener, and closes the popup. We pin the environment in the // URL so the bridge saves credentials under the correct env even though // PayPal's redirect carries no auth/session. // Use the /hooks namespace: Medusa's publishable-key middleware rejects // unauthenticated top-level navigations to /store/* routes, so the popup // would land on a NOT_ALLOWED error page instead of the bridge. const return_url = `${String(onboarding.backend_url || "").replace(/\/$/, "")}/hooks/paypal/onboard-return?env=${env}` const partner_merchant_id = await this.getPartnerMerchantId(env) const email = (input?.email || "").trim() if (!partner_merchant_id) { throw new Error( "Missing PAYPAL_PARTNER_MERCHANT_ID_* env for current environment" ) } const form = new URLSearchParams() if (email) { form.set("email", email) } form.set("sandbox", env === "live" ? "no" : "yes") form.set("return_url", return_url) form.set("return_url_description", "Return to your shop.") form.set("partner_merchant_id", partner_merchant_id) form.set("from", "medusa") const products = input?.products?.length ? input.products : ["PPCP"] products.forEach((p) => { form.append("products[]", p) }) const res = await paypalFetch(onboarding.partner_service_url, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: form.toString(), }) const text = await res.text().catch(() => "") if (!res.ok) { throw new Error(`Onboarding service failed (${res.status}): ${text}`) } const trimmed = text.trim() if (trimmed.startsWith("http")) { return { onboarding_url: trimmed, return_url } } let json: any try { json = JSON.parse(trimmed) } catch { throw new Error( `Invalid onboarding link response (not JSON / URL): ${trimmed.slice(0, 200)}` ) } if (json?.body) { const inner = typeof json.body === "string" ? json.body.trim() : json.body if (typeof inner === "string" && inner.startsWith("http")) { return { onboarding_url: inner, return_url } } try { json = typeof inner === "string" ? JSON.parse(inner) : inner } catch { throw new Error( `Onboarding wrapper JSON 'body' is not valid JSON / URL: ${ typeof inner === "string" ? inner.slice(0, 200) : "[object]" }` ) } } if ( json?.name && json?.message && (json?.debug_id || json?.details || json?.links) ) { const debug = json.debug_id ? ` debug_id=${json.debug_id}` : "" const details = Array.isArray(json.details) ? json.details .slice(0, 3) .map((d: any) => { const issue = d?.issue ? String(d.issue) : "" const desc = d?.description ? String(d.description) : "" const field = d?.field ? String(d.field) : "" return [issue, desc, field].filter(Boolean).join(" | ") }) .filter(Boolean) .join("; ") : "" throw new Error( `PayPal onboarding error: ${json.name}: ${json.message}.${debug}${details ? ` Details: ${details}` : ""}` ) } if ( json?.onboarding_url && String(json.onboarding_url).startsWith("http") ) { return { onboarding_url: String(json.onboarding_url), return_url } } const links = Array.isArray(json?.links) ? json.links : null if (links) { const action = links.find( (l: any) => l?.rel === "action_url" || l?.rel === "actionUrl" || l?.rel === "action-url" ) const href = action?.href ? String(action.href) : null if (href && href.startsWith("http")) { return { onboarding_url: href, return_url } } } throw new Error( `Onboarding JSON missing action_url link. Keys: ${Object.keys(json || {}).join(", ")}` ) } /** Mark the connection as onboarding-in-progress (creates the row if needed). */ async startOnboarding() { const row = await this.getCurrentRow() const env = await this.getCurrentEnvironment() if (row) { await this.updatePayPalConnections({ id: row.id, status: "pending" }) return } await this.createPayPalConnections({ environment: env, status: "pending", metadata: {}, }) } /** Persist the `authCode`/`sharedId` PayPal hands back after onboarding. */ async saveOnboardCallback(input: { authCode: string; sharedId: string }) { const row = await this.getCurrentRow() const env = await this.getCurrentEnvironment() if (!row) { return await this.createPayPalConnections({ environment: env, status: "pending_credentials", auth_code: input.authCode, shared_id: input.sharedId, metadata: {}, }) } return await this.updatePayPalConnections({ id: row.id, status: "pending_credentials", auth_code: input.authCode, shared_id: input.sharedId, }) } /** * Complete partner onboarding: exchange the single-use `authCode` for a * seller access token, fetch the seller's REST credentials, and store them. * Idempotent — a repeated exchange of an already-consumed code is a no-op * when credentials for that environment are already saved. */ async exchangeAndSaveSellerCredentials(input: { authCode: string sharedId: string env?: "sandbox" | "live" }) { const env = (input.env || (await this.getCurrentEnvironment())) as Environment // Onboarding completion can now arrive via several redundant paths // (partner.js's onboardingCallback in the opener, the return_url bridge that // runs inside the popup, and admin status polling). PayPal's authorization // code is single-use, so a second exchange of the same code would fail with // invalid_grant. If we already exchanged this exact code and hold seller // credentials for this environment, treat the duplicate as a success no-op. const existingRow = await this.getCurrentRow() if (existingRow && existingRow.auth_code === input.authCode) { const existingCreds = this.getEnvCreds(existingRow, env) if (existingCreds.clientId && existingCreds.clientSecret) { return } } await this.saveOnboardCallback({ authCode: input.authCode, sharedId: input.sharedId, }) const baseUrl = getPayPalApiBase(env) const { onboarding } = await this.ensureSettingsDefaults() const sellerNonce = (onboarding.seller_nonce || "").trim() if (!sellerNonce) { throw new Error( "PayPal seller nonce is not configured. Set PAYPAL_SELLER_NONCE." ) } const tokenBody = new URLSearchParams() tokenBody.set("grant_type", "authorization_code") tokenBody.set("code", input.authCode) tokenBody.set("code_verifier", sellerNonce) const basic = Buffer.from(`${input.sharedId}:`).toString("base64") const tokenRes = await paypalFetch(`${baseUrl}/v1/oauth2/token`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", Authorization: `Basic ${basic}`, "PayPal-Partner-Attribution-Id": this.bnCode, }, body: tokenBody, }) const tokenText = await tokenRes.text().catch(() => "") let tokenJson: any = {} try { tokenJson = tokenText ? JSON.parse(tokenText) : {} } catch (e: any) { console.warn("[PayPal] Failed to parse token response JSON:", e?.message) } if (!tokenRes.ok) { // Two redundant completion paths can race to exchange the same single-use // code; the loser gets invalid_grant. If credentials were saved in the // meantime (the other path won), the onboarding actually succeeded. const racedRow = await this.getCurrentRow() if (racedRow) { const racedCreds = this.getEnvCreds(racedRow, env) if (racedCreds.clientId && racedCreds.clientSecret) { return } } throw new Error( `PayPal authorization_code token exchange failed (${tokenRes.status}): ${tokenText || JSON.stringify(tokenJson)}` ) } const sellerAccessToken = String(tokenJson.access_token || "") if (!sellerAccessToken) { throw new Error( "PayPal token exchange succeeded but access_token is missing." ) } const partnerMerchantId = await this.getPartnerMerchantId(env) if (!partnerMerchantId) { throw new Error("Missing PayPal partner merchant id configuration.") } const credRes = await paypalFetch( `${baseUrl}/v1/customer/partners/${encodeURIComponent(partnerMerchantId)}/merchant-integrations/credentials/`, { method: "GET", headers: { "Content-Type": "application/json", Authorization: `Bearer ${sellerAccessToken}`, "PayPal-Partner-Attribution-Id": this.bnCode, }, } ) const credText = await credRes.text().catch(() => "") let credJson: any = {} try { credJson = credText ? JSON.parse(credText) : {} } catch (e: any) { console.warn( "[PayPal] Failed to parse credentials response JSON:", e?.message ) } if (!credRes.ok) { throw new Error( `PayPal credentials fetch failed (${credRes.status}): ${credText || JSON.stringify(credJson)}` ) } const clientId = String(credJson.client_id || credJson.clientId || "") const clientSecret = String( credJson.client_secret || credJson.clientSecret || "" ) if (!clientId || !clientSecret) { throw new Error( `PayPal credentials response missing client_id/client_secret. Keys: ${Object.keys(credJson || {}).join(", ")}` ) } let sellerEmail = this.extractSellerEmail(credJson, tokenJson) let sellerMerchantId = String( credJson.payer_id || credJson.merchant_id || tokenJson.payer_id || tokenJson.merchant_id || "" ).trim() || null if (!sellerEmail) { const merchantCandidates = [ String(credJson.payer_id || "").trim(), String(credJson.merchant_id || "").trim(), String(tokenJson.payer_id || "").trim(), String(tokenJson.merchant_id || "").trim(), ] .filter(Boolean) .filter((v, i, arr) => arr.indexOf(v) === i) for (const merchantId of merchantCandidates) { try { const merchantDetails = await this.fetchMerchantIntegrationDetails( env, merchantId, sellerAccessToken ) sellerMerchantId = sellerMerchantId || merchantId sellerEmail = this.extractSellerEmail(merchantDetails) if (sellerEmail) { break } } catch (e: any) { console.warn( `[PayPal] Merchant integration lookup for ${merchantId} failed:`, e?.message || e ) } } } await this.saveSellerCredentials({ clientId, clientSecret, sellerMerchantId, sellerEmail, }) if (!sellerEmail && sellerMerchantId) { try { const appAccessToken = await this.getAppAccessToken() const details = await this.fetchMerchantIntegrationDetails( env, sellerMerchantId, appAccessToken ) sellerEmail = this.extractSellerEmail(details) if (sellerEmail) { await this.saveSellerCredentials({ clientId, clientSecret, sellerMerchantId, sellerEmail, }) } } catch (e: any) { console.warn( "[PayPal] Post-save merchant_id email lookup failed:", e?.message || e ) } } } /** * Store seller REST credentials for an environment (encrypted at rest when * `PAYPAL_ENCRYPTION_KEY` is set) and make sure a webhook is registered. */ async saveSellerCredentials(input: { clientId: string clientSecret: string sellerMerchantId?: string | null sellerEmail?: string | null environment?: Environment }) { const row = await this.getCurrentRow() const currentEnv = await this.getCurrentEnvironment() const env = (input.environment || currentEnv) as Environment const existingCreds = row ? this.getEnvCreds(row, env) : {} const nextSellerMerchantId = (input.sellerMerchantId || "").trim() || existingCreds.sellerMerchantId || row?.seller_merchant_id || null const nextSellerEmail = (input.sellerEmail || "").trim() || existingCreds.sellerEmail || row?.seller_email || null // Encrypt once and store the same ciphertext in both the metadata copy and // the denormalized column (no-op when encryption is disabled). const storedClientSecret = encryptSecret(input.clientSecret) const nextCreds = { client_id: input.clientId, client_secret: storedClientSecret, merchant_id: nextSellerMerchantId, seller_merchant_id: nextSellerMerchantId, seller_email: nextSellerEmail, } if (!row) { const created = await this.createPayPalConnections({ environment: env, status: "connected", seller_client_id: input.clientId, seller_client_secret: storedClientSecret, seller_merchant_id: nextSellerMerchantId, seller_email: nextSellerEmail, app_access_token: null, app_access_token_expires_at: null, metadata: { credentials: { [env]: nextCreds, }, active_environment: env, }, }) await this.recordAuditEvent("credentials_saved", { environment: env, client_id: input.clientId, }) await this.ensureWebhookRegistration() return created } const meta = (row.metadata || {}) as any const creds = { ...(meta.credentials || {}) } creds[env] = { ...(creds[env] || {}), ...nextCreds, } const updated = await this.updatePayPalConnections({ id: row.id, status: "connected", seller_client_id: input.clientId, seller_client_secret: storedClientSecret, seller_merchant_id: nextSellerMerchantId, seller_email: nextSellerEmail, app_access_token: null, app_access_token_expires_at: null, metadata: { ...(row.metadata || {}), credentials: creds, active_environment: env, }, }) await this.recordAuditEvent("credentials_saved", { environment: env, client_id: input.clientId, }) await this.ensureWebhookRegistration() return updated } /** * Save manually-entered credentials, then best-effort look up the seller's * merchant id / email from PayPal so the admin UI can display them. */ async saveAndHydrateSellerCredentials(input: { clientId: string clientSecret: string environment?: Environment }) { const env = (input.environment || (await this.getCurrentEnvironment())) as Environment await this.saveSellerCredentials({ clientId: input.clientId, clientSecret: input.clientSecret, environment: env, }) try { const hydrated = await this.fetchSellerProfileFromDirectCredentials(env, { clientId: input.clientId, clientSecret: input.clientSecret, }) if (hydrated.sellerEmail || hydrated.sellerMerchantId) { await this.saveSellerCredentials({ clientId: input.clientId, clientSecret: input.clientSecret, sellerMerchantId: hydrated.sellerMerchantId, sellerEmail: hydrated.sellerEmail, environment: env, }) } const refreshedRow = await this.getCurrentRow() if (refreshedRow) { await this.syncRowFieldsFromMetadata(refreshedRow, env) } } catch (e: any) { console.warn( "[PayPal] saveAndHydrateSellerCredentials lookup failed:", e?.message || e ) } // This is a write context (credentials were just saved), so it is allowed to // backfill the seller profile if it is still missing. return await this.getStatus(env, { hydrateMissingProfile: true }) } private async resolveWebhookUrl() { const { onboarding } = await this.ensureSettingsDefaults() const base = String(onboarding.backend_url || "").replace(/\/$/, "") if (!base) { throw new Error("PayPal backend URL is not configured.") } // The /hooks namespace has no publishable-key guard. PayPal cannot send // Medusa's x-publishable-api-key header, so a webhook registered at the // old /store/paypal/webhook path is rejected before the handler runs. return `${base}/hooks/paypal/webhook` } /** Legacy webhook path (blocked by Medusa's store publishable-key guard). */ private async resolveLegacyWebhookUrl() { const { onboarding } = await this.ensureSettingsDefaults() const base = String(onboarding.backend_url || "").replace(/\/$/, "") if (!base) return "" return `${base}/store/paypal/webhook` } private isLocalWebhookUrl(url: string) { try { const parsed = new URL(url) return ["localhost", "127.0.0.1", "::1"].includes(parsed.hostname) } catch { return false } } private async migrateLegacyWebhookUrl(env: string, webhookId: string) { if (this.webhookUrlMigrationChecked[env]) return this.webhookUrlMigrationChecked[env] = true const newUrl = await this.resolveWebhookUrl().catch(() => "") const legacyUrl = await this.resolveLegacyWebhookUrl() if (!newUrl || !legacyUrl || this.isLocalWebhookUrl(newUrl)) return const accessToken = await this.getAppAccessToken() const baseUrl = getPayPalApiBase(env) const getResp = await paypalFetch( `${baseUrl}/v1/notifications/webhooks/${encodeURIComponent(webhookId)}`, { headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json", "PayPal-Partner-Attribution-Id": this.bnCode, }, } ) const getJson = await getResp.json().catch(() => ({})) if (!getResp.ok) return const currentUrl = String(getJson?.url || "") if (currentUrl !== legacyUrl) return const patchResp = await paypalFetch( `${baseUrl}/v1/notifications/webhooks/${encodeURIComponent(webhookId)}`, { method: "PATCH", headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json", "PayPal-Partner-Attribution-Id": this.bnCode, }, body: JSON.stringify([{ op: "replace", path: "/url", value: newUrl }]), } ) if (patchResp.ok) { await this.recordAuditEvent("webhook_url_migrated", { environment: env, webhook_id: webhookId, from: legacyUrl, to: newUrl, }) console.info( `[PayPal] migrated webhook ${webhookId} URL from ${legacyUrl} to ${newUrl}` ) } } private async ensureWebhookRegistration() { const env = await this.getCurrentEnvironment() const { apiDetails } = await this.ensureSettingsDefaults() const webhookIds = { ...(apiDetails.webhook_ids || {}) } as Record< string, string > if (webhookIds[env]) { // Existing installs registered the webhook at /store/paypal/webhook, // which Medusa's publishable-key middleware blocks for external callers. // Migrate the registered URL to /hooks/paypal/webhook in place // (best-effort — a failure keeps the stored id and current behavior). await this.migrateLegacyWebhookUrl(env, webhookIds[env]).catch( (e: any) => { console.warn( "[PayPal] webhook URL migration check failed:", e?.message || e ) } ) return webhookIds[env] } const accessToken = await this.getAppAccessToken() const baseUrl = getPayPalApiBase(env) const webhookUrl = await this.resolveWebhookUrl() if (this.isLocalWebhookUrl(webhookUrl)) { await this.recordAuditEvent("webhook_skipped_localhost", { environment: env, webhook_url: webhookUrl, }) return webhookIds[env] || "" } const listResp = await paypalFetch(`${baseUrl}/v1/notifications/webhooks`, { headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json", "PayPal-Partner-Attribution-Id": this.bnCode, }, }) const listJson = await listResp.json().catch(() => ({})) if (!listResp.ok) { throw new Error( `PayPal webhook list failed (${listResp.status}): ${JSON.stringify(listJson)}` ) } const existing = Array.isArray(listJson?.webhooks) ? listJson.webhooks.find((hook: any) => hook?.url === webhookUrl) : null let webhookId = existing?.id ? String(existing.id) : "" if (!webhookId) { const createResp = await paypalFetch( `${baseUrl}/v1/notifications/webhooks`, { method: "POST", headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json", "PayPal-Partner-Attribution-Id": this.bnCode, }, body: JSON.stringify({ url: webhookUrl, event_types: [ { name: "CHECKOUT.ORDER.APPROVED" }, { name: "CHECKOUT.ORDER.CANCELLED" }, { name: "PAYMENT.CAPTURE.COMPLETED" }, { name: "PAYMENT.CAPTURE.DENIED" }, { name: "PAYMENT.CAPTURE.REFUNDED" }, { name: "PAYMENT.CAPTURE.REVERSED" }, { name: "PAYMENT.AUTHORIZATION.CREATED" }, { name: "PAYMENT.AUTHORIZATION.VOIDED" }, { name: "PAYMENT.AUTHORIZATION.DENIED" }, { name: "PAYMENT.REFUND.COMPLETED" }, { name: "PAYMENT.REFUND.DENIED" }, ], }), } ) const createJson = await createResp.json().catch(() => ({})) if (!createResp.ok) { throw new Error( `PayPal webhook create failed (${createResp.status}): ${JSON.stringify(createJson)}` ) } webhookId = String(createJson?.id || "") } if (!webhookId) { throw new Error("PayPal webhook registration did not return an id") } const nextWebhookIds = { ...webhookIds, [env]: webhookId } await this.saveSettings({ api_details: { ...apiDetails, webhook_ids: nextWebhookIds, }, }) await this.recordAuditEvent("webhook_registered", { environment: env, webhook_id: webhookId, webhook_url: webhookUrl, }) return webhookId } private maskValue(value?: string | null, visibleChars = 4) { if (!value) return null const trimmed = String(value) if (trimmed.length <= visibleChars) { return "•".repeat(trimmed.length) } return `${"•".repeat(Math.max(0, trimmed.length - visibleChars))}${trimmed.slice( -visibleChars )}` } /** * Report the current connection status. * * `hydrateMissingProfile` is opt-in and OFF by default: status is read on * `GET /admin/paypal/status` (and other read-only routes), where it must be a * safe, side-effect-free read. The seller-profile backfill makes an outbound * PayPal call and persists to the DB, so it only runs from write contexts * (e.g. saving credentials) that explicitly request it. */ async getStatus( envOverride?: Environment, opts: { hydrateMissingProfile?: boolean } = {} ) { const row = await this.getCurrentRow() const env = envOverride ?? (await this.getCurrentEnvironment()) if (!row) { return { environment: env, status: "disconnected" as Status, seller_client_id_present: false, } } const c = this.getEnvCreds(row, env) const hasCreds = !!(c.clientId && c.clientSecret) let sellerEmail: string | null = c.sellerEmail || row.seller_email || null let sellerMerchantId: string | null = c.sellerMerchantId || row.seller_merchant_id || null // Self-heal the webhook registration for already-connected merchants: // installs that registered the webhook at the old (blocked) // /store/paypal/webhook path get migrated to /hooks/paypal/webhook the // first time an admin views the status page after upgrading. Fire and // forget — status reads must never block on PayPal, and the migration is // throttled to once per process per environment internally. if (hasCreds && !this.webhookHealScheduled[env]) { this.webhookHealScheduled[env] = true void this.ensureWebhookRegistration().catch((e: any) => { console.warn("[PayPal] webhook self-heal failed:", e?.message || e) }) } if (!sellerEmail && hasCreds && opts.hydrateMissingProfile) { try { const hydrated = await this.fetchSellerProfileFromDirectCredentials(env) if (hydrated.sellerEmail || hydrated.sellerMerchantId) { await this.saveSellerCredentials({ clientId: c.clientId!, clientSecret: c.clientSecret!, sellerMerchantId: hydrated.sellerMerchantId || sellerMerchantId, sellerEmail: hydrated.sellerEmail || sellerEmail, environment: env, }) const refreshedRow = await this.getCurrentRow() if (refreshedRow) { const refreshedCreds = this.getEnvCreds(refreshedRow, env) sellerEmail = refreshedCreds.sellerEmail || refreshedRow.seller_email || sellerEmail sellerMerchantId = refreshedCreds.sellerMerchantId || refreshedRow.seller_merchant_id || sellerMerchantId } } } catch (e: any) { console.warn( "[PayPal] status direct credential lookup failed:", e?.message || e ) } } return { environment: env, status: (hasCreds ? "connected" : "disconnected") as Status, shared_id: row.shared_id ?? null, auth_code: row.auth_code ? "***stored***" : null, seller_client_id_present: hasCreds, seller_client_id_masked: this.maskValue(c.clientId), seller_client_secret_masked: c.clientSecret ? "••••••••" : null, seller_merchant_id: sellerMerchantId, seller_email: sellerEmail, updated_at: (row.updated_at as any)?.toISOString?.() ?? null, } } /** * Remove the active environment's credentials. The other environment (if * connected) is untouched, so the row stays "connected" when it still holds * a usable credential set. */ async disconnect() { const row = await this.getCurrentRow() if (!row) return const env = await this.getCurrentEnvironment() const meta = (row.metadata || {}) as any const creds = { ...(meta.credentials || {}) } delete creds[env] const hasAnyCreds = Object.values(creds).some((v: any) => { return ( v && typeof v === "object" && (v as any).client_id && (v as any).client_secret ) }) await this.updatePayPalConnections({ id: row.id, status: hasAnyCreds ? "connected" : "disconnected", shared_id: null, auth_code: null, seller_client_id: null, seller_client_secret: null, seller_merchant_id: null, seller_email: null, app_access_token: null, app_access_token_expires_at: null, metadata: { ...(row.metadata || {}), credentials: creds, active_environment: env, }, }) const updated = await this.getCurrentRow() if (updated) { await this.syncRowFieldsFromMetadata(updated, env) } await this.recordAuditEvent("disconnected", { environment: env }) } /** * Cached client-credentials access token for the active environment. * Refreshes single-flight when the stored token is within two minutes of * expiry so concurrent callers never race to mint duplicate tokens. */ async getAppAccessToken(): Promise { const row = await this.getCurrentRow() if (!row) { throw new Error( "PayPal connection row not found. Please complete onboarding." ) } const expiresAt = row.app_access_token_expires_at ? new Date(row.app_access_token_expires_at as string) : null if (row.app_access_token && expiresAt) { const msLeft = expiresAt.getTime() - Date.now() // Token is encrypted at rest when a key is configured; decrypt the cached // value (legacy plaintext passes through unchanged). if (msLeft > 2 * 60 * 1000) return decryptSecret(row.app_access_token as string) as string } if (this.tokenRefreshPromise) { return this.tokenRefreshPromise } this.tokenRefreshPromise = this.refreshAccessToken(row).finally(() => { this.tokenRefreshPromise = null }) return this.tokenRefreshPromise } private async refreshAccessToken(row: { id: string }): Promise { const env = await this.getCurrentEnvironment() const creds = await this.getActiveCredentials() const baseUrl = getPayPalApiBase(env) const basic = Buffer.from( `${creds.client_id}:${creds.client_secret}` ).toString("base64") const body = new URLSearchParams() body.set("grant_type", "client_credentials") const res = await paypalFetch(`${baseUrl}/v1/oauth2/token`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", Authorization: `Basic ${basic}`, "PayPal-Partner-Attribution-Id": this.bnCode, }, body, signal: AbortSignal.timeout(30_000), }) const json = await res.json().catch(() => ({})) if (!res.ok) throw new Error( `PayPal client_credentials failed (${res.status}): ${JSON.stringify(json)}` ) const accessToken = String(json.access_token || "") if (!accessToken) { throw new Error( "PayPal client_credentials succeeded but access_token is missing." ) } const expiresIn = Number(json.expires_in || 3600) const newExpiresAt = new Date(Date.now() + expiresIn * 1000) await this.updatePayPalConnections({ id: row.id, app_access_token: encryptSecret(accessToken), app_access_token_expires_at: newExpiresAt as unknown as Date, }) return accessToken } /** Mint a browser `client_token` (required by the hosted card fields SDK). */ async generateClientToken(opts?: { locale?: string }): Promise { const env = await this.getCurrentEnvironment() const baseUrl = getPayPalApiBase(env) const accessToken = await this.getAppAccessToken() const res = await paypalFetch(`${baseUrl}/v1/identity/generate-token`, { method: "POST", headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json", Accept: "application/json", "PayPal-Partner-Attribution-Id": this.bnCode, ...(opts?.locale ? { "Accept-Language": opts.locale } : {}), }, }) const json = await res.json().catch(() => ({})) if (!res.ok) { throw new Error( `PayPal generate-token failed (${res.status}): ${JSON.stringify(json)}` ) } const token = String((json as any)?.client_token || "") if (!token) { throw new Error( "PayPal client_token is missing in generate-token response" ) } return token } /** The plugin settings singleton (`{ data }`), never null. */ async getSettings() { // Deterministic singleton read: always the OLDEST row. If a first-boot race // ever created a duplicate settings row, every reader (here and the // credential resolver's raw read) must agree on the same winner — ordering // by created_at ASC guarantees that; an unordered `take: 1` does not. const rows = await this.listPayPalSettings( {}, { take: 1, order: { created_at: "ASC" } } ) const row = rows?.[0] return { data: (row?.data || {}) as Record } } private deepMerge( target: Record, source: Record ): Record { const result = { ...target } for (const key of Object.keys(source)) { const sv = source[key] const tv = target[key] if ( sv !== null && typeof sv === "object" && !Array.isArray(sv) && tv !== null && typeof tv === "object" && !Array.isArray(tv) ) { result[key] = this.deepMerge(tv, sv) } else { result[key] = sv } } return result } /** Deep-merge `patch` into the settings singleton and return the result. */ async saveSettings(patch: Record) { // Same deterministic ordering as getSettings so reads and writes always // target the same singleton row. const rows = await this.listPayPalSettings( {}, { take: 1, order: { created_at: "ASC" } } ) const row = rows?.[0] const current = (row?.data || {}) as Record const next = this.deepMerge(current, patch) if (!row) { const created = await this.createPayPalSettings({ data: next }) // First-boot race guard: if a concurrent save created a row a moment // earlier, the ASC ordering means that row is the canonical singleton — // merge this patch into it instead of leaving a divergent duplicate. const recheck = await this.listPayPalSettings( {}, { take: 1, order: { created_at: "ASC" } } ) const canonical = recheck?.[0] if (canonical && canonical.id !== created.id) { const merged = this.deepMerge( (canonical.data || {}) as Record, patch ) await this.updatePayPalSettings({ id: canonical.id, data: merged }) return { data: merged } } return { data: (created.data || {}) as Record } } await this.updatePayPalSettings({ id: row.id, data: next }) return { data: next } } /** Decrypted REST credentials for the active environment; throws if unset. */ async getActiveCredentials() { const row = await this.getCurrentRow() const env = await this.getCurrentEnvironment() if (!row) { throw new Error( "PayPal connection row not found. Please complete onboarding." ) } const c = this.getEnvCreds(row, env) const clientSecret = c.clientSecret || "" if (!c.clientId || !clientSecret) { throw new Error( `PayPal credentials missing for environment "${env}". Please save credentials.` ) } return { environment: env, client_id: c.clientId, client_secret: clientSecret, } } /** Fetch a PayPal order (`GET /v2/checkout/orders/{id}`). */ async getOrderDetails(orderId: string) { if (!orderId) { throw new Error("PayPal orderId is required") } const env = await this.getCurrentEnvironment() const base = getPayPalApiBase(env) const accessToken = await this.getAppAccessToken() const resp = await paypalFetch( `${base}/v2/checkout/orders/${encodeURIComponent(orderId)}`, { method: "GET", headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json", "PayPal-Partner-Attribution-Id": this.bnCode, }, signal: AbortSignal.timeout(30_000), } ) const text = await resp.text() if (!resp.ok) { throw new Error(`PayPal get order error (${resp.status}): ${text}`) } return JSON.parse(text) } /** * Insert a webhook event row. Returns `{ created: false, event }` instead of * throwing when `event_id` already exists, so the webhook route can treat a * redelivery as a duplicate. */ async createWebhookEventRecord(input: { event_id: string event_type: string resource_id?: string | null payload?: Record event_version?: string | null transmission_id?: string | null transmission_time?: Date | null status?: string attempt_count?: number }) { try { const created = await this.createPayPalWebhookEvents({ event_id: input.event_id, event_type: input.event_type, resource_id: input.resource_id ?? null, payload: input.payload ?? {}, event_version: input.event_version ?? null, transmission_id: input.transmission_id ?? null, transmission_time: input.transmission_time ?? null, status: input.status ?? "pending", attempt_count: input.attempt_count ?? 0, next_retry_at: null, processed_at: null, last_error: null, }) return { created: true, event: created } } catch (error: any) { const message = String(error?.message || "") const code = String(error?.code || "") if ( message.includes("paypal_webhook_event_event_id_unique") || code === "23505" || (message.includes("unique") && message.includes("event_id")) ) { const existing = await this.listPayPalWebhookEvents({ event_id: input.event_id, }) return { created: false, event: existing?.[0] ?? null } } throw error } } /** Update processing state on a webhook event row. */ async updateWebhookEventRecord(input: { id: string status?: string attempt_count?: number next_retry_at?: Date | null processed_at?: Date | null last_error?: string | null resource_id?: string | null }) { return await this.updatePayPalWebhookEvents({ id: input.id, status: input.status, attempt_count: input.attempt_count, next_retry_at: input.next_retry_at ?? null, processed_at: input.processed_at ?? null, last_error: input.last_error ?? null, resource_id: input.resource_id ?? null, }) } /** * Emit an audit event as a single structured log line. * * The dedicated audit-log table was removed; for high-volume/containerized * deployments the audit trail lives in aggregated stdout logs. This records a * greppable `paypal_audit` line (filter on `"log":"paypal_audit"`) with * sensitive keys redacted. It never throws — audit logging must not break the * caller's payment flow. */ async recordAuditEvent( eventType: string, metadata?: Record ) { try { console.info( JSON.stringify({ log: "paypal_audit", event_type: eventType, at: new Date().toISOString(), ...(metadata ? { metadata: redactSensitive(metadata) } : {}), }) ) } catch { // never let audit logging interfere with the payment flow } return null } /** * Increment a named counter in `paypal_metric`, merging any metadata. * Best-effort — never throws. */ async recordMetric(name: string, metadata?: Record) { // Preferred path: single atomic INSERT ... ON CONFLICT (same statement the // credential resolver uses), so concurrent routes/webhooks never lose // increments to a read-modify-write race. if (this.pgForMetrics) { await recordMetricAtomic(this.pgForMetrics, name, metadata) return null } // Fallback (container without a raw pg connection): the previous ORM // read-modify-write. Best-effort only — never throws. try { const existing = await this.listPayPalMetrics({ name }) const row = existing?.[0] const current = (row?.data || {}) as Record const next = { ...current, ...(metadata || {}), count: Number(current.count || 0) + 1, last_recorded_at: new Date().toISOString(), } if (!row) { try { return await this.createPayPalMetrics({ name, data: next }) } catch { const retry = await this.listPayPalMetrics({ name }) const retryRow = retry?.[0] if (retryRow) { const retryData = (retryRow.data || {}) as Record return await this.updatePayPalMetrics({ id: retryRow.id, name, data: { ...retryData, ...(metadata || {}), count: Number(retryData.count || 0) + 1, last_recorded_at: new Date().toISOString(), }, }) } throw new Error(`Failed to record metric "${name}"`) } } return await this.updatePayPalMetrics({ id: row.id, name, data: next }) } catch (e: unknown) { console.warn( "[PayPal] recordMetric failed:", e instanceof Error ? e.message : e ) return null } } /** Audit-log a payment event under the `payment_` prefix. */ async recordPaymentLog( eventType: string, metadata?: Record ) { return await this.recordAuditEvent(`payment_${eventType}`, metadata) } /** * POST an operational alert to every configured `PAYPAL_ALERT_WEBHOOK_URLS` * endpoint. Delivery results are audit-logged; failures never throw. */ async sendAlert(input: { type: string message: string metadata?: Record }) { const urls = this.getAlertWebhookUrls() if (urls.length === 0) { return } const payload = { type: input.type, message: input.message, metadata: input.metadata ?? {}, source: "paypal", timestamp: new Date().toISOString(), } await Promise.all( urls.map(async (url) => { try { const resp = await paypalFetch(url, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify(payload), }) if (!resp.ok) { const text = await resp.text().catch(() => "") await this.recordAuditEvent("alert_failed", { url, status: resp.status, response: text, }) } else { await this.recordAuditEvent("alert_sent", { url, type: input.type }) } } catch (error: any) { await this.recordAuditEvent("alert_failed", { url, message: error?.message, }) } }) ) } } export default PayPalModuleService