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/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/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/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/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/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..d965976 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,6 +252,7 @@ const rootRouteChildren: RootRouteChildren = { RecuperarSenhaRoute: RecuperarSenhaRoute, RedefinirSenhaRoute: RedefinirSenhaRoute, SignupRoute: SignupRoute, + CheckoutSucessoRoute: CheckoutSucessoRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) 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 ( + 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/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() {

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