// ─── Configuration ──────────────────────────────────────────────────────────── export interface CheckoutApiConfig { /** Supabase Edge Function base URL, e.g. https://.supabase.co/functions/v1 */ functionBaseUrl: string; /** UUID of the merchant from checkout.merchants */ merchantId: string; } /** Device + UTM context sent with OTP verify to enrich checkout sessions. */ export interface SessionContextPayload { utm_source?: string; utm_medium?: string; utm_campaign?: string; device_type?: string; platform?: string; user_agent?: string; experiment_variant?: string; } // ─── OTP ────────────────────────────────────────────────────────────────────── /** Send OTP to the given mobile number. Throws on failure. */ export async function sendOtp(config: CheckoutApiConfig, mobile: string): Promise { const res = await fetch(`${config.functionBaseUrl}/checkout-otp`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'send', mobile, merchant_id: config.merchantId }), }); const data = await res.json().catch(() => ({})); if (!res.ok || !data.success) { throw new Error(data.error ?? 'Failed to send OTP. Please try again.'); } } export interface VerifyOtpResult { session_token: string; session_id: string; expires_at: string; buyer_id: string; } /** Verify OTP and return a session token. Throws on invalid OTP. */ export async function verifyOtp( config: CheckoutApiConfig, mobile: string, otp: string, context?: SessionContextPayload ): Promise { const res = await fetch(`${config.functionBaseUrl}/checkout-otp`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'verify', mobile, otp, merchant_id: config.merchantId, ...context, }), }); const data = await res.json().catch(() => ({})); if (!res.ok || !data.success) { throw new Error(data.error ?? 'Invalid or expired OTP.'); } return data as VerifyOtpResult; } // ─── Saved Addresses ────────────────────────────────────────────────────────── export interface SavedAddress { id: string; /** Null for imported addresses pending lazy map verification. */ digipin: string | null; lat: number | null; lng: number | null; state: string; district: string; locality: string; pincode: string; delivery_status: string; flat_number: string; floor_number: string; building_name: string; street_name: string; label: string; /** Custom display text when label is `other` */ label_text: string | null; is_default: boolean; created_at: string; updated_at: string; /** Brand name of the merchant where this address was first saved. Null if unknown. */ saved_at_brand: string | null; } export interface SavedAddressPayload { digipin: string; lat: number; lng: number; state?: string; district?: string; locality?: string; pincode?: string; delivery_status?: string; flat_number?: string; floor_number?: string; building_name?: string; street_name?: string; full_address_json?: Record; label?: string; label_text?: string | null; is_default?: boolean; } function addressHeaders(sessionToken: string) { return { 'Content-Type': 'application/json', 'x-session-token': sessionToken }; } /** Fetch all saved addresses for the authenticated buyer. */ export async function listAddresses( config: CheckoutApiConfig, sessionToken: string ): Promise { const res = await fetch(`${config.functionBaseUrl}/checkout-addresses`, { method: 'POST', headers: addressHeaders(sessionToken), body: JSON.stringify({ action: 'list' }), }); const data = await res.json().catch(() => ({})); if (!res.ok || !data.success) { throw new Error(data.error ?? 'Failed to load saved addresses.'); } return (data.data ?? []) as SavedAddress[]; } /** Save a new address for the authenticated buyer. */ export async function createAddress( config: CheckoutApiConfig, sessionToken: string, address: SavedAddressPayload ): Promise { const res = await fetch(`${config.functionBaseUrl}/checkout-addresses`, { method: 'POST', headers: addressHeaders(sessionToken), body: JSON.stringify({ action: 'create', address }), }); const data = await res.json().catch(() => ({})); if (!res.ok || !data.success) { throw new Error(data.error ?? 'Failed to save address.'); } return data.data as SavedAddress; } /** Soft-delete a saved address. */ export async function deleteAddress( config: CheckoutApiConfig, sessionToken: string, addressId: string ): Promise { const res = await fetch(`${config.functionBaseUrl}/checkout-addresses`, { method: 'POST', headers: addressHeaders(sessionToken), body: JSON.stringify({ action: 'delete', address_id: addressId }), }); const data = await res.json().catch(() => ({})); if (!res.ok || !data.success) { throw new Error(data.error ?? 'Failed to delete address.'); } } /** Set an address as the default. */ export async function setDefaultAddress( config: CheckoutApiConfig, sessionToken: string, addressId: string ): Promise { const res = await fetch(`${config.functionBaseUrl}/checkout-addresses`, { method: 'POST', headers: addressHeaders(sessionToken), body: JSON.stringify({ action: 'set_default', address_id: addressId }), }); const data = await res.json().catch(() => ({})); if (!res.ok || !data.success) { throw new Error(data.error ?? 'Failed to set default address.'); } } /** Record the buyer's address choice for the current checkout session. */ export async function selectAddress( config: CheckoutApiConfig, sessionToken: string, addressId: string ): Promise { const res = await fetch(`${config.functionBaseUrl}/checkout-addresses`, { method: 'POST', headers: addressHeaders(sessionToken), body: JSON.stringify({ action: 'select_address', address_id: addressId }), }); const data = await res.json().catch(() => ({})); if (!res.ok || !data.success) { throw new Error(data.error ?? 'Failed to select address.'); } } export interface VerifyDigipinPayload { digipin: string; lat: number; lng: number; state?: string; district?: string; locality?: string; pincode?: string; delivery_status?: string; flat_number?: string; floor_number?: string; building_name?: string; street_name?: string; } /** * Confirm map pin + form for an imported address missing DigiPin. * Updates buyer_addresses and marks the session address as verified. */ export async function verifyDigipin( config: CheckoutApiConfig, sessionToken: string, addressId: string, payload: VerifyDigipinPayload ): Promise { const res = await fetch(`${config.functionBaseUrl}/checkout-addresses`, { method: 'POST', headers: addressHeaders(sessionToken), body: JSON.stringify({ action: 'verify_digipin', address_id: addressId, ...payload }), }); const data = await res.json().catch(() => ({})); if (!res.ok || !data.success) { throw new Error(data.error ?? 'Failed to verify delivery pin.'); } return data.data as SavedAddress; } // ─── Webhook: address completed ────────────────────────────────────────────── /** * Notify the server that the buyer has confirmed their delivery address. * Triggers `session.address_completed` webhook on the merchant's configured URL. * Call this once `onComplete` fires in the widget (or from your own checkout page). */ export async function notifyAddressCompleted( config: CheckoutApiConfig, sessionToken: string, address: { pincode: string; digipin: string; state: string; district: string; locality: string } ): Promise { const res = await fetch(`${config.functionBaseUrl}/checkout-addresses`, { method: 'POST', headers: addressHeaders(sessionToken), body: JSON.stringify({ action: 'address_completed', data: address }), }); const data = await res.json().catch(() => ({})); if (!res.ok || !data.success) { // Non-blocking: log but don't throw — this is analytics, not critical path console.warn('[checkout-api] notifyAddressCompleted failed:', data.error); } } // ─── Email OTP auth ─────────────────────────────────────────────────────────── /** Result from send email OTP — includes whether an existing code was reused (no new email). */ export interface SendEmailOtpResult { reused: boolean; } /** Send a 6-digit OTP to the given email via Resend (Edge: `checkout-email-otp`). */ export async function sendEmailOtp( config: CheckoutApiConfig, email: string ): Promise { const res = await fetch(`${config.functionBaseUrl}/checkout-email-otp`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'send', email, merchant_id: config.merchantId }), }); const data = await res.json().catch(() => ({})); if (!res.ok || !data.success) { throw new Error(data.error ?? 'Failed to send OTP. Please try again.'); } return { reused: Boolean(data.reused) }; } export interface VerifyEmailOtpResult { session_token: string; session_id: string; expires_at: string; buyer_id: string; } /** Verify a 6-digit email OTP. Returns a session token on success. */ export async function verifyEmailOtp( config: CheckoutApiConfig, email: string, code: string, context?: SessionContextPayload ): Promise { const res = await fetch(`${config.functionBaseUrl}/checkout-email-otp`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'verify', email, code, merchant_id: config.merchantId, ...context, }), }); const data = await res.json().catch(() => ({})); if (!res.ok || !data.success) { throw new Error(data.error ?? 'Invalid or expired code.'); } return data as VerifyEmailOtpResult; } // ─── COD eligibility (merchant pincode blocklist) ───────────────────────────── export interface CodEligibilityResult { /** Whether COD is allowed for this pincode for the session merchant. */ cod_allowed: boolean; /** Normalized 6-digit pincode when valid; otherwise the trimmed input. */ pincode: string; /** Present when COD is not allowed or the pincode could not be normalized. */ reason?: 'cod_disabled' | 'pincode_blocked' | 'invalid_pincode' | 'cart_value_exceeded'; /** Merchant COD cart cap in paise; null when unlimited. */ cod_max_cart_value_paise?: number | null; /** Echo of `cartTotalPaise` when sent. */ cart_total_paise?: number; } /** * Check COD availability for a delivery pincode (Edge: `cod_eligibility`). * Respects `checkout.merchants.enable_cod` (C4), `cod_blocked_pincodes` / `cod_max_cart_value_paise` (D1/D2). * Pass `cartTotalPaise` in paise (e.g. ₹499 → 49900) to enforce the cart cap when set. */ export async function checkCodEligibility( config: CheckoutApiConfig, sessionToken: string, pincode: string, cartTotalPaise?: number ): Promise { const body: Record = { action: 'cod_eligibility', pincode }; if (cartTotalPaise !== undefined) { body.cart_total_paise = cartTotalPaise; } const res = await fetch(`${config.functionBaseUrl}/checkout-addresses`, { method: 'POST', headers: addressHeaders(sessionToken), body: JSON.stringify(body), }); const data = await res.json().catch(() => ({})); if (!res.ok || !data.success) { throw new Error(data.error ?? 'Failed to check COD eligibility.'); } return data.data as CodEligibilityResult; } // ─── Merchant serviceability (courier / 3PL pincode check) ─────────────────── export interface ServiceabilityResult { /** Whether a merchant serviceability endpoint was configured and enabled. */ enabled: boolean; /** Whether the merchant endpoint returned a definitive response. */ checked: boolean; /** true/false when checked, null when disabled or failed open. */ serviceable: boolean | null; /** Normalized 6-digit pincode when valid; otherwise the trimmed input. */ pincode: string; /** Buyer-facing merchant message, when supplied by the merchant endpoint. */ message?: string; /** Whether the widget should block address submission when not serviceable. */ block_checkout: boolean; /** Whether the merchant wants the soft India Post info banner as fallback. */ show_india_post_delivery_warning: boolean; /** Present when the endpoint was skipped or failed open. */ reason?: | 'not_configured' | 'invalid_pincode' | 'merchant_error' | 'merchant_unreachable' | 'invalid_response'; } // ─── Address Intelligence ───────────────────────────────────────────────────── export type AddressRiskLevel = 'low' | 'medium' | 'high'; export type AddressIntervention = | 'none' | 'request_missing_details' | 'reconfirm_pin' | 'prepaid_nudge' | 'hide_cod' | 'manual_review' | 'delivery_instruction'; export type AddressConfidenceState = | 'new_source_collected' | 'buyer_reconfirmed' | 'merchant_successful' | 'courier_corrected' | 'rto_associated' | 'trusted_repeat'; export interface AddressRiskSignals { merchant_history_records: number; buyer_successful_deliveries: number; buyer_rto_orders: number; pincode_successful_deliveries: number; pincode_rto_orders: number; digipin_cell_successful_deliveries: number; digipin_cell_rto_orders: number; repeat_verified_address: boolean; imported_address_pending_digipin: boolean; cod_requested: boolean; address_edit_count: number; } export interface AddressRiskScore { level: AddressRiskLevel; score: number; reasons: string[]; interventions: AddressIntervention[]; confidence_state: AddressConfidenceState; signals: AddressRiskSignals; scored_at: string; } export interface AddressRiskPayload { address_id?: string; digipin?: string; lat?: number; lng?: number; pincode?: string; state?: string; district?: string; locality?: string; delivery_status?: string; flat_number?: string; floor_number?: string; building_name?: string; street_name?: string; payment_method?: string; } export async function scoreAddressRisk( config: CheckoutApiConfig, sessionToken: string, payload: AddressRiskPayload ): Promise { const res = await fetch(`${config.functionBaseUrl}/checkout-addresses`, { method: 'POST', headers: addressHeaders(sessionToken), body: JSON.stringify({ action: 'address_risk', data: payload }), }); const data = await res.json().catch(() => ({})); if (!res.ok || !data.success) { throw new Error(data.error ?? 'Failed to score address risk.'); } return data.data as AddressRiskScore; } /** * Check merchant courier serviceability for a pincode. * Uses the merchant's configured webhook on checkout.merchants and fails open. */ export async function checkServiceability( config: CheckoutApiConfig, sessionToken: string, pincode: string, coords?: { lat?: number; lng?: number } ): Promise { const res = await fetch(`${config.functionBaseUrl}/checkout-addresses`, { method: 'POST', headers: addressHeaders(sessionToken), body: JSON.stringify({ action: 'serviceability_check', pincode, ...(coords?.lat !== undefined ? { lat: coords.lat } : {}), ...(coords?.lng !== undefined ? { lng: coords.lng } : {}), }), }); const data = await res.json().catch(() => ({})); if (!res.ok || !data.success) { throw new Error(data.error ?? 'Failed to check delivery serviceability.'); } return data.data as ServiceabilityResult; } // ─── Funnel events (server-side analytics) ──────────────────────────────────── export interface FunnelEventPayload { event_type: string; step?: string; session_token?: string; buyer_id?: string; auth_channel?: string; device_type?: string; platform?: string; user_agent?: string; payment_method?: string; experiment_variant?: string; } /** Log a checkout funnel event to the server (fire-and-forget). */ export async function logFunnelEvent( config: CheckoutApiConfig, payload: FunnelEventPayload, sessionToken?: string ): Promise { try { const headers: Record = { 'Content-Type': 'application/json' }; if (sessionToken) headers['x-session-token'] = sessionToken; await fetch(`${config.functionBaseUrl}/checkout-funnel-events`, { method: 'POST', headers, body: JSON.stringify({ merchant_id: config.merchantId, ...payload }), }); } catch { // Non-blocking analytics } } /** Signal explicit session abandonment (page exit). */ export async function notifySessionAbandoned( config: CheckoutApiConfig, sessionToken: string, lastStep: string ): Promise { try { await fetch(`${config.functionBaseUrl}/checkout-addresses`, { method: 'POST', headers: addressHeaders(sessionToken), body: JSON.stringify({ action: 'abandon', last_step: lastStep }), }); } catch { // Non-blocking } } // ─── UTM offers ─────────────────────────────────────────────────────────────── export interface SessionOffer { offer_id: string; name: string; banner_copy: string; banner_cta_url: string | null; } /** Fetch UTM-matched offer banner for the current session. */ export async function fetchSessionOffer( config: CheckoutApiConfig, sessionToken: string ): Promise { const res = await fetch(`${config.functionBaseUrl}/checkout-offers`, { method: 'GET', headers: addressHeaders(sessionToken), }); const data = await res.json().catch(() => ({})); if (!res.ok || !data.success) return null; return (data.data as SessionOffer | null) ?? null; } // ─── Payment (Razorpay) ─────────────────────────────────────────────────────── export interface CreatePaymentOrderResult { razorpay_order_id: string; razorpay_key_id: string; amount_paise: number; currency: string; } export async function createPaymentOrder( config: CheckoutApiConfig, sessionToken: string, amountPaise: number ): Promise { const res = await fetch(`${config.functionBaseUrl}/checkout-payment`, { method: 'POST', headers: addressHeaders(sessionToken), body: JSON.stringify({ action: 'create_order', amount_paise: amountPaise }), }); const data = await res.json().catch(() => ({})); if (!res.ok || !data.success) { throw new Error(data.error ?? 'Failed to create payment order.'); } return data.data as CreatePaymentOrderResult; } export async function verifyPayment( config: CheckoutApiConfig, sessionToken: string, params: { razorpay_order_id: string; razorpay_payment_id: string; razorpay_signature: string; payment_method?: string; } ): Promise<{ payment_method: string; razorpay_order_id: string; razorpay_payment_id: string }> { const res = await fetch(`${config.functionBaseUrl}/checkout-payment`, { method: 'POST', headers: addressHeaders(sessionToken), body: JSON.stringify({ action: 'verify', ...params }), }); const data = await res.json().catch(() => ({})); if (!res.ok || !data.success) { throw new Error(data.error ?? 'Payment verification failed.'); } return { payment_method: data.payment_method as string, razorpay_order_id: data.razorpay_order_id as string, razorpay_payment_id: data.razorpay_payment_id as string, }; } export async function recordCodPayment( config: CheckoutApiConfig, sessionToken: string ): Promise<{ payment_method: string }> { const res = await fetch(`${config.functionBaseUrl}/checkout-payment`, { method: 'POST', headers: addressHeaders(sessionToken), body: JSON.stringify({ action: 'record_cod' }), }); const data = await res.json().catch(() => ({})); if (!res.ok || !data.success) { throw new Error(data.error ?? 'Failed to record COD selection.'); } return { payment_method: data.payment_method as string }; } // ─── Merchant prepaid nudge config ──────────────────────────────────────────── export interface PrepaidNudgeConfig { prepaid_nudge_copy: string | null; prepaid_nudge_cta_url: string | null; } /** Fetch merchant prepaid nudge copy via Admin API (optional; use props to avoid). */ export async function fetchPrepaidNudgeConfig( functionBaseUrl: string, adminApiKey: string ): Promise { const res = await fetch(`${functionBaseUrl}/checkout-admin?action=cod_config`, { headers: { 'x-api-key': adminApiKey }, }); const data = await res.json().catch(() => ({})); if (!res.ok || !data.success) return null; return { prepaid_nudge_copy: data.data.prepaid_nudge_copy, prepaid_nudge_cta_url: data.data.prepaid_nudge_cta_url, }; }