/* |-------------------------------------------------------------------------- | Agência PSI — Edge Function: create-whatsapp-credit-charge |-------------------------------------------------------------------------- | Cria cobrança PIX no Asaas pra compra de pacote de créditos WhatsApp. | | Input: { package_id: UUID } | Output: { | ok: true, | purchase: { id, amount_brl, credits, package_name, asaas_pix_qrcode, | asaas_pix_copy_paste, asaas_payment_link, expires_at } | } | | Env vars: | ASAAS_API_KEY — API key da conta Asaas | ASAAS_API_URL — https://sandbox.asaas.com/api/v3 ou https://api.asaas.com/v3 |-------------------------------------------------------------------------- */ import { createClient } from 'https://esm.sh/@supabase/supabase-js@2' const corsHeaders = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', 'Access-Control-Allow-Methods': 'POST, OPTIONS', } function json(body: unknown, status = 200) { return new Response(JSON.stringify(body), { status, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }) } // ── Asaas helpers ───────────────────────────────────────── const ASAAS_API_URL = (Deno.env.get('ASAAS_API_URL') || 'https://sandbox.asaas.com/api/v3').replace(/\/+$/, '') const ASAAS_API_KEY = Deno.env.get('ASAAS_API_KEY') || '' async function asaasRequest(path: string, method: string, body?: unknown) { const resp = await fetch(`${ASAAS_API_URL}${path}`, { method, headers: { 'Content-Type': 'application/json', 'access_token': ASAAS_API_KEY, 'User-Agent': 'AgenciaPSI/1.0' }, body: body ? JSON.stringify(body) : undefined }) const text = await resp.text() let data: unknown = null try { data = JSON.parse(text) } catch { /* noop */ } if (!resp.ok) { return { ok: false, status: resp.status, error: data || text } } return { ok: true, data } } // Cria ou reutiliza cliente Asaas pro tenant. Se existente estiver sem CPF/CNPJ // mas nós temos um (ex: sandbox fallback), atualiza antes de retornar. async function getOrCreateAsaasCustomer( tenantName: string, tenantEmail: string | null, tenantDoc: string | null, tenantId: string ): Promise<{ ok: boolean; id?: string; error?: string }> { // Tenta buscar por externalReference (tenant_id) const search = await asaasRequest(`/customers?externalReference=${encodeURIComponent(tenantId)}`, 'GET') if (search.ok) { const list = (search.data as { data?: Array<{ id: string; cpfCnpj?: string; email?: string; name?: string }> })?.data || [] const existing = list[0] if (existing?.id) { // Se falta CPF/CNPJ no customer existente e nós temos um, atualiza const needsUpdate = !existing.cpfCnpj && tenantDoc if (needsUpdate) { const patchPayload: Record = { cpfCnpj: tenantDoc } if (tenantEmail && !existing.email) patchPayload.email = tenantEmail if (tenantName && !existing.name) patchPayload.name = tenantName const upd = await asaasRequest(`/customers/${existing.id}`, 'POST', patchPayload) if (!upd.ok) { return { ok: false, error: `update_customer: ${JSON.stringify(upd.error).slice(0, 300)}` } } } return { ok: true, id: existing.id } } } // Cria const payload: Record = { name: tenantName || `Tenant ${tenantId.slice(0, 8)}`, externalReference: tenantId } if (tenantEmail) payload.email = tenantEmail if (tenantDoc) payload.cpfCnpj = tenantDoc const create = await asaasRequest('/customers', 'POST', payload) if (!create.ok) return { ok: false, error: JSON.stringify(create.error).slice(0, 300) } const id = (create.data as { id?: string })?.id if (!id) return { ok: false, error: 'no_customer_id' } return { ok: true, id } } Deno.serve(async (req: Request) => { console.log('[create-charge] request received, method:', req.method) if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders }) if (req.method !== 'POST') return json({ ok: false, error: 'method_not_allowed' }, 405) console.log('[create-charge] ASAAS_API_KEY length:', ASAAS_API_KEY.length, 'URL:', ASAAS_API_URL) if (!ASAAS_API_KEY) { return json({ ok: false, error: 'Asaas não configurado. Contate o suporte.' }, 503) } try { console.log('[create-charge] parsing body') const body = await req.json().catch(() => null) as { package_id?: string; cpf_cnpj?: string } | null console.log('[create-charge] body:', JSON.stringify(body)) const packageId = body?.package_id if (!packageId) return json({ ok: false, error: 'package_id ausente' }, 400) const providedDoc = (body?.cpf_cnpj || '').replace(/\D/g, '') // Auth const authHeader = req.headers.get('Authorization') console.log('[create-charge] authHeader present:', !!authHeader) if (!authHeader) return json({ ok: false, error: 'unauthorized' }, 401) console.log('[create-charge] creating supaAuth client') const supaAuth = createClient( Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_ANON_KEY')!, { global: { headers: { Authorization: authHeader } } } ) console.log('[create-charge] calling auth.getUser') const { data: authData, error: authErr } = await supaAuth.auth.getUser() console.log('[create-charge] authErr:', authErr?.message, 'userId:', authData?.user?.id) if (authErr || !authData?.user) return json({ ok: false, error: 'unauthorized' }, 401) const userId = authData.user.id const supaSvc = createClient( Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')! ) // Descobre tenant do usuário (assume ativo — frontend passa via session) console.log('[create-charge] querying tenant membership for user', userId) const { data: membership, error: memErr } = await supaSvc .from('tenant_members') .select('tenant_id') .eq('user_id', userId) .eq('status', 'active') .limit(1) .maybeSingle() console.log('[create-charge] membership:', membership, 'err:', memErr?.message) if (!membership?.tenant_id) return json({ ok: false, error: 'no_active_tenant' }, 403) const tenantId = membership.tenant_id // Busca pacote console.log('[create-charge] querying package', packageId) const { data: pkg, error: pkgErr } = await supaSvc .from('whatsapp_credit_packages') .select('*') .eq('id', packageId) .eq('is_active', true) .maybeSingle() console.log('[create-charge] pkg:', pkg?.name, 'err:', pkgErr?.message) if (!pkg) return json({ ok: false, error: 'package_not_found_or_inactive' }, 404) // Busca dados do tenant const { data: tenant } = await supaSvc .from('tenants') .select('id, name, kind, cpf_cnpj') .eq('id', tenantId) .maybeSingle() const tenantName = tenant?.name || `Cliente ${tenantId.slice(0, 8)}` // Email do tenant: pega do usuário que está comprando (está autenticado) const tenantEmail = authData.user.email || null // CPF/CNPJ: prioridade 1) body (user informou agora), 2) coluna tenants.cpf_cnpj // Asaas exige esse campo. Se ainda nao tem, front deve coletar. const storedDoc = (tenant?.cpf_cnpj || '').replace(/\D/g, '') let tenantDoc: string | null = providedDoc || storedDoc || null // Valida comprimento (11 CPF, 14 CNPJ) — sem checksum aqui, UI valida. if (tenantDoc && tenantDoc.length !== 11 && tenantDoc.length !== 14) { return json({ ok: false, error: 'cpf_cnpj_invalid', message: 'CPF/CNPJ inválido. Informe 11 dígitos (CPF) ou 14 (CNPJ).' }, 400) } if (!tenantDoc) { return json({ ok: false, error: 'cpf_cnpj_required', message: 'Informe o CPF/CNPJ do titular pra gerar a cobrança.' }, 400) } // Persiste no tenant quando usuario informa novo doc (ou corrige um errado) if (providedDoc && providedDoc !== storedDoc) { const { error: upErr } = await supaSvc .from('tenants') .update({ cpf_cnpj: providedDoc }) .eq('id', tenantId) if (upErr) console.warn('[create-charge] update tenant cpf_cnpj failed:', upErr.message) } // Cria ordem de compra (pending) console.log('[create-charge] creating purchase record') const expiresAt = new Date(Date.now() + 24 * 3600 * 1000).toISOString() // 24h const { data: purchase, error: purErr } = await supaSvc .from('whatsapp_credit_purchases') .insert({ tenant_id: tenantId, package_id: pkg.id, package_name: pkg.name, credits: pkg.credits, amount_brl: pkg.price_brl, status: 'pending', expires_at: expiresAt, created_by: userId }) .select('id') .single() console.log('[create-charge] purchase:', purchase?.id, 'err:', purErr?.message) if (purErr || !purchase) return json({ ok: false, error: `db_insert: ${purErr?.message}` }, 500) // Cria/reutiliza customer Asaas console.log('[create-charge] calling getOrCreateAsaasCustomer') const custRes = await getOrCreateAsaasCustomer(tenantName, tenantEmail, tenantDoc, tenantId) console.log('[create-charge] customer result:', custRes) if (!custRes.ok) { // Marca purchase como failed await supaSvc.from('whatsapp_credit_purchases').update({ status: 'failed', failed_at: new Date().toISOString() }).eq('id', purchase.id) return json({ ok: false, error: `asaas_customer: ${custRes.error}` }, 502) } const customerId = custRes.id! // Cria pagamento PIX no Asaas const dueDate = new Date() dueDate.setDate(dueDate.getDate() + 1) const paymentPayload = { customer: customerId, billingType: 'PIX', value: Number(pkg.price_brl), dueDate: dueDate.toISOString().slice(0, 10), description: `Créditos WhatsApp — ${pkg.name} (${pkg.credits} mensagens)`, externalReference: purchase.id } console.log('[create-charge] creating Asaas payment, payload:', JSON.stringify(paymentPayload)) const payRes = await asaasRequest('/payments', 'POST', paymentPayload) console.log('[create-charge] payment result ok:', payRes.ok, 'status:', (payRes as any).status, 'data/error:', JSON.stringify(payRes.ok ? payRes.data : payRes.error).slice(0, 500)) if (!payRes.ok) { await supaSvc.from('whatsapp_credit_purchases').update({ status: 'failed', failed_at: new Date().toISOString() }).eq('id', purchase.id) return json({ ok: false, error: `asaas_payment: ${JSON.stringify(payRes.error).slice(0, 300)}` }, 502) } const payment = payRes.data as { id: string; invoiceUrl?: string } console.log('[create-charge] payment created:', payment.id) // Busca QR Code PIX console.log('[create-charge] fetching PIX QR code') const qrRes = await asaasRequest(`/payments/${payment.id}/pixQrCode`, 'GET') console.log('[create-charge] qr result ok:', qrRes.ok, 'has encodedImage:', !!(qrRes.ok && (qrRes.data as any)?.encodedImage)) const qr = qrRes.ok ? (qrRes.data as { encodedImage?: string; payload?: string; expirationDate?: string }) : null // Atualiza purchase com dados Asaas console.log('[create-charge] updating purchase with Asaas data') const { error: updErr } = await supaSvc .from('whatsapp_credit_purchases') .update({ asaas_customer_id: customerId, asaas_payment_id: payment.id, asaas_payment_link: payment.invoiceUrl ?? null, asaas_pix_qrcode: qr?.encodedImage ?? null, asaas_pix_copy_paste: qr?.payload ?? null }) .eq('id', purchase.id) console.log('[create-charge] update purchase err:', updErr?.message) console.log('[create-charge] returning success') return json({ ok: true, purchase: { id: purchase.id, package_name: pkg.name, credits: pkg.credits, amount_brl: pkg.price_brl, asaas_payment_link: payment.invoiceUrl ?? null, asaas_pix_qrcode: qr?.encodedImage ?? null, asaas_pix_copy_paste: qr?.payload ?? null, expires_at: expiresAt } }) } catch (err) { const msg = err instanceof Error ? `${err.message}\n${err.stack}` : String(err) console.error('[create-whatsapp-credit-charge] fatal:', msg) return json({ ok: false, error: String(err), stack: msg }, 500) } })