From 13b3951a1e70c3134b6f3a89e63e953fa5320898 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:46:18 +0000
Subject: [PATCH 1/6] Changes
Co-authored-by: domfelipe <53182096+domfelipe@users.noreply.github.com>
---
src/integrations/supabase/types.ts | 50 ++++++++++++++-
...5_bf18cc9e-7019-4b50-bc80-ddfd10c3ce3b.sql | 62 +++++++++++++++++++
2 files changed, 111 insertions(+), 1 deletion(-)
create mode 100644 supabase/migrations/20260417174615_bf18cc9e-7019-4b50-bc80-ddfd10c3ce3b.sql
diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts
index c3e0690..21418a2 100644
--- a/src/integrations/supabase/types.ts
+++ b/src/integrations/supabase/types.ts
@@ -97,6 +97,33 @@ export type Database = {
}
Relationships: []
}
+ paddle_webhook_events: {
+ Row: {
+ environment: string
+ event_type: string
+ id: string
+ paddle_event_id: string
+ payload: Json | null
+ processed_at: string
+ }
+ Insert: {
+ environment: string
+ event_type: string
+ id?: string
+ paddle_event_id: string
+ payload?: Json | null
+ processed_at?: string
+ }
+ Update: {
+ environment?: string
+ event_type?: string
+ id?: string
+ paddle_event_id?: string
+ payload?: Json | null
+ processed_at?: string
+ }
+ Relationships: []
+ }
plans: {
Row: {
created_at: string
@@ -154,6 +181,7 @@ export type Database = {
full_name: string
id: string
onboarding_completed: boolean
+ paddle_customer_id: string | null
phone: string | null
stripe_customer_id: string | null
updated_at: string
@@ -166,6 +194,7 @@ export type Database = {
full_name?: string
id: string
onboarding_completed?: boolean
+ paddle_customer_id?: string | null
phone?: string | null
stripe_customer_id?: string | null
updated_at?: string
@@ -178,6 +207,7 @@ export type Database = {
full_name?: string
id?: string
onboarding_completed?: boolean
+ paddle_customer_id?: string | null
phone?: string | null
stripe_customer_id?: string | null
updated_at?: string
@@ -215,8 +245,13 @@ export type Database = {
created_at: string
current_period_end: string | null
current_period_start: string | null
+ environment: string
id: string
+ paddle_customer_id: string | null
+ paddle_subscription_id: string | null
plan_id: string | null
+ price_id: string | null
+ product_id: string | null
status: string
stripe_subscription_id: string | null
updated_at: string
@@ -228,8 +263,13 @@ export type Database = {
created_at?: string
current_period_end?: string | null
current_period_start?: string | null
+ environment?: string
id?: string
+ paddle_customer_id?: string | null
+ paddle_subscription_id?: string | null
plan_id?: string | null
+ price_id?: string | null
+ product_id?: string | null
status: string
stripe_subscription_id?: string | null
updated_at?: string
@@ -241,8 +281,13 @@ export type Database = {
created_at?: string
current_period_end?: string | null
current_period_start?: string | null
+ environment?: string
id?: string
+ paddle_customer_id?: string | null
+ paddle_subscription_id?: string | null
plan_id?: string | null
+ price_id?: string | null
+ product_id?: string | null
status?: string
stripe_subscription_id?: string | null
updated_at?: string
@@ -270,7 +315,10 @@ export type Database = {
[_ in never]: never
}
Functions: {
- [_ in never]: never
+ has_active_subscription: {
+ Args: { check_env?: string; user_uuid: string }
+ Returns: boolean
+ }
}
Enums: {
[_ in never]: never
diff --git a/supabase/migrations/20260417174615_bf18cc9e-7019-4b50-bc80-ddfd10c3ce3b.sql b/supabase/migrations/20260417174615_bf18cc9e-7019-4b50-bc80-ddfd10c3ce3b.sql
new file mode 100644
index 0000000..3370c72
--- /dev/null
+++ b/supabase/migrations/20260417174615_bf18cc9e-7019-4b50-bc80-ddfd10c3ce3b.sql
@@ -0,0 +1,62 @@
+-- Migrar tabela subscriptions para o formato Paddle
+ALTER TABLE public.subscriptions
+ ADD COLUMN IF NOT EXISTS paddle_subscription_id text,
+ ADD COLUMN IF NOT EXISTS paddle_customer_id text,
+ ADD COLUMN IF NOT EXISTS product_id text,
+ ADD COLUMN IF NOT EXISTS price_id text,
+ ADD COLUMN IF NOT EXISTS environment text NOT NULL DEFAULT 'sandbox';
+
+-- Índices
+CREATE UNIQUE INDEX IF NOT EXISTS idx_subscriptions_paddle_id
+ ON public.subscriptions(paddle_subscription_id)
+ WHERE paddle_subscription_id IS NOT NULL;
+
+CREATE INDEX IF NOT EXISTS idx_subscriptions_user_id ON public.subscriptions(user_id);
+
+-- Constraint única (user_id, environment) para upsert idempotente do webhook
+CREATE UNIQUE INDEX IF NOT EXISTS idx_subscriptions_user_env
+ ON public.subscriptions(user_id, environment);
+
+-- Adicionar paddle_customer_id ao profiles (substitui stripe_customer_id conceitualmente)
+ALTER TABLE public.profiles
+ ADD COLUMN IF NOT EXISTS paddle_customer_id text;
+
+-- Função utilitária para checar assinatura ativa
+CREATE OR REPLACE FUNCTION public.has_active_subscription(
+ user_uuid uuid,
+ check_env text DEFAULT 'live'
+)
+RETURNS boolean
+LANGUAGE sql
+STABLE
+SECURITY DEFINER
+SET search_path = public
+AS $$
+ SELECT EXISTS (
+ SELECT 1 FROM public.subscriptions
+ WHERE user_id = user_uuid
+ AND environment = check_env
+ AND status IN ('active', 'trialing')
+ AND (current_period_end IS NULL OR current_period_end > now())
+ );
+$$;
+
+-- Trigger updated_at na subscriptions
+DROP TRIGGER IF EXISTS update_subscriptions_updated_at ON public.subscriptions;
+CREATE TRIGGER update_subscriptions_updated_at
+ BEFORE UPDATE ON public.subscriptions
+ FOR EACH ROW
+ EXECUTE FUNCTION public.update_updated_at_column();
+
+-- Tabela de eventos de webhook (idempotência) — renomeando conceitualmente para paddle
+CREATE TABLE IF NOT EXISTS public.paddle_webhook_events (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ paddle_event_id text NOT NULL UNIQUE,
+ event_type text NOT NULL,
+ environment text NOT NULL,
+ payload jsonb,
+ processed_at timestamptz NOT NULL DEFAULT now()
+);
+
+ALTER TABLE public.paddle_webhook_events ENABLE ROW LEVEL SECURITY;
+-- Sem políticas: apenas service_role acessa
\ No newline at end of file
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 2/6] 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);
+}
From 2a9c5a8d098a228bab2adba96df3311dd96e1585 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:38 +0000
Subject: [PATCH 3/6] Changes
Co-authored-by: domfelipe <53182096+domfelipe@users.noreply.github.com>
---
src/routeTree.gen.ts | 9 ---------
1 file changed, 9 deletions(-)
diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts
index cb4acc6..d965976 100644
--- a/src/routeTree.gen.ts
+++ b/src/routeTree.gen.ts
@@ -257,12 +257,3 @@ const rootRouteChildren: RootRouteChildren = {
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>
- }
-}
From 6bdca7b7b2350230934645c239ccaef9c150a833 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:57 +0000
Subject: [PATCH 4/6] Changes
Co-authored-by: domfelipe <53182096+domfelipe@users.noreply.github.com>
---
src/hooks/use-profile.ts | 14 +++++++++++---
src/routes/__root.tsx | 2 ++
2 files changed, 13 insertions(+), 3 deletions(-)
diff --git a/src/hooks/use-profile.ts b/src/hooks/use-profile.ts
index 7855eec..14055eb 100644
--- a/src/hooks/use-profile.ts
+++ b/src/hooks/use-profile.ts
@@ -3,6 +3,7 @@
import { useQuery } from "@tanstack/react-query";
import { supabase } from "@/integrations/supabase/client";
import { useAuth } from "./use-auth";
+import { getPaddleEnv } from "@/lib/paddle";
export interface Profile {
id: string;
@@ -12,6 +13,7 @@ export interface Profile {
phone: string | null;
avatar_url: string | null;
stripe_customer_id: string | null;
+ paddle_customer_id: string | null;
onboarding_completed: boolean;
}
@@ -38,8 +40,12 @@ export interface SubscriptionRow {
id: string;
user_id: string;
plan_id: string | null;
- stripe_subscription_id: string | null;
- status: "active" | "trialing" | "past_due" | "canceled" | "incomplete" | "incomplete_expired" | "unpaid";
+ paddle_subscription_id: string | null;
+ paddle_customer_id: string | null;
+ product_id: string | null;
+ price_id: string | null;
+ environment: "sandbox" | "live";
+ status: "active" | "trialing" | "past_due" | "canceled" | "incomplete" | "incomplete_expired" | "unpaid" | "paused";
billing_cycle: "monthly" | "yearly";
current_period_start: string | null;
current_period_end: string | null;
@@ -48,9 +54,10 @@ export interface SubscriptionRow {
export function useSubscription() {
const { user } = useAuth();
+ const env = getPaddleEnv();
return useQuery({
- queryKey: ["subscription", user?.id],
+ queryKey: ["subscription", user?.id, env],
enabled: !!user,
queryFn: async (): Promise => {
if (!user) return null;
@@ -58,6 +65,7 @@ export function useSubscription() {
.from("subscriptions")
.select("*")
.eq("user_id", user.id)
+ .eq("environment", env)
.order("created_at", { ascending: false })
.limit(1)
.maybeSingle();
diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx
index 3605010..e215205 100644
--- a/src/routes/__root.tsx
+++ b/src/routes/__root.tsx
@@ -3,6 +3,7 @@ import type { QueryClient } from "@tanstack/react-query";
import { QueryClientProvider } from "@tanstack/react-query";
import { Toaster } from "@/components/ui/sonner";
import { ThemeProvider } from "@/components/theme-provider";
+import { PaymentTestModeBanner } from "@/components/mika/PaymentTestModeBanner";
import appCss from "../styles.css?url";
@@ -98,6 +99,7 @@ function RootComponent() {
return (
+
From db49d48413e036ee447ed0d8a631d45b2d31f5df 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:48:30 +0000
Subject: [PATCH 5/6] Changes
Co-authored-by: domfelipe <53182096+domfelipe@users.noreply.github.com>
---
src/components/mika/PlansSection.tsx | 43 ++++++++++++++++++++++------
src/routes/painel.faturamento.tsx | 31 ++++++++++++++++----
2 files changed, 60 insertions(+), 14 deletions(-)
diff --git a/src/components/mika/PlansSection.tsx b/src/components/mika/PlansSection.tsx
index cedaa4c..8b152b9 100644
--- a/src/components/mika/PlansSection.tsx
+++ b/src/components/mika/PlansSection.tsx
@@ -1,8 +1,8 @@
"use client";
import { useState } from "react";
-import { Link } from "@tanstack/react-router";
-import { Check } from "lucide-react";
+import { Link, useNavigate } from "@tanstack/react-router";
+import { Check, Loader2 } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { supabase } from "@/integrations/supabase/client";
import { Button } from "@/components/ui/button";
@@ -18,6 +18,8 @@ import {
DialogTrigger,
} from "@/components/ui/dialog";
import { EnterpriseLeadForm } from "./EnterpriseLeadForm";
+import { useAuth } from "@/hooks/use-auth";
+import { usePaddleCheckout } from "@/hooks/use-paddle-checkout";
import { cn } from "@/lib/utils";
interface PlanRow {
@@ -57,6 +59,26 @@ function usePlans() {
export function PlansSection() {
const [yearly, setYearly] = useState(false);
const { data: plans, isLoading } = usePlans();
+ const { user } = useAuth();
+ const navigate = useNavigate();
+ const { openCheckout, loading: checkoutLoading } = usePaddleCheckout();
+ const [pendingSlug, setPendingSlug] = useState(null);
+
+ const handleSubscribe = async (slug: string) => {
+ const cycle = yearly ? "yearly" : "monthly";
+ if (!user) {
+ navigate({ to: "/signup", search: { plan: slug, cycle } });
+ return;
+ }
+ const priceId = `${slug}_${cycle}`;
+ setPendingSlug(slug);
+ await openCheckout({
+ priceId,
+ userId: user.id,
+ customerEmail: user.email || undefined,
+ });
+ setPendingSlug(null);
+ };
return (
@@ -179,7 +201,8 @@ export function PlansSection() {
) : (
)}
diff --git a/src/routes/painel.faturamento.tsx b/src/routes/painel.faturamento.tsx
index 331d81d..cd813f1 100644
--- a/src/routes/painel.faturamento.tsx
+++ b/src/routes/painel.faturamento.tsx
@@ -1,10 +1,12 @@
"use client";
+import { useState } from "react";
import { createFileRoute } from "@tanstack/react-router";
import { format } from "date-fns";
import { ptBR } from "date-fns/locale";
-import { CreditCard, ExternalLink } from "lucide-react";
-import { useSubscription, useProfile } from "@/hooks/use-profile";
+import { CreditCard, ExternalLink, Loader2 } from "lucide-react";
+import { toast } from "sonner";
+import { useSubscription } from "@/hooks/use-profile";
import { useQuery } from "@tanstack/react-query";
import { supabase } from "@/integrations/supabase/client";
import { Button } from "@/components/ui/button";
@@ -16,7 +18,7 @@ export const Route = createFileRoute("/painel/faturamento")({
function BillingPage() {
const { data: subscription, isLoading } = useSubscription();
- const { data: profile } = useProfile();
+ const [portalLoading, setPortalLoading] = useState(false);
const { data: plan } = useQuery({
queryKey: ["plan", subscription?.plan_id],
@@ -31,6 +33,20 @@ function BillingPage() {
},
});
+ const openPortal = async () => {
+ setPortalLoading(true);
+ try {
+ const { data, error } = await supabase.functions.invoke("create-portal-session");
+ if (error || !data?.url) {
+ toast.error("Não foi possível abrir o portal. Tente novamente.");
+ return;
+ }
+ window.open(data.url, "_blank", "noopener,noreferrer");
+ } finally {
+ setPortalLoading(false);
+ }
+ };
+
return (
@@ -75,10 +91,15 @@ function BillingPage() {
)}
From b9788bc0769597715e0ec492b4721e3af4448863 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:48:41 +0000
Subject: [PATCH 6/6] Changes
Co-authored-by: domfelipe <53182096+domfelipe@users.noreply.github.com>
---
supabase/config.toml | 8 +++++++-
1 file changed, 7 insertions(+), 1 deletion(-)
diff --git a/supabase/config.toml b/supabase/config.toml
index b925aa7..cee1866 100644
--- a/supabase/config.toml
+++ b/supabase/config.toml
@@ -1 +1,7 @@
-project_id = "smsarmgoirlcedmqvdgc"
\ No newline at end of file
+project_id = "smsarmgoirlcedmqvdgc"
+
+[functions.payments-webhook]
+verify_jwt = false
+
+[functions.get-paddle-price]
+verify_jwt = false