Lade till portal/abonnemang
X-Lovable-Edit-ID: edt-c4fe3ee8-d3b1-4b2b-b588-9260f9fe5413 Co-authored-by: wolfoftyreso-debug <250630591+wolfoftyreso-debug@users.noreply.github.com>
This commit is contained in:
@@ -186,6 +186,66 @@ export type Database = {
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
subscriptions: {
|
||||
Row: {
|
||||
cancel_at_period_end: boolean
|
||||
collection_method: string | null
|
||||
company_name: string | null
|
||||
created_at: string
|
||||
current_period_end: string | null
|
||||
current_period_start: string | null
|
||||
customer_email: string
|
||||
environment: string
|
||||
id: string
|
||||
kg_per_week: number | null
|
||||
monthly_amount: number | null
|
||||
price_id: string | null
|
||||
status: string
|
||||
stripe_customer_id: string
|
||||
stripe_subscription_id: string
|
||||
updated_at: string
|
||||
user_id: string | null
|
||||
}
|
||||
Insert: {
|
||||
cancel_at_period_end?: boolean
|
||||
collection_method?: string | null
|
||||
company_name?: string | null
|
||||
created_at?: string
|
||||
current_period_end?: string | null
|
||||
current_period_start?: string | null
|
||||
customer_email: string
|
||||
environment?: string
|
||||
id?: string
|
||||
kg_per_week?: number | null
|
||||
monthly_amount?: number | null
|
||||
price_id?: string | null
|
||||
status?: string
|
||||
stripe_customer_id: string
|
||||
stripe_subscription_id: string
|
||||
updated_at?: string
|
||||
user_id?: string | null
|
||||
}
|
||||
Update: {
|
||||
cancel_at_period_end?: boolean
|
||||
collection_method?: string | null
|
||||
company_name?: string | null
|
||||
created_at?: string
|
||||
current_period_end?: string | null
|
||||
current_period_start?: string | null
|
||||
customer_email?: string
|
||||
environment?: string
|
||||
id?: string
|
||||
kg_per_week?: number | null
|
||||
monthly_amount?: number | null
|
||||
price_id?: string | null
|
||||
status?: string
|
||||
stripe_customer_id?: string
|
||||
stripe_subscription_id?: string
|
||||
updated_at?: string
|
||||
user_id?: string | null
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
user_roles: {
|
||||
Row: {
|
||||
created_at: string
|
||||
@@ -212,6 +272,10 @@ export type Database = {
|
||||
[_ in never]: never
|
||||
}
|
||||
Functions: {
|
||||
has_active_kaka_subscription: {
|
||||
Args: { user_uuid: string }
|
||||
Returns: boolean
|
||||
}
|
||||
has_role: {
|
||||
Args: {
|
||||
_role: Database["public"]["Enums"]["app_role"]
|
||||
|
||||
+146
-21
@@ -7,8 +7,9 @@ import { Label } from "@/components/ui/label";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { getStripeEnvironment, hasPaymentsConfigured } from "@/lib/stripe";
|
||||
import Header from "@/components/Header";
|
||||
import { Save, Package } from "lucide-react";
|
||||
import { Save, Package, ExternalLink } from "lucide-react";
|
||||
|
||||
|
||||
// Validate Stockholm postal codes (100 00 - 199 99)
|
||||
@@ -53,6 +54,16 @@ const getPostalCodeError = (postalCode: string) => {
|
||||
return null;
|
||||
};
|
||||
|
||||
interface SubscriptionRow {
|
||||
id: string;
|
||||
status: string;
|
||||
kg_per_week: number | null;
|
||||
monthly_amount: number | null;
|
||||
current_period_end: string | null;
|
||||
cancel_at_period_end: boolean;
|
||||
collection_method: string | null;
|
||||
}
|
||||
|
||||
const Account = () => {
|
||||
const [profile, setProfile] = useState<ProfileData>({
|
||||
company_name: "",
|
||||
@@ -68,6 +79,8 @@ const Account = () => {
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [subscription, setSubscription] = useState<SubscriptionRow | null>(null);
|
||||
const [openingPortal, setOpeningPortal] = useState(false);
|
||||
const { user, loading: authLoading, signOut } = useAuth();
|
||||
const { toast } = useToast();
|
||||
const navigate = useNavigate();
|
||||
@@ -87,9 +100,57 @@ const Account = () => {
|
||||
|
||||
if (user) {
|
||||
fetchProfile();
|
||||
fetchSubscription();
|
||||
}
|
||||
}, [user, authLoading, navigate]);
|
||||
|
||||
const fetchSubscription = async () => {
|
||||
if (!user) return;
|
||||
try {
|
||||
const env = hasPaymentsConfigured() ? getStripeEnvironment() : "sandbox";
|
||||
const { data } = await supabase
|
||||
.from("subscriptions")
|
||||
.select("id, status, kg_per_week, monthly_amount, current_period_end, cancel_at_period_end, collection_method")
|
||||
.eq("user_id", user.id)
|
||||
.eq("environment", env)
|
||||
.order("created_at", { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
setSubscription((data as SubscriptionRow | null) ?? null);
|
||||
} catch (e) {
|
||||
console.error("fetchSubscription error:", e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleManageSubscription = async () => {
|
||||
if (!hasPaymentsConfigured()) {
|
||||
toast({ title: "Betalningar inte konfigurerade", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
setOpeningPortal(true);
|
||||
try {
|
||||
const { data, error } = await supabase.functions.invoke("create-portal-session", {
|
||||
body: {
|
||||
return_url: `${window.location.origin}/account`,
|
||||
environment: getStripeEnvironment(),
|
||||
},
|
||||
});
|
||||
if (error) throw error;
|
||||
if ((data as any)?.error) throw new Error((data as any).error);
|
||||
const url = (data as any)?.url;
|
||||
if (!url) throw new Error("Ingen portal-URL mottagen");
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
} catch (e: any) {
|
||||
toast({
|
||||
title: "Kunde inte öppna kundportalen",
|
||||
description: e?.message ?? String(e),
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setOpeningPortal(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchProfile = async () => {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
@@ -302,26 +363,90 @@ const Account = () => {
|
||||
<h2 className="font-serif text-xl">Mitt abonnemang</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Du har för närvarande inget aktivt abonnemang.
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
navigate("/");
|
||||
setTimeout(() => {
|
||||
const element = document.getElementById("abonnemang");
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
}, 100);
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
Beställ abonnemang
|
||||
</Button>
|
||||
</div>
|
||||
{subscription ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<div>
|
||||
<div className="text-muted-foreground text-xs uppercase tracking-widest">
|
||||
Ditt abonnemang
|
||||
</div>
|
||||
<div className="font-serif text-2xl font-semibold text-foreground">
|
||||
{subscription.kg_per_week ?? "—"} kg / vecka
|
||||
</div>
|
||||
{subscription.monthly_amount && (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{Math.round(subscription.monthly_amount * 1.25).toLocaleString("sv-SE")} kr/mån ink moms
|
||||
{" · "}
|
||||
{subscription.collection_method === "send_invoice" ? "Faktura" : "Kort"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={`text-xs px-2 py-1 rounded-sm border ${
|
||||
subscription.cancel_at_period_end
|
||||
? "border-destructive/40 text-destructive"
|
||||
: subscription.status === "past_due"
|
||||
? "border-amber-500/40 text-amber-700"
|
||||
: "border-primary/40 text-primary"
|
||||
}`}
|
||||
>
|
||||
{subscription.cancel_at_period_end
|
||||
? "Avslutas vid periodens slut"
|
||||
: subscription.status === "active"
|
||||
? "Aktiv"
|
||||
: subscription.status === "trialing"
|
||||
? "Provperiod"
|
||||
: subscription.status === "past_due"
|
||||
? "Obetald – Stripe försöker igen"
|
||||
: subscription.status === "canceled"
|
||||
? "Avslutad"
|
||||
: subscription.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{subscription.current_period_end && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{subscription.cancel_at_period_end || subscription.status === "canceled"
|
||||
? "Sista leveransperiod till: "
|
||||
: "Nästa förnyelse: "}
|
||||
{new Date(subscription.current_period_end).toLocaleDateString("sv-SE")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleManageSubscription}
|
||||
disabled={openingPortal}
|
||||
className="w-full"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4 mr-2" />
|
||||
{openingPortal ? "Öppnar kundportal..." : "Hantera abonnemang"}
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
Ändra kort, ladda ner fakturor eller säg upp — tillgång kvar till periodens slut.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Du har för närvarande inget aktivt abonnemang.
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
navigate("/");
|
||||
setTimeout(() => {
|
||||
const element = document.getElementById("abonnemang");
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
}, 100);
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
Beställ abonnemang
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-center mt-6">
|
||||
<button
|
||||
|
||||
@@ -17,3 +17,6 @@ verify_jwt = false
|
||||
|
||||
[functions.payments-webhook]
|
||||
verify_jwt = false
|
||||
|
||||
[functions.create-portal-session]
|
||||
verify_jwt = false
|
||||
|
||||
@@ -204,12 +204,14 @@ Deno.serve(async (req) => {
|
||||
description: `Kakabonnemang – ${b.kg_per_week} kg/vecka`,
|
||||
metadata: {
|
||||
company: b.company,
|
||||
customer_email: b.email,
|
||||
kg_per_week: String(b.kg_per_week),
|
||||
employees: String(b.employees),
|
||||
},
|
||||
},
|
||||
metadata: {
|
||||
company: b.company,
|
||||
customer_email: b.email,
|
||||
kg_per_week: String(b.kg_per_week),
|
||||
},
|
||||
} as any);
|
||||
@@ -251,6 +253,7 @@ Deno.serve(async (req) => {
|
||||
description: `Kakabonnemang – ${b.kg_per_week} kg/vecka`,
|
||||
metadata: {
|
||||
company: b.company,
|
||||
customer_email: b.email,
|
||||
kg_per_week: String(b.kg_per_week),
|
||||
employees: String(b.employees),
|
||||
},
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.0";
|
||||
import { z } from "npm:zod@3.23.8";
|
||||
import { type StripeEnv, createStripeClient } from "../_shared/stripe.ts";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers":
|
||||
"authorization, x-client-info, x-supabase-client-platform, x-supabase-api-version, apikey, content-type",
|
||||
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
||||
};
|
||||
|
||||
const supabase = createClient(
|
||||
Deno.env.get("SUPABASE_URL")!,
|
||||
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
|
||||
);
|
||||
|
||||
const BodySchema = z.object({
|
||||
return_url: z.string().url(),
|
||||
environment: z.enum(["sandbox", "live"]),
|
||||
});
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
if (req.method === "OPTIONS") return new Response("ok", { headers: corsHeaders });
|
||||
if (req.method !== "POST") {
|
||||
return new Response(JSON.stringify({ error: "Method not allowed" }), {
|
||||
status: 405,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const token = req.headers.get("Authorization")?.replace("Bearer ", "") ?? "";
|
||||
const { data: userData, error: authErr } = await supabase.auth.getUser(token);
|
||||
if (authErr || !userData?.user) {
|
||||
return new Response(JSON.stringify({ error: "Ej inloggad" }), {
|
||||
status: 401,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const parsed = BodySchema.safeParse(await req.json());
|
||||
if (!parsed.success) {
|
||||
return new Response(JSON.stringify({ error: "Ogiltiga fält" }), {
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const env: StripeEnv = parsed.data.environment;
|
||||
|
||||
// Hämta senaste subscription för denna user i rätt env
|
||||
const { data: sub } = await supabase
|
||||
.from("subscriptions")
|
||||
.select("stripe_customer_id")
|
||||
.eq("user_id", userData.user.id)
|
||||
.eq("environment", env)
|
||||
.order("created_at", { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
|
||||
if (!sub?.stripe_customer_id) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Inget abonnemang hittades för detta konto." }),
|
||||
{ status: 404, headers: { ...corsHeaders, "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
const stripe = createStripeClient(env);
|
||||
const portal = await stripe.billingPortal.sessions.create({
|
||||
customer: sub.stripe_customer_id as string,
|
||||
return_url: parsed.data.return_url,
|
||||
});
|
||||
|
||||
return new Response(JSON.stringify({ url: portal.url }), {
|
||||
status: 200,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
} catch (e: any) {
|
||||
console.error("create-portal-session error:", e);
|
||||
return new Response(JSON.stringify({ error: e?.message ?? "Kunde inte öppna kundportal" }), {
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,5 +1,106 @@
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.0";
|
||||
import { verifyWebhook, type StripeEnv } from "../_shared/stripe.ts";
|
||||
|
||||
const supabase = createClient(
|
||||
Deno.env.get("SUPABASE_URL")!,
|
||||
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
|
||||
);
|
||||
|
||||
/** Find existing auth user by email, or create one and send magic-link invite. */
|
||||
async function ensureUser(email: string, companyName?: string): Promise<string | null> {
|
||||
if (!email) return null;
|
||||
try {
|
||||
// Search by email (paginated). For low volumes this is fine.
|
||||
const { data: list } = await supabase.auth.admin.listUsers({ page: 1, perPage: 200 });
|
||||
const existing = list?.users?.find((u) => u.email?.toLowerCase() === email.toLowerCase());
|
||||
if (existing) return existing.id;
|
||||
|
||||
// Create + invite so kunden får ett välkomstmejl med länk att sätta lösenord.
|
||||
const siteUrl = Deno.env.get("SUPABASE_URL")?.replace(".supabase.co", ".lovable.app") ?? undefined;
|
||||
const { data: invited, error: inviteErr } = await supabase.auth.admin.inviteUserByEmail(email, {
|
||||
data: { company_name: companyName ?? null },
|
||||
redirectTo: siteUrl ? `${siteUrl}/account` : undefined,
|
||||
});
|
||||
if (inviteErr) {
|
||||
console.error("inviteUserByEmail failed:", inviteErr);
|
||||
return null;
|
||||
}
|
||||
return invited?.user?.id ?? null;
|
||||
} catch (e) {
|
||||
console.error("ensureUser error:", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function upsertSubscription(sub: any, env: StripeEnv) {
|
||||
const item = sub.items?.data?.[0];
|
||||
const priceId = item?.price?.lookup_key || item?.price?.id;
|
||||
const periodStart = item?.current_period_start ?? sub.current_period_start;
|
||||
const periodEnd = item?.current_period_end ?? sub.current_period_end;
|
||||
|
||||
const customerEmail: string =
|
||||
sub.metadata?.customer_email ||
|
||||
sub.customer_email ||
|
||||
(typeof sub.customer === "object" ? sub.customer?.email : null) ||
|
||||
"";
|
||||
const companyName = sub.metadata?.company ?? null;
|
||||
const kgPerWeek = sub.metadata?.kg_per_week ? Number(sub.metadata.kg_per_week) : null;
|
||||
const unitAmount = item?.price?.unit_amount ?? 0;
|
||||
const quantity = item?.quantity ?? 1;
|
||||
const monthlyAmount = (unitAmount * quantity) / 100;
|
||||
|
||||
// Ensure a Lovable Cloud user exists for this email, unless we can already
|
||||
// link via customer metadata.userId (future-proof).
|
||||
let userId: string | null = sub.metadata?.userId ?? null;
|
||||
if (!userId && customerEmail) {
|
||||
userId = await ensureUser(customerEmail, companyName ?? undefined);
|
||||
}
|
||||
|
||||
await supabase.from("subscriptions").upsert(
|
||||
{
|
||||
user_id: userId,
|
||||
customer_email: customerEmail,
|
||||
company_name: companyName,
|
||||
stripe_subscription_id: sub.id,
|
||||
stripe_customer_id: typeof sub.customer === "string" ? sub.customer : sub.customer?.id,
|
||||
price_id: priceId,
|
||||
kg_per_week: kgPerWeek,
|
||||
monthly_amount: monthlyAmount || null,
|
||||
status: sub.status,
|
||||
collection_method: sub.collection_method ?? null,
|
||||
current_period_start: periodStart ? new Date(periodStart * 1000).toISOString() : null,
|
||||
current_period_end: periodEnd ? new Date(periodEnd * 1000).toISOString() : null,
|
||||
cancel_at_period_end: !!sub.cancel_at_period_end,
|
||||
environment: env,
|
||||
updated_at: new Date().toISOString(),
|
||||
},
|
||||
{ onConflict: "stripe_subscription_id" },
|
||||
);
|
||||
}
|
||||
|
||||
async function markCanceled(sub: any, env: StripeEnv) {
|
||||
const periodEnd = sub.items?.data?.[0]?.current_period_end ?? sub.current_period_end;
|
||||
await supabase
|
||||
.from("subscriptions")
|
||||
.update({
|
||||
status: "canceled",
|
||||
cancel_at_period_end: !!sub.cancel_at_period_end,
|
||||
current_period_end: periodEnd ? new Date(periodEnd * 1000).toISOString() : null,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq("stripe_subscription_id", sub.id)
|
||||
.eq("environment", env);
|
||||
}
|
||||
|
||||
async function handleCheckoutCompleted(session: any, env: StripeEnv) {
|
||||
// For subscription checkouts, subscription events kommer separat — men
|
||||
// vi ser till att koppla user_id om vi har e-post redan här.
|
||||
const email = session.customer_details?.email || session.customer_email;
|
||||
if (email) {
|
||||
await ensureUser(email, session.metadata?.company);
|
||||
}
|
||||
}
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
if (req.method !== "POST") return new Response("Method not allowed", { status: 405 });
|
||||
|
||||
@@ -15,7 +116,23 @@ Deno.serve(async (req) => {
|
||||
try {
|
||||
const event = await verifyWebhook(req, env);
|
||||
console.log(`[payments-webhook:${env}] ${event.type}`);
|
||||
// Minimal handler — Stripe dashboard is source of truth for subscriptions.
|
||||
|
||||
switch (event.type) {
|
||||
case "customer.subscription.created":
|
||||
case "customer.subscription.updated":
|
||||
await upsertSubscription(event.data.object, env);
|
||||
break;
|
||||
case "customer.subscription.deleted":
|
||||
await markCanceled(event.data.object, env);
|
||||
break;
|
||||
case "checkout.session.completed":
|
||||
await handleCheckoutCompleted(event.data.object, env);
|
||||
break;
|
||||
default:
|
||||
// ignore
|
||||
break;
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ received: true }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
|
||||
CREATE TABLE public.subscriptions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||
customer_email TEXT NOT NULL,
|
||||
company_name TEXT,
|
||||
stripe_subscription_id TEXT NOT NULL UNIQUE,
|
||||
stripe_customer_id TEXT NOT NULL,
|
||||
price_id TEXT,
|
||||
kg_per_week NUMERIC,
|
||||
monthly_amount NUMERIC,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
collection_method TEXT,
|
||||
current_period_start TIMESTAMPTZ,
|
||||
current_period_end TIMESTAMPTZ,
|
||||
cancel_at_period_end BOOLEAN NOT NULL DEFAULT false,
|
||||
environment TEXT NOT NULL DEFAULT 'sandbox',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_subscriptions_user_id ON public.subscriptions(user_id);
|
||||
CREATE INDEX idx_subscriptions_customer_email ON public.subscriptions(customer_email);
|
||||
CREATE INDEX idx_subscriptions_stripe_id ON public.subscriptions(stripe_subscription_id);
|
||||
|
||||
GRANT SELECT ON public.subscriptions TO authenticated;
|
||||
GRANT ALL ON public.subscriptions TO service_role;
|
||||
|
||||
ALTER TABLE public.subscriptions ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "Users can view own subscriptions"
|
||||
ON public.subscriptions FOR SELECT
|
||||
TO authenticated
|
||||
USING (auth.uid() = user_id);
|
||||
|
||||
CREATE POLICY "Admins can view all subscriptions"
|
||||
ON public.subscriptions FOR SELECT
|
||||
TO authenticated
|
||||
USING (public.has_role(auth.uid(), 'admin'::app_role));
|
||||
|
||||
CREATE TRIGGER update_subscriptions_updated_at
|
||||
BEFORE UPDATE ON public.subscriptions
|
||||
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.has_active_kaka_subscription(user_uuid UUID)
|
||||
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 (
|
||||
(status IN ('active','trialing','past_due') AND (current_period_end IS NULL OR current_period_end > now()))
|
||||
OR (status = 'canceled' AND current_period_end > now())
|
||||
)
|
||||
);
|
||||
$$;
|
||||
@@ -0,0 +1,3 @@
|
||||
|
||||
REVOKE EXECUTE ON FUNCTION public.has_active_kaka_subscription(UUID) FROM PUBLIC, anon, authenticated;
|
||||
GRANT EXECUTE ON FUNCTION public.has_active_kaka_subscription(UUID) TO service_role;
|
||||
Reference in New Issue
Block a user