mirror of
https://github.com/domfelipe/mika-agent-assist.git
synced 2026-08-07 09:16:46 +00:00
Migrou schema e criou produtos
X-Lovable-Edit-ID: edt-45d95c32-cb2b-4cfc-bb03-50f3200929db Co-authored-by: domfelipe <53182096+domfelipe@users.noreply.github.com>
This commit is contained in:
commit
a7c5db1307
16 changed files with 694 additions and 19 deletions
20
src/components/mika/PaymentTestModeBanner.tsx
Normal file
20
src/components/mika/PaymentTestModeBanner.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Link } from "@tanstack/react-router";
|
import { Link, useNavigate } from "@tanstack/react-router";
|
||||||
import { Check } from "lucide-react";
|
import { Check, Loader2 } from "lucide-react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { supabase } from "@/integrations/supabase/client";
|
import { supabase } from "@/integrations/supabase/client";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -18,6 +18,8 @@ import {
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { EnterpriseLeadForm } from "./EnterpriseLeadForm";
|
import { EnterpriseLeadForm } from "./EnterpriseLeadForm";
|
||||||
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
import { usePaddleCheckout } from "@/hooks/use-paddle-checkout";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface PlanRow {
|
interface PlanRow {
|
||||||
|
|
@ -57,6 +59,26 @@ function usePlans() {
|
||||||
export function PlansSection() {
|
export function PlansSection() {
|
||||||
const [yearly, setYearly] = useState(false);
|
const [yearly, setYearly] = useState(false);
|
||||||
const { data: plans, isLoading } = usePlans();
|
const { data: plans, isLoading } = usePlans();
|
||||||
|
const { user } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { openCheckout, loading: checkoutLoading } = usePaddleCheckout();
|
||||||
|
const [pendingSlug, setPendingSlug] = useState<string | null>(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 (
|
return (
|
||||||
<section id="planos" className="py-20 sm:py-28 bg-background scroll-mt-20">
|
<section id="planos" className="py-20 sm:py-28 bg-background scroll-mt-20">
|
||||||
|
|
@ -179,7 +201,8 @@ export function PlansSection() {
|
||||||
</Dialog>
|
</Dialog>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
asChild
|
onClick={() => handleSubscribe(plan.slug)}
|
||||||
|
disabled={checkoutLoading && pendingSlug === plan.slug}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full rounded-lg transition-all duration-150 active:scale-[0.98]",
|
"w-full rounded-lg transition-all duration-150 active:scale-[0.98]",
|
||||||
plan.highlighted
|
plan.highlighted
|
||||||
|
|
@ -187,12 +210,14 @@ export function PlansSection() {
|
||||||
: "bg-foreground hover:bg-foreground/90 text-background",
|
: "bg-foreground hover:bg-foreground/90 text-background",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Link
|
{checkoutLoading && pendingSlug === plan.slug ? (
|
||||||
to="/signup"
|
<>
|
||||||
search={{ plan: plan.slug, cycle: yearly ? "yearly" : "monthly" }}
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
>
|
Abrindo checkout…
|
||||||
Assinar agora
|
</>
|
||||||
</Link>
|
) : (
|
||||||
|
"Assinar agora"
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<p className="mt-3 text-xs text-muted-foreground text-center">
|
<p className="mt-3 text-xs text-muted-foreground text-center">
|
||||||
|
|
|
||||||
44
src/hooks/use-paddle-checkout.ts
Normal file
44
src/hooks/use-paddle-checkout.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { supabase } from "@/integrations/supabase/client";
|
import { supabase } from "@/integrations/supabase/client";
|
||||||
import { useAuth } from "./use-auth";
|
import { useAuth } from "./use-auth";
|
||||||
|
import { getPaddleEnv } from "@/lib/paddle";
|
||||||
|
|
||||||
export interface Profile {
|
export interface Profile {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -12,6 +13,7 @@ export interface Profile {
|
||||||
phone: string | null;
|
phone: string | null;
|
||||||
avatar_url: string | null;
|
avatar_url: string | null;
|
||||||
stripe_customer_id: string | null;
|
stripe_customer_id: string | null;
|
||||||
|
paddle_customer_id: string | null;
|
||||||
onboarding_completed: boolean;
|
onboarding_completed: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -38,8 +40,12 @@ export interface SubscriptionRow {
|
||||||
id: string;
|
id: string;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
plan_id: string | null;
|
plan_id: string | null;
|
||||||
stripe_subscription_id: string | null;
|
paddle_subscription_id: string | null;
|
||||||
status: "active" | "trialing" | "past_due" | "canceled" | "incomplete" | "incomplete_expired" | "unpaid";
|
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";
|
billing_cycle: "monthly" | "yearly";
|
||||||
current_period_start: string | null;
|
current_period_start: string | null;
|
||||||
current_period_end: string | null;
|
current_period_end: string | null;
|
||||||
|
|
@ -48,9 +54,10 @@ export interface SubscriptionRow {
|
||||||
|
|
||||||
export function useSubscription() {
|
export function useSubscription() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
const env = getPaddleEnv();
|
||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["subscription", user?.id],
|
queryKey: ["subscription", user?.id, env],
|
||||||
enabled: !!user,
|
enabled: !!user,
|
||||||
queryFn: async (): Promise<SubscriptionRow | null> => {
|
queryFn: async (): Promise<SubscriptionRow | null> => {
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
@ -58,6 +65,7 @@ export function useSubscription() {
|
||||||
.from("subscriptions")
|
.from("subscriptions")
|
||||||
.select("*")
|
.select("*")
|
||||||
.eq("user_id", user.id)
|
.eq("user_id", user.id)
|
||||||
|
.eq("environment", env)
|
||||||
.order("created_at", { ascending: false })
|
.order("created_at", { ascending: false })
|
||||||
.limit(1)
|
.limit(1)
|
||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,33 @@ export type Database = {
|
||||||
}
|
}
|
||||||
Relationships: []
|
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: {
|
plans: {
|
||||||
Row: {
|
Row: {
|
||||||
created_at: string
|
created_at: string
|
||||||
|
|
@ -154,6 +181,7 @@ export type Database = {
|
||||||
full_name: string
|
full_name: string
|
||||||
id: string
|
id: string
|
||||||
onboarding_completed: boolean
|
onboarding_completed: boolean
|
||||||
|
paddle_customer_id: string | null
|
||||||
phone: string | null
|
phone: string | null
|
||||||
stripe_customer_id: string | null
|
stripe_customer_id: string | null
|
||||||
updated_at: string
|
updated_at: string
|
||||||
|
|
@ -166,6 +194,7 @@ export type Database = {
|
||||||
full_name?: string
|
full_name?: string
|
||||||
id: string
|
id: string
|
||||||
onboarding_completed?: boolean
|
onboarding_completed?: boolean
|
||||||
|
paddle_customer_id?: string | null
|
||||||
phone?: string | null
|
phone?: string | null
|
||||||
stripe_customer_id?: string | null
|
stripe_customer_id?: string | null
|
||||||
updated_at?: string
|
updated_at?: string
|
||||||
|
|
@ -178,6 +207,7 @@ export type Database = {
|
||||||
full_name?: string
|
full_name?: string
|
||||||
id?: string
|
id?: string
|
||||||
onboarding_completed?: boolean
|
onboarding_completed?: boolean
|
||||||
|
paddle_customer_id?: string | null
|
||||||
phone?: string | null
|
phone?: string | null
|
||||||
stripe_customer_id?: string | null
|
stripe_customer_id?: string | null
|
||||||
updated_at?: string
|
updated_at?: string
|
||||||
|
|
@ -215,8 +245,13 @@ export type Database = {
|
||||||
created_at: string
|
created_at: string
|
||||||
current_period_end: string | null
|
current_period_end: string | null
|
||||||
current_period_start: string | null
|
current_period_start: string | null
|
||||||
|
environment: string
|
||||||
id: string
|
id: string
|
||||||
|
paddle_customer_id: string | null
|
||||||
|
paddle_subscription_id: string | null
|
||||||
plan_id: string | null
|
plan_id: string | null
|
||||||
|
price_id: string | null
|
||||||
|
product_id: string | null
|
||||||
status: string
|
status: string
|
||||||
stripe_subscription_id: string | null
|
stripe_subscription_id: string | null
|
||||||
updated_at: string
|
updated_at: string
|
||||||
|
|
@ -228,8 +263,13 @@ export type Database = {
|
||||||
created_at?: string
|
created_at?: string
|
||||||
current_period_end?: string | null
|
current_period_end?: string | null
|
||||||
current_period_start?: string | null
|
current_period_start?: string | null
|
||||||
|
environment?: string
|
||||||
id?: string
|
id?: string
|
||||||
|
paddle_customer_id?: string | null
|
||||||
|
paddle_subscription_id?: string | null
|
||||||
plan_id?: string | null
|
plan_id?: string | null
|
||||||
|
price_id?: string | null
|
||||||
|
product_id?: string | null
|
||||||
status: string
|
status: string
|
||||||
stripe_subscription_id?: string | null
|
stripe_subscription_id?: string | null
|
||||||
updated_at?: string
|
updated_at?: string
|
||||||
|
|
@ -241,8 +281,13 @@ export type Database = {
|
||||||
created_at?: string
|
created_at?: string
|
||||||
current_period_end?: string | null
|
current_period_end?: string | null
|
||||||
current_period_start?: string | null
|
current_period_start?: string | null
|
||||||
|
environment?: string
|
||||||
id?: string
|
id?: string
|
||||||
|
paddle_customer_id?: string | null
|
||||||
|
paddle_subscription_id?: string | null
|
||||||
plan_id?: string | null
|
plan_id?: string | null
|
||||||
|
price_id?: string | null
|
||||||
|
product_id?: string | null
|
||||||
status?: string
|
status?: string
|
||||||
stripe_subscription_id?: string | null
|
stripe_subscription_id?: string | null
|
||||||
updated_at?: string
|
updated_at?: string
|
||||||
|
|
@ -270,7 +315,10 @@ export type Database = {
|
||||||
[_ in never]: never
|
[_ in never]: never
|
||||||
}
|
}
|
||||||
Functions: {
|
Functions: {
|
||||||
[_ in never]: never
|
has_active_subscription: {
|
||||||
|
Args: { check_env?: string; user_uuid: string }
|
||||||
|
Returns: boolean
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Enums: {
|
Enums: {
|
||||||
[_ in never]: never
|
[_ in never]: never
|
||||||
|
|
|
||||||
71
src/lib/paddle.ts
Normal file
71
src/lib/paddle.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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,6 +252,7 @@ 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)
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import type { QueryClient } from "@tanstack/react-query";
|
||||||
import { QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import { ThemeProvider } from "@/components/theme-provider";
|
import { ThemeProvider } from "@/components/theme-provider";
|
||||||
|
import { PaymentTestModeBanner } from "@/components/mika/PaymentTestModeBanner";
|
||||||
|
|
||||||
import appCss from "../styles.css?url";
|
import appCss from "../styles.css?url";
|
||||||
|
|
||||||
|
|
@ -98,6 +99,7 @@ function RootComponent() {
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
|
<PaymentTestModeBanner />
|
||||||
<Outlet />
|
<Outlet />
|
||||||
<Toaster richColors position="top-right" />
|
<Toaster richColors position="top-right" />
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
|
|
||||||
50
src/routes/checkout.sucesso.tsx
Normal file
50
src/routes/checkout.sucesso.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { ptBR } from "date-fns/locale";
|
import { ptBR } from "date-fns/locale";
|
||||||
import { CreditCard, ExternalLink } from "lucide-react";
|
import { CreditCard, ExternalLink, Loader2 } from "lucide-react";
|
||||||
import { useSubscription, useProfile } from "@/hooks/use-profile";
|
import { toast } from "sonner";
|
||||||
|
import { useSubscription } from "@/hooks/use-profile";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { supabase } from "@/integrations/supabase/client";
|
import { supabase } from "@/integrations/supabase/client";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -16,7 +18,7 @@ export const Route = createFileRoute("/painel/faturamento")({
|
||||||
|
|
||||||
function BillingPage() {
|
function BillingPage() {
|
||||||
const { data: subscription, isLoading } = useSubscription();
|
const { data: subscription, isLoading } = useSubscription();
|
||||||
const { data: profile } = useProfile();
|
const [portalLoading, setPortalLoading] = useState(false);
|
||||||
|
|
||||||
const { data: plan } = useQuery({
|
const { data: plan } = useQuery({
|
||||||
queryKey: ["plan", subscription?.plan_id],
|
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 (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<header>
|
<header>
|
||||||
|
|
@ -75,10 +91,15 @@ function BillingPage() {
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
disabled={!profile?.stripe_customer_id}
|
disabled={!subscription?.paddle_subscription_id || portalLoading}
|
||||||
|
onClick={openPortal}
|
||||||
className="mt-6 rounded-lg bg-primary hover:bg-primary-dark text-primary-foreground"
|
className="mt-6 rounded-lg bg-primary hover:bg-primary-dark text-primary-foreground"
|
||||||
>
|
>
|
||||||
|
{portalLoading ? (
|
||||||
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
) : (
|
||||||
<ExternalLink className="h-4 w-4 mr-2" />
|
<ExternalLink className="h-4 w-4 mr-2" />
|
||||||
|
)}
|
||||||
Gerenciar assinatura
|
Gerenciar assinatura
|
||||||
</Button>
|
</Button>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -1 +1,7 @@
|
||||||
project_id = "smsarmgoirlcedmqvdgc"
|
project_id = "smsarmgoirlcedmqvdgc"
|
||||||
|
|
||||||
|
[functions.payments-webhook]
|
||||||
|
verify_jwt = false
|
||||||
|
|
||||||
|
[functions.get-paddle-price]
|
||||||
|
verify_jwt = false
|
||||||
|
|
|
||||||
59
supabase/functions/_shared/paddle.ts
Normal file
59
supabase/functions/_shared/paddle.ts
Normal 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);
|
||||||
|
}
|
||||||
62
supabase/functions/create-portal-session/index.ts
Normal file
62
supabase/functions/create-portal-session/index.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
42
supabase/functions/get-paddle-price/index.ts
Normal file
42
supabase/functions/get-paddle-price/index.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
134
supabase/functions/payments-webhook/index.ts
Normal file
134
supabase/functions/payments-webhook/index.ts
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue