diff --git a/.github/workflows/stripe-sync.yml b/.github/workflows/stripe-sync.yml new file mode 100644 index 00000000..c85affb6 --- /dev/null +++ b/.github/workflows/stripe-sync.yml @@ -0,0 +1,30 @@ +name: Stripe Product Sync + +on: + push: + branches: [main] + paths: ["stripe/**"] + workflow_dispatch: + inputs: + mode: + description: "Stripe mode" + required: true + default: "test" + type: choice + options: + - test + - live + +jobs: + sync: + runs-on: ubuntu-latest + environment: ${{ github.event.inputs.mode == 'live' && 'production' || 'staging' }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + - run: npm ci + - run: npx tsx stripe/sync.ts + env: + STRIPE_SECRET_KEY: ${{ github.event.inputs.mode == 'live' && secrets.STRIPE_SECRET_KEY_LIVE || secrets.STRIPE_SECRET_KEY_TEST }} diff --git a/actions/cancelSubscription.ts b/actions/cancelSubscription.ts new file mode 100644 index 00000000..96b58bb5 --- /dev/null +++ b/actions/cancelSubscription.ts @@ -0,0 +1,40 @@ +"use server"; + +import { getIdentityData } from "./getIdentityData"; +import { stripe } from "stripe/client"; +import { supabaseServerClient } from "supabase/serverClient"; +import { Ok, Err, type Result } from "src/result"; + +export async function cancelSubscription(): Promise< + Result<{ cancelAt: string }, string> +> { + const identity = await getIdentityData(); + if (!identity) { + return Err("Not authenticated"); + } + + const { data: sub } = await supabaseServerClient + .from("user_subscriptions") + .select("stripe_subscription_id, current_period_end") + .eq("identity_id", identity.id) + .single(); + + if (!sub?.stripe_subscription_id) { + return Err("No active subscription found"); + } + + await stripe.subscriptions.update(sub.stripe_subscription_id, { + cancel_at_period_end: true, + }); + + // Optimistic update + await supabaseServerClient + .from("user_subscriptions") + .update({ + status: "canceling", + updated_at: new Date().toISOString(), + }) + .eq("identity_id", identity.id); + + return Ok({ cancelAt: sub.current_period_end || "" }); +} diff --git a/actions/createCheckoutSession.ts b/actions/createCheckoutSession.ts new file mode 100644 index 00000000..7d6ba1e9 --- /dev/null +++ b/actions/createCheckoutSession.ts @@ -0,0 +1,62 @@ +"use server"; + +import { getIdentityData } from "./getIdentityData"; +import { stripe } from "stripe/client"; +import { supabaseServerClient } from "supabase/serverClient"; +import { PRICE_IDS } from "stripe/products"; +import { Ok, Err, type Result } from "src/result"; + +export async function createCheckoutSession( + cadence: "month" | "year", + returnUrl?: string, +): Promise> { + const identity = await getIdentityData(); + if (!identity) { + return Err("Not authenticated"); + } + + const priceId = PRICE_IDS[cadence]; + if (!priceId) { + return Err("Price not configured. Set STRIPE_PRICE_MONTHLY_ID and STRIPE_PRICE_YEARLY_ID env vars."); + } + + // Check for existing Stripe customer + let customerId: string | undefined; + const { data: existingSub } = await supabaseServerClient + .from("user_subscriptions") + .select("stripe_customer_id") + .eq("identity_id", identity.id) + .single(); + + if (existingSub?.stripe_customer_id) { + customerId = existingSub.stripe_customer_id; + } + + const successUrl = new URL( + "/api/checkout/success", + process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000", + ); + successUrl.searchParams.set("session_id", "{CHECKOUT_SESSION_ID}"); + if (returnUrl) { + successUrl.searchParams.set("return", returnUrl); + } + + const cancelUrl = returnUrl || process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"; + + const session = await stripe.checkout.sessions.create({ + mode: "subscription", + line_items: [{ price: priceId, quantity: 1 }], + client_reference_id: identity.id, + ...(customerId + ? { customer: customerId } + : { customer_email: identity.email || undefined }), + success_url: successUrl.toString(), + cancel_url: cancelUrl, + }); + + if (!session.url) { + return Err("Failed to create checkout session"); + } + + return Ok({ url: session.url }); +} diff --git a/actions/getIdentityData.ts b/actions/getIdentityData.ts index 6ed8e690..2c7cd1ab 100644 --- a/actions/getIdentityData.ts +++ b/actions/getIdentityData.ts @@ -34,7 +34,9 @@ export async function uncachedGetIdentityData() { leaflets_to_documents(*, documents(*)), leaflets_in_publications(*, publications(*), documents(*)) ) - ) + ), + user_subscriptions(plan, status, current_period_end), + user_entitlements(entitlement_key, granted_at, expires_at, source, metadata) )`, ) .eq("identities.notifications.read", false) @@ -43,6 +45,30 @@ export async function uncachedGetIdentityData() { .single() : null; if (!auth_res?.data?.identities) return null; + + // Transform embedded entitlements into a keyed record, filtering expired + const now = new Date().toISOString(); + const entitlements: Record< + string, + { + granted_at: string; + expires_at: string | null; + source: string | null; + metadata: unknown; + } + > = {}; + for (const row of auth_res.data.identities.user_entitlements || []) { + if (row.expires_at && row.expires_at < now) continue; + entitlements[row.entitlement_key] = { + granted_at: row.granted_at, + expires_at: row.expires_at, + source: row.source, + metadata: row.metadata, + }; + } + + const subscription = auth_res.data.identities.user_subscriptions ?? null; + if (auth_res.data.identities.atp_did) { //I should create a relationship table so I can do this in the above query let { data: rawPublications } = await supabaseServerClient @@ -54,8 +80,15 @@ export async function uncachedGetIdentityData() { return { ...auth_res.data.identities, publications, + entitlements, + subscription, }; } - return { ...auth_res.data.identities, publications: [] }; + return { + ...auth_res.data.identities, + publications: [], + entitlements, + subscription, + }; } diff --git a/app/(home-pages)/home/Actions/AccountSettings.tsx b/app/(home-pages)/home/Actions/AccountSettings.tsx index 0f610d53..f640e210 100644 --- a/app/(home-pages)/home/Actions/AccountSettings.tsx +++ b/app/(home-pages)/home/Actions/AccountSettings.tsx @@ -14,6 +14,7 @@ import { useIsMobile } from "src/hooks/isMobile"; import { ManageProSubscription } from "app/lish/[did]/[publication]/dashboard/settings/ManageProSubscription"; import { Modal } from "components/Modal"; import { UpgradeContent } from "app/lish/[did]/[publication]/UpgradeModal"; +import { useIsPro } from "src/hooks/useEntitlement"; export const AccountSettings = (props: { entityID: string }) => { let [state, setState] = useState< @@ -53,7 +54,7 @@ const SettingsMenu = (props: { let menuItemClassName = "menuItem -mx-[8px] text-left flex items-center justify-between hover:no-underline!"; - let isPro = true; + let isPro = useIsPro(); return (
diff --git a/app/api/checkout/success/route.ts b/app/api/checkout/success/route.ts new file mode 100644 index 00000000..69baf033 --- /dev/null +++ b/app/api/checkout/success/route.ts @@ -0,0 +1,70 @@ +import { NextRequest, NextResponse } from "next/server"; +import { stripe } from "stripe/client"; +import { supabaseServerClient } from "supabase/serverClient"; +import { parseEntitlements } from "stripe/products"; + +export async function GET(req: NextRequest) { + const sessionId = req.nextUrl.searchParams.get("session_id"); + const returnUrl = req.nextUrl.searchParams.get("return") || "/"; + + if (!sessionId) { + return NextResponse.redirect(new URL(returnUrl, req.url)); + } + + try { + const session = await stripe.checkout.sessions.retrieve(sessionId, { + expand: ["subscription", "subscription.items.data.price.product"], + }); + + const identityId = session.client_reference_id; + const customerId = session.customer as string; + const sub = + typeof session.subscription === "object" ? session.subscription : null; + + if (identityId && sub) { + const priceItem = sub.items.data[0]; + const product = + priceItem?.price.product && + typeof priceItem.price.product === "object" && + !("deleted" in priceItem.price.product) + ? priceItem.price.product + : null; + const periodEnd = priceItem?.current_period_end ?? 0; + + // Optimistic upsert — idempotent with webhook handler + await supabaseServerClient.from("user_subscriptions").upsert( + { + identity_id: identityId, + stripe_customer_id: customerId, + stripe_subscription_id: sub.id, + plan: product?.name || "Leaflet Pro", + status: sub.status, + current_period_end: new Date(periodEnd * 1000).toISOString(), + updated_at: new Date().toISOString(), + }, + { onConflict: "identity_id" }, + ); + + const entitlements = product + ? parseEntitlements(product.metadata) + : { publication_analytics: true }; + + for (const key of Object.keys(entitlements)) { + await supabaseServerClient.from("user_entitlements").upsert( + { + identity_id: identityId, + entitlement_key: key, + granted_at: new Date().toISOString(), + expires_at: new Date(periodEnd * 1000).toISOString(), + source: `stripe:${sub.id}`, + }, + { onConflict: "identity_id,entitlement_key" }, + ); + } + } + } catch (err) { + console.error("Error processing checkout success:", err); + } + + return NextResponse.redirect(new URL(returnUrl, req.url)); +} diff --git a/app/api/inngest/client.ts b/app/api/inngest/client.ts index 4dceb80f..e3b6617e 100644 --- a/app/api/inngest/client.ts +++ b/app/api/inngest/client.ts @@ -61,6 +61,28 @@ export type Events = { }>; }; }; + "stripe/checkout.session.completed": { + data: { + sessionId: string; + }; + }; + "stripe/customer.subscription.updated": { + data: { + subscriptionId: string; + }; + }; + "stripe/customer.subscription.deleted": { + data: { + subscriptionId: string; + }; + }; + "stripe/invoice.payment.failed": { + data: { + invoiceId: string; + subscriptionId: string; + customerId: string; + }; + }; }; // Create a client to send and receive events diff --git a/app/api/inngest/functions/stripe_handle_checkout_completed.ts b/app/api/inngest/functions/stripe_handle_checkout_completed.ts new file mode 100644 index 00000000..8d2ae850 --- /dev/null +++ b/app/api/inngest/functions/stripe_handle_checkout_completed.ts @@ -0,0 +1,82 @@ +import { inngest } from "../client"; +import { stripe } from "stripe/client"; +import { supabaseServerClient } from "supabase/serverClient"; +import { parseEntitlements } from "stripe/products"; + +export const stripe_handle_checkout_completed = inngest.createFunction( + { id: "stripe-handle-checkout-completed" }, + { event: "stripe/checkout.session.completed" }, + async ({ event, step }) => { + const session = await step.run("fetch-checkout-session", async () => { + const s = await stripe.checkout.sessions.retrieve(event.data.sessionId, { + expand: ["subscription", "subscription.items.data.price.product"], + }); + const sub = + typeof s.subscription === "object" ? s.subscription : null; + const priceItem = sub?.items.data[0]; + const product = + priceItem?.price.product && + typeof priceItem.price.product === "object" && + !("deleted" in priceItem.price.product) + ? priceItem.price.product + : null; + const periodEnd = priceItem?.current_period_end ?? 0; + + return { + identityId: s.client_reference_id, + customerId: s.customer as string, + subId: sub?.id ?? null, + subStatus: sub?.status ?? null, + periodEnd, + productName: product?.name || "Leaflet Pro", + productMetadata: product?.metadata ?? null, + }; + }); + + if (!session.identityId || !session.subId) { + throw new Error("Missing client_reference_id or subscription"); + } + + await step.run("upsert-subscription-and-entitlements", async () => { + // Upsert user_subscriptions + await supabaseServerClient.from("user_subscriptions").upsert( + { + identity_id: session.identityId!, + stripe_customer_id: session.customerId, + stripe_subscription_id: session.subId!, + plan: session.productName, + status: session.subStatus, + current_period_end: new Date( + session.periodEnd * 1000, + ).toISOString(), + updated_at: new Date().toISOString(), + }, + { onConflict: "identity_id" }, + ); + + // Parse entitlements from product metadata and upsert + const entitlements = session.productMetadata + ? parseEntitlements( + session.productMetadata as Record, + ) + : { publication_analytics: true }; + + for (const key of Object.keys(entitlements)) { + await supabaseServerClient.from("user_entitlements").upsert( + { + identity_id: session.identityId!, + entitlement_key: key, + granted_at: new Date().toISOString(), + expires_at: new Date( + session.periodEnd * 1000, + ).toISOString(), + source: `stripe:${session.subId}`, + }, + { onConflict: "identity_id,entitlement_key" }, + ); + } + }); + + return { success: true }; + }, +); diff --git a/app/api/inngest/functions/stripe_handle_invoice_payment_failed.ts b/app/api/inngest/functions/stripe_handle_invoice_payment_failed.ts new file mode 100644 index 00000000..ff9bead7 --- /dev/null +++ b/app/api/inngest/functions/stripe_handle_invoice_payment_failed.ts @@ -0,0 +1,23 @@ +import { inngest } from "../client"; +import { supabaseServerClient } from "supabase/serverClient"; + +export const stripe_handle_invoice_payment_failed = inngest.createFunction( + { id: "stripe-handle-invoice-payment-failed" }, + { event: "stripe/invoice.payment.failed" }, + async ({ event, step }) => { + await step.run("mark-subscription-past-due", async () => { + if (event.data.subscriptionId) { + await supabaseServerClient + .from("user_subscriptions") + .update({ + status: "past_due", + updated_at: new Date().toISOString(), + }) + .eq("stripe_subscription_id", event.data.subscriptionId); + } + }); + + // Entitlements remain valid until expires_at + return { success: true }; + }, +); diff --git a/app/api/inngest/functions/stripe_handle_subscription_deleted.ts b/app/api/inngest/functions/stripe_handle_subscription_deleted.ts new file mode 100644 index 00000000..1ebd839f --- /dev/null +++ b/app/api/inngest/functions/stripe_handle_subscription_deleted.ts @@ -0,0 +1,21 @@ +import { inngest } from "../client"; +import { supabaseServerClient } from "supabase/serverClient"; + +export const stripe_handle_subscription_deleted = inngest.createFunction( + { id: "stripe-handle-subscription-deleted" }, + { event: "stripe/customer.subscription.deleted" }, + async ({ event, step }) => { + await step.run("mark-subscription-canceled", async () => { + await supabaseServerClient + .from("user_subscriptions") + .update({ + status: "canceled", + updated_at: new Date().toISOString(), + }) + .eq("stripe_subscription_id", event.data.subscriptionId); + }); + + // Entitlements expire naturally via expires_at — no need to delete them + return { success: true }; + }, +); diff --git a/app/api/inngest/functions/stripe_handle_subscription_updated.ts b/app/api/inngest/functions/stripe_handle_subscription_updated.ts new file mode 100644 index 00000000..83c51b8d --- /dev/null +++ b/app/api/inngest/functions/stripe_handle_subscription_updated.ts @@ -0,0 +1,84 @@ +import { inngest } from "../client"; +import { stripe } from "stripe/client"; +import { supabaseServerClient } from "supabase/serverClient"; +import { parseEntitlements } from "stripe/products"; + +export const stripe_handle_subscription_updated = inngest.createFunction( + { id: "stripe-handle-subscription-updated" }, + { event: "stripe/customer.subscription.updated" }, + async ({ event, step }) => { + const subData = await step.run("fetch-subscription", async () => { + const sub = await stripe.subscriptions.retrieve( + event.data.subscriptionId, + { expand: ["items.data.price.product"] }, + ); + const priceItem = sub.items.data[0]; + const product = + priceItem?.price.product && + typeof priceItem.price.product === "object" && + !("deleted" in priceItem.price.product) + ? priceItem.price.product + : null; + const periodEnd = priceItem?.current_period_end ?? 0; + + return { + id: sub.id, + customerId: sub.customer as string, + status: sub.status, + periodEnd, + productName: product?.name || "Leaflet Pro", + productMetadata: product?.metadata ?? null, + }; + }); + + await step.run("update-subscription-and-entitlements", async () => { + // Find the identity by stripe_customer_id + const { data: existingSub } = await supabaseServerClient + .from("user_subscriptions") + .select("identity_id") + .eq("stripe_customer_id", subData.customerId) + .single(); + + if (!existingSub) { + console.warn( + `No subscription record for customer ${subData.customerId}`, + ); + return; + } + + // Update subscription record + await supabaseServerClient + .from("user_subscriptions") + .update({ + status: subData.status, + plan: subData.productName, + current_period_end: new Date( + subData.periodEnd * 1000, + ).toISOString(), + updated_at: new Date().toISOString(), + }) + .eq("identity_id", existingSub.identity_id); + + // Update entitlement expiry dates for all entitlements from this subscription + const entitlements = subData.productMetadata + ? parseEntitlements( + subData.productMetadata as Record, + ) + : {}; + for (const key of Object.keys(entitlements)) { + await supabaseServerClient + .from("user_entitlements") + .update({ + expires_at: new Date( + subData.periodEnd * 1000, + ).toISOString(), + }) + .eq("identity_id", existingSub.identity_id) + .eq("entitlement_key", key) + .eq("source", `stripe:${subData.id}`); + } + }); + + return { success: true }; + }, +); diff --git a/app/api/inngest/route.tsx b/app/api/inngest/route.tsx index ce86472d..47fc2a22 100644 --- a/app/api/inngest/route.tsx +++ b/app/api/inngest/route.tsx @@ -13,6 +13,10 @@ import { check_oauth_session, } from "./functions/cleanup_expired_oauth_sessions"; import { write_records_to_pds } from "./functions/write_records_to_pds"; +import { stripe_handle_checkout_completed } from "./functions/stripe_handle_checkout_completed"; +import { stripe_handle_subscription_updated } from "./functions/stripe_handle_subscription_updated"; +import { stripe_handle_subscription_deleted } from "./functions/stripe_handle_subscription_deleted"; +import { stripe_handle_invoice_payment_failed } from "./functions/stripe_handle_invoice_payment_failed"; export const { GET, POST, PUT } = serve({ client: inngest, @@ -28,5 +32,9 @@ export const { GET, POST, PUT } = serve({ cleanup_expired_oauth_sessions, check_oauth_session, write_records_to_pds, + stripe_handle_checkout_completed, + stripe_handle_subscription_updated, + stripe_handle_subscription_deleted, + stripe_handle_invoice_payment_failed, ], }); diff --git a/app/api/webhooks/stripe/route.ts b/app/api/webhooks/stripe/route.ts new file mode 100644 index 00000000..66f1bdd1 --- /dev/null +++ b/app/api/webhooks/stripe/route.ts @@ -0,0 +1,67 @@ +import { NextRequest, NextResponse } from "next/server"; +import { stripe } from "stripe/client"; +import { inngest } from "app/api/inngest/client"; + +export async function POST(req: NextRequest) { + const body = await req.text(); + const signature = req.headers.get("stripe-signature"); + if (!signature) { + return NextResponse.json({ error: "Missing signature" }, { status: 400 }); + } + + let event; + try { + event = stripe.webhooks.constructEvent( + body, + signature, + process.env.STRIPE_WEBHOOK_SECRET as string, + ); + } catch (err) { + console.error("Stripe webhook signature verification failed:", err); + return NextResponse.json({ error: "Invalid signature" }, { status: 400 }); + } + + switch (event.type) { + case "checkout.session.completed": + await inngest.send({ + name: "stripe/checkout.session.completed", + data: { sessionId: event.data.object.id }, + }); + break; + + case "customer.subscription.created": + case "customer.subscription.updated": + await inngest.send({ + name: "stripe/customer.subscription.updated", + data: { subscriptionId: event.data.object.id }, + }); + break; + + case "customer.subscription.deleted": + await inngest.send({ + name: "stripe/customer.subscription.deleted", + data: { subscriptionId: event.data.object.id }, + }); + break; + + case "invoice.payment_failed": { + const invoice = event.data.object; + const subDetails = invoice.parent?.subscription_details; + const subId = + typeof subDetails?.subscription === "string" + ? subDetails.subscription + : subDetails?.subscription?.id || ""; + await inngest.send({ + name: "stripe/invoice.payment.failed", + data: { + invoiceId: invoice.id, + subscriptionId: subId, + customerId: invoice.customer as string, + }, + }); + break; + } + } + + return NextResponse.json({ received: true }); +} diff --git a/app/lish/[did]/[publication]/UpgradeModal.tsx b/app/lish/[did]/[publication]/UpgradeModal.tsx index 2f632feb..4ef23710 100644 --- a/app/lish/[did]/[publication]/UpgradeModal.tsx +++ b/app/lish/[did]/[publication]/UpgradeModal.tsx @@ -1,9 +1,26 @@ import { ButtonPrimary } from "components/Buttons"; import { Modal } from "components/Modal"; import { useState } from "react"; +import { createCheckoutSession } from "actions/createCheckoutSession"; +import { DotLoader } from "components/utils/DotLoader"; export const UpgradeContent = () => { let [cadence, setCadence] = useState<"year" | "month">("year"); + let [loading, setLoading] = useState(false); + let [error, setError] = useState(null); + + async function handleCheckout() { + setLoading(true); + setError(null); + let result = await createCheckoutSession(cadence, window.location.href); + if (result.ok) { + window.location.href = result.value.url; + } else { + setError(result.error); + setLoading(false); + } + } + return (

Get Leaflet Pro!

@@ -42,9 +59,17 @@ export const UpgradeContent = () => { {cadence === "year" ? "/year" : "/month"}
- - Get it! + + {loading ? : "Get it!"} + {error && ( +
{error}
+ )} diff --git a/app/lish/[did]/[publication]/dashboard/Actions.tsx b/app/lish/[did]/[publication]/dashboard/Actions.tsx index 40787905..eb661306 100644 --- a/app/lish/[did]/[publication]/dashboard/Actions.tsx +++ b/app/lish/[did]/[publication]/dashboard/Actions.tsx @@ -13,12 +13,14 @@ import { SpeedyLink } from "components/SpeedyLink"; import { ButtonSecondary, ButtonTertiary } from "components/Buttons"; import { UpgradeModal } from "../UpgradeModal"; import { LeafletPro } from "components/Icons/LeafletPro"; +import { useIsPro } from "src/hooks/useEntitlement"; export const Actions = (props: { publication: string }) => { + let isPro = useIsPro(); return ( <> - + {!isPro && } diff --git a/app/lish/[did]/[publication]/dashboard/PublicationAnalytics.tsx b/app/lish/[did]/[publication]/dashboard/PublicationAnalytics.tsx index cd8ab76b..8230c9b8 100644 --- a/app/lish/[did]/[publication]/dashboard/PublicationAnalytics.tsx +++ b/app/lish/[did]/[publication]/dashboard/PublicationAnalytics.tsx @@ -11,6 +11,7 @@ import { ComboboxResult, useComboboxState, } from "components/Combobox"; +import { useIsPro } from "src/hooks/useEntitlement"; type referrorType = { iconSrc: string; name: string; viewCount: string }; let refferors = [ @@ -21,7 +22,7 @@ let refferors = [ ]; export const PublicationAnalytics = () => { - let isPro = true; + let isPro = useIsPro(); let { data: publication } = usePublicationData(); let [dateRange, setDateRange] = useState({ from: undefined }); diff --git a/app/lish/[did]/[publication]/dashboard/settings/ManageProSubscription.tsx b/app/lish/[did]/[publication]/dashboard/settings/ManageProSubscription.tsx index 4163b409..e5a220a6 100644 --- a/app/lish/[did]/[publication]/dashboard/settings/ManageProSubscription.tsx +++ b/app/lish/[did]/[publication]/dashboard/settings/ManageProSubscription.tsx @@ -1,11 +1,37 @@ import { useState } from "react"; import { ButtonPrimary } from "components/Buttons"; import { PubSettingsHeader } from "./PublicationSettings"; +import { cancelSubscription } from "actions/cancelSubscription"; +import { useIdentityData } from "components/IdentityProvider"; +import { DotLoader } from "components/utils/DotLoader"; +import { useLocalizedDate } from "src/hooks/useLocalizedDate"; export const ManageProSubscription = (props: { backToMenu: () => void }) => { const [state, setState] = useState<"manage" | "confirm" | "success">( "manage", ); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const { identity, mutate } = useIdentityData(); + + const subscription = identity?.subscription; + const renewalDate = useLocalizedDate( + subscription?.current_period_end || new Date().toISOString(), + { month: "long", day: "numeric" }, + ); + + async function handleCancel() { + setLoading(true); + setError(null); + let result = await cancelSubscription(); + if (result.ok) { + setState("success"); + mutate(); + } else { + setError(result.error); + } + setLoading(false); + } return (
@@ -17,17 +43,23 @@ export const ManageProSubscription = (props: { backToMenu: () => void }) => { <>
You have a
- Pro monthly subscription -
$12/mo
- Renews on the 12th + {subscription?.plan || "Pro"} subscription +
+ {subscription?.plan || "Leaflet Pro"} +
+ {subscription?.status === "canceling" + ? `Access until ${renewalDate}` + : `Renews on ${renewalDate}`}
- setState("confirm")} - > - Cancel Subscription - + {subscription?.status !== "canceling" && ( + setState("confirm")} + > + Cancel Subscription + + )} )} {state === "confirm" && ( @@ -36,14 +68,21 @@ export const ManageProSubscription = (props: { backToMenu: () => void }) => { setState("success")} + onClick={handleCancel} + disabled={loading} > - Yes, Cancel it + {loading ? : "Yes, Cancel it"} + {error && ( +
{error}
+ )} )} {state === "success" && ( -
Your subscription has been successfully cancelled!
+
+ Your subscription has been cancelled. You'll have access until{" "} + {renewalDate}. +
)}
diff --git a/app/lish/[did]/[publication]/dashboard/settings/PublicationSettings.tsx b/app/lish/[did]/[publication]/dashboard/settings/PublicationSettings.tsx index af633a29..42c5c7be 100644 --- a/app/lish/[did]/[publication]/dashboard/settings/PublicationSettings.tsx +++ b/app/lish/[did]/[publication]/dashboard/settings/PublicationSettings.tsx @@ -16,6 +16,7 @@ import { PostOptions } from "./PostOptions"; import { UpgradeContent } from "../../UpgradeModal"; import { Modal } from "components/Modal"; import { ManageProSubscription } from "./ManageProSubscription"; +import { useIsPro } from "src/hooks/useEntitlement"; type menuState = | "menu" @@ -85,7 +86,7 @@ const PubSettingsMenu = (props: { }) => { let menuItemClassName = "menuItem -mx-[8px] text-left flex items-center justify-between hover:no-underline!"; - let isPro = true; + let isPro = useIsPro(); return (
diff --git a/app/lish/createPub/UpdatePubForm.tsx b/app/lish/createPub/UpdatePubForm.tsx index 3672c5f8..ab50576f 100644 --- a/app/lish/createPub/UpdatePubForm.tsx +++ b/app/lish/createPub/UpdatePubForm.tsx @@ -99,7 +99,6 @@ export const EditPubForm = (props: { loading={props.loading} setLoadingAction={props.setLoadingAction} backToMenuAction={props.backToMenuAction} - state={"theme"} > General Settings diff --git a/components/ThemeManager/PubThemeSetter.tsx b/components/ThemeManager/PubThemeSetter.tsx index e49ffbe1..f626840e 100644 --- a/components/ThemeManager/PubThemeSetter.tsx +++ b/components/ThemeManager/PubThemeSetter.tsx @@ -119,7 +119,6 @@ export const PubThemeSetter = (props: { loading={props.loading} setLoadingAction={props.setLoading} backToMenuAction={props.backToMenu} - state={"theme"} > Theme and Layout diff --git a/drizzle/schema.ts b/drizzle/schema.ts index 65cd9add..8580149c 100644 --- a/drizzle/schema.ts +++ b/drizzle/schema.ts @@ -412,4 +412,37 @@ export const leaflets_in_publications = pgTable("leaflets_in_publications", { publication_idx: index("leaflets_in_publications_publication_idx").on(table.publication), leaflets_in_publications_pkey: primaryKey({ columns: [table.publication, table.leaflet], name: "leaflets_in_publications_pkey"}), } +}); + +export const user_subscriptions = pgTable("user_subscriptions", { + identity_id: uuid("identity_id").primaryKey().notNull().references(() => identities.id, { onDelete: "cascade" }), + stripe_customer_id: text("stripe_customer_id").notNull(), + stripe_subscription_id: text("stripe_subscription_id"), + plan: text("plan"), + status: text("status"), + current_period_end: timestamp("current_period_end", { withTimezone: true, mode: 'string' }), + created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), + updated_at: timestamp("updated_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), +}, +(table) => { + return { + user_subscriptions_stripe_customer_id_key: unique("user_subscriptions_stripe_customer_id_key").on(table.stripe_customer_id), + user_subscriptions_stripe_subscription_id_key: unique("user_subscriptions_stripe_subscription_id_key").on(table.stripe_subscription_id), + } +}); + +export const user_entitlements = pgTable("user_entitlements", { + identity_id: uuid("identity_id").notNull().references(() => identities.id, { onDelete: "cascade" }), + entitlement_key: text("entitlement_key").notNull(), + granted_at: timestamp("granted_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), + expires_at: timestamp("expires_at", { withTimezone: true, mode: 'string' }), + source: text("source"), + metadata: jsonb("metadata"), +}, +(table) => { + return { + identity_id_idx: index("user_entitlements_identity_id_idx").on(table.identity_id), + expires_at_idx: index("user_entitlements_expires_at_idx").on(table.expires_at), + user_entitlements_pkey: primaryKey({ columns: [table.identity_id, table.entitlement_key], name: "user_entitlements_pkey"}), + } }); \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 65a85d2a..870f4119 100644 --- a/package-lock.json +++ b/package-lock.json @@ -77,6 +77,7 @@ "replicache": "^15.3.0", "sharp": "^0.34.4", "shiki": "^3.8.1", + "stripe": "^20.4.0", "swr": "^2.3.3", "thumbhash": "^0.1.1", "twilio": "^5.3.7", @@ -18234,6 +18235,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/stripe": { + "version": "20.4.0", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-20.4.0.tgz", + "integrity": "sha512-F/aN1IQ9vHmlyLNi3DkiIbyzQb6gyBG0uYFd/VrEVQSc9BLtlgknPUx0EvzZdBMRLFuRaPFIFd7Mxwtg7Pbwzw==", + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "@types/node": ">=16" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/style-to-object": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.8.tgz", diff --git a/package.json b/package.json index e3f13bed..3e22bd04 100644 --- a/package.json +++ b/package.json @@ -88,6 +88,7 @@ "replicache": "^15.3.0", "sharp": "^0.34.4", "shiki": "^3.8.1", + "stripe": "^20.4.0", "swr": "^2.3.3", "thumbhash": "^0.1.1", "twilio": "^5.3.7", diff --git a/src/hooks/useEntitlement.ts b/src/hooks/useEntitlement.ts new file mode 100644 index 00000000..f3e906a9 --- /dev/null +++ b/src/hooks/useEntitlement.ts @@ -0,0 +1,11 @@ +import { useIdentityData } from "components/IdentityProvider"; + +export function useHasEntitlement(key: string): boolean { + const { identity } = useIdentityData(); + if (!identity?.entitlements) return false; + return key in identity.entitlements; +} + +export function useIsPro(): boolean { + return useHasEntitlement("publication_analytics"); +} diff --git a/stripe/client.ts b/stripe/client.ts new file mode 100644 index 00000000..33c065b6 --- /dev/null +++ b/stripe/client.ts @@ -0,0 +1,5 @@ +import Stripe from "stripe"; + +export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string, { + apiVersion: "2026-02-25.clover", +}); diff --git a/stripe/products.ts b/stripe/products.ts new file mode 100644 index 00000000..35ac6cab --- /dev/null +++ b/stripe/products.ts @@ -0,0 +1,41 @@ +export const PRODUCT_DEF_ID = "leaflet_pro_v1"; + +export const PRODUCT_DEFINITION = { + name: "Leaflet Pro", + metadata: { + product_def_id: PRODUCT_DEF_ID, + entitlements: JSON.stringify({ publication_analytics: true }), + }, +}; + +export const PRICE_DEFINITIONS = { + month: { + lookup_key: "leaflet_pro_monthly_v1_usd", + unit_amount: 1200, + currency: "usd", + recurring: { interval: "month" as const }, + }, + year: { + lookup_key: "leaflet_pro_yearly_v1_usd", + unit_amount: 12000, + currency: "usd", + recurring: { interval: "year" as const }, + }, +}; + +// Populated at runtime by sync script or looked up dynamically +export const PRICE_IDS: Record<"month" | "year", string> = { + month: process.env.STRIPE_PRICE_MONTHLY_ID || "", + year: process.env.STRIPE_PRICE_YEARLY_ID || "", +}; + +export function parseEntitlements( + metadata: Record | null, +): Record { + if (!metadata?.entitlements) return {}; + try { + return JSON.parse(metadata.entitlements); + } catch { + return {}; + } +} diff --git a/stripe/sync.ts b/stripe/sync.ts new file mode 100644 index 00000000..2a1f3e81 --- /dev/null +++ b/stripe/sync.ts @@ -0,0 +1,62 @@ +import Stripe from "stripe"; +import { PRODUCT_DEF_ID, PRODUCT_DEFINITION, PRICE_DEFINITIONS } from "./products"; + +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string, { + apiVersion: "2026-02-25.clover", +}); + +async function sync() { + console.log("Syncing Stripe products and prices..."); + + // Find or create product + let product: Stripe.Product | undefined; + const existing = await stripe.products.search({ + query: `metadata["product_def_id"]:"${PRODUCT_DEF_ID}"`, + }); + + if (existing.data.length > 0) { + product = existing.data[0]; + console.log(`Found existing product: ${product.id}`); + // Update if name or metadata changed + product = await stripe.products.update(product.id, { + name: PRODUCT_DEFINITION.name, + metadata: PRODUCT_DEFINITION.metadata, + }); + console.log(`Updated product: ${product.id}`); + } else { + product = await stripe.products.create({ + name: PRODUCT_DEFINITION.name, + metadata: PRODUCT_DEFINITION.metadata, + }); + console.log(`Created product: ${product.id}`); + } + + // Sync prices by lookup_key + for (const [cadence, def] of Object.entries(PRICE_DEFINITIONS)) { + const existingPrices = await stripe.prices.list({ + lookup_keys: [def.lookup_key], + }); + + if (existingPrices.data.length > 0) { + console.log( + `Price "${def.lookup_key}" already exists: ${existingPrices.data[0].id}`, + ); + } else { + const price = await stripe.prices.create({ + product: product.id, + unit_amount: def.unit_amount, + currency: def.currency, + recurring: def.recurring, + lookup_key: def.lookup_key, + }); + console.log(`Created price "${def.lookup_key}": ${price.id}`); + } + } + + console.log("Sync complete."); +} + +sync().catch((err) => { + console.error("Sync failed:", err); + process.exit(1); +}); diff --git a/supabase/database.types.ts b/supabase/database.types.ts index edbfe86e..6ffb2124 100644 --- a/supabase/database.types.ts +++ b/supabase/database.types.ts @@ -1295,6 +1295,82 @@ export type Database = { }, ] } + user_entitlements: { + Row: { + entitlement_key: string + expires_at: string | null + granted_at: string + identity_id: string + metadata: Json | null + source: string | null + } + Insert: { + entitlement_key: string + expires_at?: string | null + granted_at?: string + identity_id: string + metadata?: Json | null + source?: string | null + } + Update: { + entitlement_key?: string + expires_at?: string | null + granted_at?: string + identity_id?: string + metadata?: Json | null + source?: string | null + } + Relationships: [ + { + foreignKeyName: "user_entitlements_identity_id_fkey" + columns: ["identity_id"] + isOneToOne: false + referencedRelation: "identities" + referencedColumns: ["id"] + }, + ] + } + user_subscriptions: { + Row: { + created_at: string + current_period_end: string | null + identity_id: string + plan: string | null + status: string | null + stripe_customer_id: string + stripe_subscription_id: string | null + updated_at: string + } + Insert: { + created_at?: string + current_period_end?: string | null + identity_id: string + plan?: string | null + status?: string | null + stripe_customer_id: string + stripe_subscription_id?: string | null + updated_at?: string + } + Update: { + created_at?: string + current_period_end?: string | null + identity_id?: string + plan?: string | null + status?: string | null + stripe_customer_id?: string + stripe_subscription_id?: string | null + updated_at?: string + } + Relationships: [ + { + foreignKeyName: "user_subscriptions_identity_id_fkey" + columns: ["identity_id"] + isOneToOne: true + referencedRelation: "identities" + referencedColumns: ["id"] + }, + ] + } } Views: { [_ in never]: never diff --git a/supabase/migrations/20260225000000_add_subscription_tables.sql b/supabase/migrations/20260225000000_add_subscription_tables.sql new file mode 100644 index 00000000..1a9792bb --- /dev/null +++ b/supabase/migrations/20260225000000_add_subscription_tables.sql @@ -0,0 +1,93 @@ +-- user_subscriptions: tracks Stripe subscription state per identity +create table "public"."user_subscriptions" ( + "identity_id" uuid not null, + "stripe_customer_id" text not null, + "stripe_subscription_id" text, + "plan" text, + "status" text, + "current_period_end" timestamp with time zone, + "created_at" timestamp with time zone not null default now(), + "updated_at" timestamp with time zone not null default now() +); + +alter table "public"."user_subscriptions" enable row level security; + +CREATE UNIQUE INDEX user_subscriptions_pkey ON public.user_subscriptions USING btree (identity_id); + +alter table "public"."user_subscriptions" add constraint "user_subscriptions_pkey" PRIMARY KEY using index "user_subscriptions_pkey"; + +CREATE UNIQUE INDEX user_subscriptions_stripe_customer_id_key ON public.user_subscriptions USING btree (stripe_customer_id); + +CREATE UNIQUE INDEX user_subscriptions_stripe_subscription_id_key ON public.user_subscriptions USING btree (stripe_subscription_id); + +alter table "public"."user_subscriptions" add constraint "user_subscriptions_identity_id_fkey" FOREIGN KEY (identity_id) REFERENCES identities(id) ON DELETE CASCADE; + +grant delete on table "public"."user_subscriptions" to "anon"; +grant insert on table "public"."user_subscriptions" to "anon"; +grant references on table "public"."user_subscriptions" to "anon"; +grant select on table "public"."user_subscriptions" to "anon"; +grant trigger on table "public"."user_subscriptions" to "anon"; +grant truncate on table "public"."user_subscriptions" to "anon"; +grant update on table "public"."user_subscriptions" to "anon"; + +grant delete on table "public"."user_subscriptions" to "authenticated"; +grant insert on table "public"."user_subscriptions" to "authenticated"; +grant references on table "public"."user_subscriptions" to "authenticated"; +grant select on table "public"."user_subscriptions" to "authenticated"; +grant trigger on table "public"."user_subscriptions" to "authenticated"; +grant truncate on table "public"."user_subscriptions" to "authenticated"; +grant update on table "public"."user_subscriptions" to "authenticated"; + +grant delete on table "public"."user_subscriptions" to "service_role"; +grant insert on table "public"."user_subscriptions" to "service_role"; +grant references on table "public"."user_subscriptions" to "service_role"; +grant select on table "public"."user_subscriptions" to "service_role"; +grant trigger on table "public"."user_subscriptions" to "service_role"; +grant truncate on table "public"."user_subscriptions" to "service_role"; +grant update on table "public"."user_subscriptions" to "service_role"; + +-- user_entitlements: feature access decoupled from billing +create table "public"."user_entitlements" ( + "identity_id" uuid not null, + "entitlement_key" text not null, + "granted_at" timestamp with time zone not null default now(), + "expires_at" timestamp with time zone, + "source" text, + "metadata" jsonb +); + +alter table "public"."user_entitlements" enable row level security; + +CREATE UNIQUE INDEX user_entitlements_pkey ON public.user_entitlements USING btree (identity_id, entitlement_key); + +alter table "public"."user_entitlements" add constraint "user_entitlements_pkey" PRIMARY KEY using index "user_entitlements_pkey"; + +CREATE INDEX user_entitlements_identity_id_idx ON public.user_entitlements USING btree (identity_id); + +CREATE INDEX user_entitlements_expires_at_idx ON public.user_entitlements USING btree (expires_at); + +alter table "public"."user_entitlements" add constraint "user_entitlements_identity_id_fkey" FOREIGN KEY (identity_id) REFERENCES identities(id) ON DELETE CASCADE; + +grant delete on table "public"."user_entitlements" to "anon"; +grant insert on table "public"."user_entitlements" to "anon"; +grant references on table "public"."user_entitlements" to "anon"; +grant select on table "public"."user_entitlements" to "anon"; +grant trigger on table "public"."user_entitlements" to "anon"; +grant truncate on table "public"."user_entitlements" to "anon"; +grant update on table "public"."user_entitlements" to "anon"; + +grant delete on table "public"."user_entitlements" to "authenticated"; +grant insert on table "public"."user_entitlements" to "authenticated"; +grant references on table "public"."user_entitlements" to "authenticated"; +grant select on table "public"."user_entitlements" to "authenticated"; +grant trigger on table "public"."user_entitlements" to "authenticated"; +grant truncate on table "public"."user_entitlements" to "authenticated"; +grant update on table "public"."user_entitlements" to "authenticated"; + +grant delete on table "public"."user_entitlements" to "service_role"; +grant insert on table "public"."user_entitlements" to "service_role"; +grant references on table "public"."user_entitlements" to "service_role"; +grant select on table "public"."user_entitlements" to "service_role"; +grant trigger on table "public"."user_entitlements" to "service_role"; +grant truncate on table "public"."user_entitlements" to "service_role"; +grant update on table "public"."user_entitlements" to "service_role";