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);
+}