From 5ef5bab1210e65ee5be77d33458c8c6297e67c7d Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 17:47:29 +0000 Subject: [PATCH] Changes Co-authored-by: domfelipe <53182096+domfelipe@users.noreply.github.com> --- src/components/mika/PaymentTestModeBanner.tsx | 20 +++ src/hooks/use-paddle-checkout.ts | 44 ++++++ src/lib/paddle.ts | 71 ++++++++++ src/routeTree.gen.ts | 30 ++++ src/routes/checkout.sucesso.tsx | 50 +++++++ supabase/functions/_shared/paddle.ts | 59 ++++++++ .../functions/create-portal-session/index.ts | 62 ++++++++ supabase/functions/get-paddle-price/index.ts | 42 ++++++ supabase/functions/payments-webhook/index.ts | 134 ++++++++++++++++++ 9 files changed, 512 insertions(+) create mode 100644 src/components/mika/PaymentTestModeBanner.tsx create mode 100644 src/hooks/use-paddle-checkout.ts create mode 100644 src/lib/paddle.ts create mode 100644 src/routes/checkout.sucesso.tsx create mode 100644 supabase/functions/_shared/paddle.ts create mode 100644 supabase/functions/create-portal-session/index.ts create mode 100644 supabase/functions/get-paddle-price/index.ts create mode 100644 supabase/functions/payments-webhook/index.ts diff --git a/src/components/mika/PaymentTestModeBanner.tsx b/src/components/mika/PaymentTestModeBanner.tsx new file mode 100644 index 0000000..07a1fc0 --- /dev/null +++ b/src/components/mika/PaymentTestModeBanner.tsx @@ -0,0 +1,20 @@ +const clientToken = import.meta.env.VITE_PAYMENTS_CLIENT_TOKEN as string | undefined; + +export function PaymentTestModeBanner() { + if (!clientToken?.startsWith("test_")) return null; + + return ( +
+ Modo de teste:{" "} + pagamentos no preview não cobram dinheiro real.{" "} + + Saiba mais + +
+ ); +} diff --git a/src/hooks/use-paddle-checkout.ts b/src/hooks/use-paddle-checkout.ts new file mode 100644 index 0000000..c1bf003 --- /dev/null +++ b/src/hooks/use-paddle-checkout.ts @@ -0,0 +1,44 @@ +"use client"; + +import { useState } from "react"; +import { toast } from "sonner"; +import { initializePaddle, getPaddlePriceId } from "@/lib/paddle"; + +interface CheckoutOptions { + priceId: string; + quantity?: number; + customerEmail?: string; + userId: string; + successUrl?: string; +} + +export function usePaddleCheckout() { + const [loading, setLoading] = useState(false); + + const openCheckout = async (opts: CheckoutOptions) => { + setLoading(true); + try { + await initializePaddle(); + const paddlePriceId = await getPaddlePriceId(opts.priceId); + + window.Paddle.Checkout.open({ + items: [{ priceId: paddlePriceId, quantity: opts.quantity ?? 1 }], + customer: opts.customerEmail ? { email: opts.customerEmail } : undefined, + customData: { userId: opts.userId }, + settings: { + displayMode: "overlay", + successUrl: opts.successUrl || `${window.location.origin}/checkout/sucesso`, + allowLogout: false, + variant: "one-page", + }, + }); + } catch (e) { + console.error(e); + toast.error("Não foi possível abrir o checkout. Tente novamente."); + } finally { + setLoading(false); + } + }; + + return { openCheckout, loading }; +} diff --git a/src/lib/paddle.ts b/src/lib/paddle.ts new file mode 100644 index 0000000..9ce546b --- /dev/null +++ b/src/lib/paddle.ts @@ -0,0 +1,71 @@ +import { supabase } from "@/integrations/supabase/client"; + +const clientToken = import.meta.env.VITE_PAYMENTS_CLIENT_TOKEN as string | undefined; + +declare global { + interface Window { + Paddle: any; + } +} + +let paddleInitialized = false; +let initPromise: Promise | null = null; + +export function getPaddleEnv(): "sandbox" | "live" { + return clientToken?.startsWith("test_") ? "sandbox" : "live"; +} + +export async function initializePaddle(): Promise { + if (paddleInitialized) return; + if (initPromise) return initPromise; + + if (!clientToken) { + throw new Error("VITE_PAYMENTS_CLIENT_TOKEN não está configurado"); + } + + initPromise = new Promise((resolve, reject) => { + const existing = document.querySelector('script[data-paddle="true"]'); + const onLoad = () => { + try { + const environment = clientToken.startsWith("test_") ? "sandbox" : "production"; + window.Paddle.Environment.set(environment); + window.Paddle.Initialize({ token: clientToken }); + paddleInitialized = true; + resolve(); + } catch (err) { + reject(err); + } + }; + + if (existing) { + if (window.Paddle) onLoad(); + else existing.addEventListener("load", onLoad); + return; + } + + const script = document.createElement("script"); + script.src = "https://cdn.paddle.com/paddle/v2/paddle.js"; + script.dataset.paddle = "true"; + script.onload = onLoad; + script.onerror = () => reject(new Error("Falha ao carregar Paddle.js")); + document.head.appendChild(script); + }); + + return initPromise; +} + +const priceIdCache = new Map(); + +export async function getPaddlePriceId(priceId: string): Promise { + if (priceIdCache.has(priceId)) return priceIdCache.get(priceId)!; + + const environment = getPaddleEnv(); + const { data, error } = await supabase.functions.invoke("get-paddle-price", { + body: { priceId, environment }, + }); + if (error || !data?.paddleId) { + throw new Error(`Não foi possível resolver o preço: ${priceId}`); + } + priceIdCache.set(priceId, data.paddleId); + return data.paddleId; +} diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 8c8dbaa..cb4acc6 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -18,6 +18,7 @@ import { Route as IndexRouteImport } from './routes/index' import { Route as PainelIndexRouteImport } from './routes/painel.index' import { Route as PainelFaturamentoRouteImport } from './routes/painel.faturamento' import { Route as PainelConfiguracoesRouteImport } from './routes/painel.configuracoes' +import { Route as CheckoutSucessoRouteImport } from './routes/checkout.sucesso' const SignupRoute = SignupRouteImport.update({ id: '/signup', @@ -64,6 +65,11 @@ const PainelConfiguracoesRoute = PainelConfiguracoesRouteImport.update({ path: '/configuracoes', getParentRoute: () => PainelRoute, } as any) +const CheckoutSucessoRoute = CheckoutSucessoRouteImport.update({ + id: '/checkout/sucesso', + path: '/checkout/sucesso', + getParentRoute: () => rootRouteImport, +} as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute @@ -72,6 +78,7 @@ export interface FileRoutesByFullPath { '/recuperar-senha': typeof RecuperarSenhaRoute '/redefinir-senha': typeof RedefinirSenhaRoute '/signup': typeof SignupRoute + '/checkout/sucesso': typeof CheckoutSucessoRoute '/painel/configuracoes': typeof PainelConfiguracoesRoute '/painel/faturamento': typeof PainelFaturamentoRoute '/painel/': typeof PainelIndexRoute @@ -82,6 +89,7 @@ export interface FileRoutesByTo { '/recuperar-senha': typeof RecuperarSenhaRoute '/redefinir-senha': typeof RedefinirSenhaRoute '/signup': typeof SignupRoute + '/checkout/sucesso': typeof CheckoutSucessoRoute '/painel/configuracoes': typeof PainelConfiguracoesRoute '/painel/faturamento': typeof PainelFaturamentoRoute '/painel': typeof PainelIndexRoute @@ -94,6 +102,7 @@ export interface FileRoutesById { '/recuperar-senha': typeof RecuperarSenhaRoute '/redefinir-senha': typeof RedefinirSenhaRoute '/signup': typeof SignupRoute + '/checkout/sucesso': typeof CheckoutSucessoRoute '/painel/configuracoes': typeof PainelConfiguracoesRoute '/painel/faturamento': typeof PainelFaturamentoRoute '/painel/': typeof PainelIndexRoute @@ -107,6 +116,7 @@ export interface FileRouteTypes { | '/recuperar-senha' | '/redefinir-senha' | '/signup' + | '/checkout/sucesso' | '/painel/configuracoes' | '/painel/faturamento' | '/painel/' @@ -117,6 +127,7 @@ export interface FileRouteTypes { | '/recuperar-senha' | '/redefinir-senha' | '/signup' + | '/checkout/sucesso' | '/painel/configuracoes' | '/painel/faturamento' | '/painel' @@ -128,6 +139,7 @@ export interface FileRouteTypes { | '/recuperar-senha' | '/redefinir-senha' | '/signup' + | '/checkout/sucesso' | '/painel/configuracoes' | '/painel/faturamento' | '/painel/' @@ -140,6 +152,7 @@ export interface RootRouteChildren { RecuperarSenhaRoute: typeof RecuperarSenhaRoute RedefinirSenhaRoute: typeof RedefinirSenhaRoute SignupRoute: typeof SignupRoute + CheckoutSucessoRoute: typeof CheckoutSucessoRoute } declare module '@tanstack/react-router' { @@ -207,6 +220,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof PainelConfiguracoesRouteImport parentRoute: typeof PainelRoute } + '/checkout/sucesso': { + id: '/checkout/sucesso' + path: '/checkout/sucesso' + fullPath: '/checkout/sucesso' + preLoaderRoute: typeof CheckoutSucessoRouteImport + parentRoute: typeof rootRouteImport + } } } @@ -232,7 +252,17 @@ const rootRouteChildren: RootRouteChildren = { RecuperarSenhaRoute: RecuperarSenhaRoute, RedefinirSenhaRoute: RedefinirSenhaRoute, SignupRoute: SignupRoute, + CheckoutSucessoRoute: CheckoutSucessoRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) ._addFileTypes() + +import type { getRouter } from './router.tsx' +import type { createStart } from '@tanstack/react-start' +declare module '@tanstack/react-start' { + interface Register { + ssr: true + router: Awaited> + } +} diff --git a/src/routes/checkout.sucesso.tsx b/src/routes/checkout.sucesso.tsx new file mode 100644 index 0000000..3b23d37 --- /dev/null +++ b/src/routes/checkout.sucesso.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { useEffect } from "react"; +import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; +import { CheckCircle2 } from "lucide-react"; +import { useQueryClient } from "@tanstack/react-query"; +import { Button } from "@/components/ui/button"; + +export const Route = createFileRoute("/checkout/sucesso")({ + component: CheckoutSuccessPage, +}); + +function CheckoutSuccessPage() { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + + useEffect(() => { + // Invalida queries de assinatura para refletir o novo estado quando o webhook chegar + queryClient.invalidateQueries({ queryKey: ["subscription"] }); + const t = setTimeout(() => { + navigate({ to: "/painel" }); + }, 6000); + return () => clearTimeout(t); + }, [navigate, queryClient]); + + return ( +
+
+
+ +
+
+

Assinatura confirmada!

+

+ Estamos provisionando seu acesso. Em alguns segundos seu plano estará ativo. +

+
+
+ + +
+

Você será redirecionado em instantes…

+
+
+ ); +} diff --git a/supabase/functions/_shared/paddle.ts b/supabase/functions/_shared/paddle.ts new file mode 100644 index 0000000..f2358b4 --- /dev/null +++ b/supabase/functions/_shared/paddle.ts @@ -0,0 +1,59 @@ +import { Environment, Paddle, EventName } from 'npm:@paddle/paddle-node-sdk'; + +export { EventName }; + +export type PaddleEnv = 'sandbox' | 'live'; + +const GATEWAY_BASE_URL = 'https://connector-gateway.lovable.dev/paddle'; + +export function getConnectionApiKey(env: PaddleEnv): string { + return env === 'sandbox' + ? Deno.env.get('PADDLE_SANDBOX_API_KEY')! + : Deno.env.get('PADDLE_LIVE_API_KEY')!; +} + +export function getPaddleClient(env: PaddleEnv): Paddle { + const connectionApiKey = getConnectionApiKey(env); + const lovableApiKey = Deno.env.get('LOVABLE_API_KEY')!; + + return new Paddle(connectionApiKey, { + environment: GATEWAY_BASE_URL as unknown as Environment, + customHeaders: { + 'X-Connection-Api-Key': connectionApiKey, + 'Lovable-API-Key': lovableApiKey, + }, + }); +} + +export async function gatewayFetch(env: PaddleEnv, path: string, init?: RequestInit): Promise { + const connectionApiKey = getConnectionApiKey(env); + const lovableApiKey = Deno.env.get('LOVABLE_API_KEY')!; + return fetch(`${GATEWAY_BASE_URL}${path}`, { + ...init, + headers: { + 'Content-Type': 'application/json', + 'X-Connection-Api-Key': connectionApiKey, + 'Lovable-API-Key': lovableApiKey, + ...init?.headers, + }, + }); +} + +export function getWebhookSecret(env: PaddleEnv): string { + return env === 'sandbox' + ? Deno.env.get('PAYMENTS_SANDBOX_WEBHOOK_SECRET')! + : Deno.env.get('PAYMENTS_LIVE_WEBHOOK_SECRET')!; +} + +export async function verifyWebhook(req: Request, env: PaddleEnv) { + const signature = req.headers.get('paddle-signature'); + const body = await req.text(); + const secret = getWebhookSecret(env); + + if (!signature || !body) { + throw new Error('Missing signature or body'); + } + + const paddle = getPaddleClient(env); + return await paddle.webhooks.unmarshal(body, secret, signature); +} diff --git a/supabase/functions/create-portal-session/index.ts b/supabase/functions/create-portal-session/index.ts new file mode 100644 index 0000000..5981e0e --- /dev/null +++ b/supabase/functions/create-portal-session/index.ts @@ -0,0 +1,62 @@ +import { createClient } from 'npm:@supabase/supabase-js@2'; +import { getPaddleClient, type PaddleEnv } from '../_shared/paddle.ts'; + +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', + 'Content-Type': 'application/json', +}; + +Deno.serve(async (req) => { + if (req.method === 'OPTIONS') { + return new Response(null, { headers: corsHeaders }); + } + + try { + const authHeader = req.headers.get('Authorization'); + if (!authHeader) { + return new Response(JSON.stringify({ error: 'Missing auth' }), { status: 401, headers: corsHeaders }); + } + + const supabaseUrl = Deno.env.get('SUPABASE_URL')!; + const anonKey = Deno.env.get('SUPABASE_PUBLISHABLE_KEY') || Deno.env.get('SUPABASE_ANON_KEY')!; + const userClient = createClient(supabaseUrl, anonKey, { + global: { headers: { Authorization: authHeader } }, + }); + + const { data: userRes, error: userErr } = await userClient.auth.getUser(); + if (userErr || !userRes.user) { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: corsHeaders }); + } + + const admin = createClient(supabaseUrl, Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!); + const { data: sub } = await admin + .from('subscriptions') + .select('paddle_customer_id, paddle_subscription_id, environment') + .eq('user_id', userRes.user.id) + .order('updated_at', { ascending: false }) + .limit(1) + .maybeSingle(); + + if (!sub?.paddle_customer_id) { + return new Response(JSON.stringify({ error: 'No subscription found' }), { + status: 404, + headers: corsHeaders, + }); + } + + const paddle = getPaddleClient(sub.environment as PaddleEnv); + const subIds = sub.paddle_subscription_id ? [sub.paddle_subscription_id] : []; + const portalSession = await paddle.customerPortalSessions.create(sub.paddle_customer_id, subIds); + + return new Response(JSON.stringify({ url: portalSession.urls.general.overview }), { + headers: corsHeaders, + }); + } catch (e) { + console.error('create-portal-session error:', e); + return new Response(JSON.stringify({ error: (e as Error).message }), { + status: 500, + headers: corsHeaders, + }); + } +}); diff --git a/supabase/functions/get-paddle-price/index.ts b/supabase/functions/get-paddle-price/index.ts new file mode 100644 index 0000000..2b7423c --- /dev/null +++ b/supabase/functions/get-paddle-price/index.ts @@ -0,0 +1,42 @@ +import { gatewayFetch, type PaddleEnv } from '../_shared/paddle.ts'; + +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', + 'Content-Type': 'application/json', +}; + +Deno.serve(async (req) => { + if (req.method === 'OPTIONS') { + return new Response(null, { headers: corsHeaders }); + } + + try { + const { priceId, environment } = await req.json(); + if (!priceId) { + return new Response(JSON.stringify({ error: 'priceId required' }), { + status: 400, + headers: corsHeaders, + }); + } + + const env = (environment || 'sandbox') as PaddleEnv; + const response = await gatewayFetch(env, `/prices?external_id=${encodeURIComponent(priceId)}`); + const data = await response.json(); + + if (!data.data?.length) { + return new Response(JSON.stringify({ error: 'Price not found' }), { + status: 404, + headers: corsHeaders, + }); + } + + return new Response(JSON.stringify({ paddleId: data.data[0].id }), { headers: corsHeaders }); + } catch (e) { + console.error('get-paddle-price error:', e); + return new Response(JSON.stringify({ error: (e as Error).message }), { + status: 500, + headers: corsHeaders, + }); + } +}); diff --git a/supabase/functions/payments-webhook/index.ts b/supabase/functions/payments-webhook/index.ts new file mode 100644 index 0000000..5f604c8 --- /dev/null +++ b/supabase/functions/payments-webhook/index.ts @@ -0,0 +1,134 @@ +import { createClient } from 'npm:@supabase/supabase-js@2'; +import { verifyWebhook, EventName, type PaddleEnv } from '../_shared/paddle.ts'; + +const supabase = createClient( + Deno.env.get('SUPABASE_URL')!, + Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')! +); + +Deno.serve(async (req) => { + if (req.method !== 'POST') { + return new Response('Method not allowed', { status: 405 }); + } + + const url = new URL(req.url); + const env = (url.searchParams.get('env') || 'sandbox') as PaddleEnv; + + try { + const event = await verifyWebhook(req, env); + console.log('Received event:', event.eventType, 'env:', env, 'id:', (event as any).eventId); + + // Idempotência: tenta inserir, ignora se já existe + const eventId = (event as any).eventId || (event as any).id; + if (eventId) { + const { error: insertErr } = await supabase + .from('paddle_webhook_events') + .insert({ + paddle_event_id: eventId, + event_type: event.eventType, + environment: env, + payload: event as any, + }); + // Se já existe (unique violation), ignora silenciosamente + if (insertErr && insertErr.code !== '23505') { + console.error('Failed to record event:', insertErr); + } else if (insertErr?.code === '23505') { + console.log('Event already processed:', eventId); + return new Response(JSON.stringify({ received: true, duplicate: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + } + + switch (event.eventType) { + case EventName.SubscriptionCreated: + case EventName.SubscriptionUpdated: + await upsertSubscription((event as any).data, env); + break; + case EventName.SubscriptionCanceled: + await markCanceled((event as any).data, env); + break; + case EventName.TransactionCompleted: + console.log('Transaction completed:', (event as any).data.id); + break; + case EventName.TransactionPaymentFailed: + console.log('Payment failed:', (event as any).data.id); + break; + default: + console.log('Unhandled event:', event.eventType); + } + + return new Response(JSON.stringify({ received: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } catch (e) { + console.error('Webhook error:', e); + return new Response('Webhook error: ' + (e as Error).message, { status: 400 }); + } +}); + +async function upsertSubscription(data: any, env: PaddleEnv) { + const { id, customerId, items, status, currentBillingPeriod, scheduledChange, customData } = data; + + const userId = customData?.userId; + if (!userId) { + console.error('No userId in customData for subscription', id); + return; + } + + const item = items?.[0]; + const priceExt = item?.price?.importMeta?.externalId || item?.price?.id; + const productExt = item?.product?.importMeta?.externalId || item?.product?.id; + const billingCycle = item?.price?.billingCycle?.interval === 'year' ? 'yearly' : 'monthly'; + + // Resolve plan_id local pelo slug correspondente ao product externalId + // Mapeamento: basic_plan -> basic, starter_plan -> starter, professional_plan -> professional + const slugMap: Record = { + basic_plan: 'basic', + starter_plan: 'starter', + professional_plan: 'professional', + }; + const planSlug = slugMap[productExt as string]; + let planId: string | null = null; + if (planSlug) { + const { data: planRow } = await supabase.from('plans').select('id').eq('slug', planSlug).maybeSingle(); + planId = planRow?.id ?? null; + } + + const { error } = await supabase.from('subscriptions').upsert( + { + user_id: userId, + paddle_subscription_id: id, + paddle_customer_id: customerId, + product_id: productExt, + price_id: priceExt, + plan_id: planId, + billing_cycle: billingCycle, + status, + current_period_start: currentBillingPeriod?.startsAt, + current_period_end: currentBillingPeriod?.endsAt, + cancel_at_period_end: scheduledChange?.action === 'cancel', + environment: env, + updated_at: new Date().toISOString(), + }, + { onConflict: 'user_id,environment' } + ); + + if (error) { + console.error('Upsert subscription error:', error); + throw error; + } + + // Atualiza paddle_customer_id no profile + await supabase.from('profiles').update({ paddle_customer_id: customerId }).eq('id', userId); +} + +async function markCanceled(data: any, env: PaddleEnv) { + await supabase + .from('subscriptions') + .update({ status: 'canceled', updated_at: new Date().toISOString() }) + .eq('paddle_subscription_id', data.id) + .eq('environment', env); +}