Co-authored-by: domfelipe <53182096+domfelipe@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot] 2026-04-17 17:47:29 +00:00
parent 13b3951a1e
commit 5ef5bab121
9 changed files with 512 additions and 0 deletions

View file

@ -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 (
<div className="w-full bg-warning/15 border-b border-warning/30 px-4 py-2 text-center text-xs sm:text-sm text-warning-foreground">
<span className="font-semibold text-warning">Modo de teste:</span>{" "}
pagamentos no preview não cobram dinheiro real.{" "}
<a
href="https://docs.lovable.dev/features/payments#test-and-live-environments"
target="_blank"
rel="noopener noreferrer"
className="underline font-medium text-warning"
>
Saiba mais
</a>
</div>
);
}

View file

@ -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 };
}

71
src/lib/paddle.ts Normal file
View file

@ -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<void> | null = null;
export function getPaddleEnv(): "sandbox" | "live" {
return clientToken?.startsWith("test_") ? "sandbox" : "live";
}
export async function initializePaddle(): Promise<void> {
if (paddleInitialized) return;
if (initPromise) return initPromise;
if (!clientToken) {
throw new Error("VITE_PAYMENTS_CLIENT_TOKEN não está configurado");
}
initPromise = new Promise<void>((resolve, reject) => {
const existing = document.querySelector<HTMLScriptElement>('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<string, string>();
export async function getPaddlePriceId(priceId: string): Promise<string> {
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;
}

View file

@ -18,6 +18,7 @@ import { Route as IndexRouteImport } from './routes/index'
import { Route as PainelIndexRouteImport } from './routes/painel.index' import { Route as PainelIndexRouteImport } from './routes/painel.index'
import { Route as PainelFaturamentoRouteImport } from './routes/painel.faturamento' import { Route as PainelFaturamentoRouteImport } from './routes/painel.faturamento'
import { Route as PainelConfiguracoesRouteImport } from './routes/painel.configuracoes' import { Route as PainelConfiguracoesRouteImport } from './routes/painel.configuracoes'
import { Route as CheckoutSucessoRouteImport } from './routes/checkout.sucesso'
const SignupRoute = SignupRouteImport.update({ const SignupRoute = SignupRouteImport.update({
id: '/signup', id: '/signup',
@ -64,6 +65,11 @@ const PainelConfiguracoesRoute = PainelConfiguracoesRouteImport.update({
path: '/configuracoes', path: '/configuracoes',
getParentRoute: () => PainelRoute, getParentRoute: () => PainelRoute,
} as any) } as any)
const CheckoutSucessoRoute = CheckoutSucessoRouteImport.update({
id: '/checkout/sucesso',
path: '/checkout/sucesso',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath { export interface FileRoutesByFullPath {
'/': typeof IndexRoute '/': typeof IndexRoute
@ -72,6 +78,7 @@ export interface FileRoutesByFullPath {
'/recuperar-senha': typeof RecuperarSenhaRoute '/recuperar-senha': typeof RecuperarSenhaRoute
'/redefinir-senha': typeof RedefinirSenhaRoute '/redefinir-senha': typeof RedefinirSenhaRoute
'/signup': typeof SignupRoute '/signup': typeof SignupRoute
'/checkout/sucesso': typeof CheckoutSucessoRoute
'/painel/configuracoes': typeof PainelConfiguracoesRoute '/painel/configuracoes': typeof PainelConfiguracoesRoute
'/painel/faturamento': typeof PainelFaturamentoRoute '/painel/faturamento': typeof PainelFaturamentoRoute
'/painel/': typeof PainelIndexRoute '/painel/': typeof PainelIndexRoute
@ -82,6 +89,7 @@ export interface FileRoutesByTo {
'/recuperar-senha': typeof RecuperarSenhaRoute '/recuperar-senha': typeof RecuperarSenhaRoute
'/redefinir-senha': typeof RedefinirSenhaRoute '/redefinir-senha': typeof RedefinirSenhaRoute
'/signup': typeof SignupRoute '/signup': typeof SignupRoute
'/checkout/sucesso': typeof CheckoutSucessoRoute
'/painel/configuracoes': typeof PainelConfiguracoesRoute '/painel/configuracoes': typeof PainelConfiguracoesRoute
'/painel/faturamento': typeof PainelFaturamentoRoute '/painel/faturamento': typeof PainelFaturamentoRoute
'/painel': typeof PainelIndexRoute '/painel': typeof PainelIndexRoute
@ -94,6 +102,7 @@ export interface FileRoutesById {
'/recuperar-senha': typeof RecuperarSenhaRoute '/recuperar-senha': typeof RecuperarSenhaRoute
'/redefinir-senha': typeof RedefinirSenhaRoute '/redefinir-senha': typeof RedefinirSenhaRoute
'/signup': typeof SignupRoute '/signup': typeof SignupRoute
'/checkout/sucesso': typeof CheckoutSucessoRoute
'/painel/configuracoes': typeof PainelConfiguracoesRoute '/painel/configuracoes': typeof PainelConfiguracoesRoute
'/painel/faturamento': typeof PainelFaturamentoRoute '/painel/faturamento': typeof PainelFaturamentoRoute
'/painel/': typeof PainelIndexRoute '/painel/': typeof PainelIndexRoute
@ -107,6 +116,7 @@ export interface FileRouteTypes {
| '/recuperar-senha' | '/recuperar-senha'
| '/redefinir-senha' | '/redefinir-senha'
| '/signup' | '/signup'
| '/checkout/sucesso'
| '/painel/configuracoes' | '/painel/configuracoes'
| '/painel/faturamento' | '/painel/faturamento'
| '/painel/' | '/painel/'
@ -117,6 +127,7 @@ export interface FileRouteTypes {
| '/recuperar-senha' | '/recuperar-senha'
| '/redefinir-senha' | '/redefinir-senha'
| '/signup' | '/signup'
| '/checkout/sucesso'
| '/painel/configuracoes' | '/painel/configuracoes'
| '/painel/faturamento' | '/painel/faturamento'
| '/painel' | '/painel'
@ -128,6 +139,7 @@ export interface FileRouteTypes {
| '/recuperar-senha' | '/recuperar-senha'
| '/redefinir-senha' | '/redefinir-senha'
| '/signup' | '/signup'
| '/checkout/sucesso'
| '/painel/configuracoes' | '/painel/configuracoes'
| '/painel/faturamento' | '/painel/faturamento'
| '/painel/' | '/painel/'
@ -140,6 +152,7 @@ export interface RootRouteChildren {
RecuperarSenhaRoute: typeof RecuperarSenhaRoute RecuperarSenhaRoute: typeof RecuperarSenhaRoute
RedefinirSenhaRoute: typeof RedefinirSenhaRoute RedefinirSenhaRoute: typeof RedefinirSenhaRoute
SignupRoute: typeof SignupRoute SignupRoute: typeof SignupRoute
CheckoutSucessoRoute: typeof CheckoutSucessoRoute
} }
declare module '@tanstack/react-router' { declare module '@tanstack/react-router' {
@ -207,6 +220,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof PainelConfiguracoesRouteImport preLoaderRoute: typeof PainelConfiguracoesRouteImport
parentRoute: typeof PainelRoute 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, RecuperarSenhaRoute: RecuperarSenhaRoute,
RedefinirSenhaRoute: RedefinirSenhaRoute, RedefinirSenhaRoute: RedefinirSenhaRoute,
SignupRoute: SignupRoute, SignupRoute: SignupRoute,
CheckoutSucessoRoute: CheckoutSucessoRoute,
} }
export const routeTree = rootRouteImport export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren) ._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>() ._addFileTypes<FileRouteTypes>()
import type { getRouter } from './router.tsx'
import type { createStart } from '@tanstack/react-start'
declare module '@tanstack/react-start' {
interface Register {
ssr: true
router: Awaited<ReturnType<typeof getRouter>>
}
}

View file

@ -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 (
<div className="min-h-screen bg-background flex items-center justify-center px-4">
<div className="max-w-md w-full text-center space-y-6">
<div className="mx-auto w-16 h-16 rounded-full bg-success/15 flex items-center justify-center">
<CheckCircle2 className="h-9 w-9 text-success" />
</div>
<div className="space-y-2">
<h1 className="text-3xl font-bold tracking-tight">Assinatura confirmada!</h1>
<p className="text-muted-foreground">
Estamos provisionando seu acesso. Em alguns segundos seu plano estará ativo.
</p>
</div>
<div className="flex flex-col sm:flex-row gap-3 justify-center">
<Button asChild className="rounded-lg bg-primary hover:bg-primary-dark text-primary-foreground">
<Link to="/painel">Ir para o painel</Link>
</Button>
<Button asChild variant="outline" className="rounded-lg">
<Link to="/painel/faturamento">Ver faturamento</Link>
</Button>
</div>
<p className="text-xs text-muted-foreground">Você será redirecionado em instantes</p>
</div>
</div>
);
}

View file

@ -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<Response> {
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);
}

View file

@ -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,
});
}
});

View file

@ -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,
});
}
});

View file

@ -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<string, string> = {
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);
}