Initial commit: PodcastYes — AI podcast platform

This commit is contained in:
Leon Serfaty
2026-06-07 03:58:32 -04:00
commit 155507f21a
151 changed files with 19826 additions and 0 deletions
+50
View File
@@ -0,0 +1,50 @@
import { PLAN_ORDER, type PlanKey } from "./plans";
export type BillingInterval = "month" | "year";
/**
* Maps plan keys ↔ provider price/plan IDs, read from env. This is the bridge
* between our provider-agnostic plan catalog and Stripe/PayPal. Set the matching
* env vars (STRIPE_PRICE_*, PAYPAL_PLAN_*) for the paid tiers.
*/
const STRIPE_PRICE_ENV: Record<Exclude<PlanKey, "free">, Record<BillingInterval, string | undefined>> = {
creator: { month: process.env.STRIPE_PRICE_CREATOR_MONTHLY, year: process.env.STRIPE_PRICE_CREATOR_YEARLY },
pro: { month: process.env.STRIPE_PRICE_PRO_MONTHLY, year: process.env.STRIPE_PRICE_PRO_YEARLY },
agency: { month: process.env.STRIPE_PRICE_AGENCY_MONTHLY, year: process.env.STRIPE_PRICE_AGENCY_YEARLY },
};
// PayPal plans are created per tier (one billing cycle each); we key by tier and
// optionally distinguish interval if you create yearly PayPal plans too.
const PAYPAL_PLAN_ENV: Record<Exclude<PlanKey, "free">, string | undefined> = {
creator: process.env.PAYPAL_PLAN_CREATOR,
pro: process.env.PAYPAL_PLAN_PRO,
agency: process.env.PAYPAL_PLAN_AGENCY,
};
export function stripePriceId(plan: PlanKey, interval: BillingInterval): string | undefined {
if (plan === "free") return undefined;
return STRIPE_PRICE_ENV[plan]?.[interval];
}
export function planFromStripePrice(priceId: string): { plan: PlanKey; interval: BillingInterval } | null {
for (const plan of PLAN_ORDER) {
if (plan === "free") continue;
const intervals = STRIPE_PRICE_ENV[plan];
if (intervals.month === priceId) return { plan, interval: "month" };
if (intervals.year === priceId) return { plan, interval: "year" };
}
return null;
}
export function paypalPlanId(plan: PlanKey): string | undefined {
if (plan === "free") return undefined;
return PAYPAL_PLAN_ENV[plan];
}
export function planFromPaypalPlan(paypalPlanId: string): PlanKey | null {
for (const plan of PLAN_ORDER) {
if (plan === "free") continue;
if (PAYPAL_PLAN_ENV[plan] === paypalPlanId) return plan;
}
return null;
}
+115
View File
@@ -0,0 +1,115 @@
import type { PlanKey } from "./plans";
const base = () => process.env.PAYPAL_API_BASE ?? "https://api-m.sandbox.paypal.com";
const appUrl = () => process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
function creds(): { id: string; secret: string } {
const id = process.env.PAYPAL_CLIENT_ID;
const secret = process.env.PAYPAL_CLIENT_SECRET;
if (!id || !secret) throw new Error("PayPal credentials are not set");
return { id, secret };
}
export function isPaypalConfigured(): boolean {
return !!(process.env.PAYPAL_CLIENT_ID && process.env.PAYPAL_CLIENT_SECRET);
}
async function accessToken(): Promise<string> {
const { id, secret } = creds();
const res = await fetch(`${base()}/v1/oauth2/token`, {
method: "POST",
headers: {
Authorization: `Basic ${Buffer.from(`${id}:${secret}`).toString("base64")}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body: "grant_type=client_credentials",
});
if (!res.ok) throw new Error(`PayPal token error ${res.status}`);
const data = (await res.json()) as { access_token: string };
return data.access_token;
}
export interface PaypalCustom {
subjectId: string;
subjectType: "user" | "organization";
plan: PlanKey;
}
/** Create a PayPal subscription; returns the subscription id + approval URL. */
export async function createPaypalSubscription(args: {
planId: string;
custom: PaypalCustom;
}): Promise<{ id: string; approveUrl: string }> {
const token = await accessToken();
const res = await fetch(`${base()}/v1/billing/subscriptions`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
Prefer: "return=representation",
},
body: JSON.stringify({
plan_id: args.planId,
custom_id: JSON.stringify(args.custom),
application_context: {
brand_name: "PodcastYes",
user_action: "SUBSCRIBE_NOW",
shipping_preference: "NO_SHIPPING",
return_url: `${appUrl()}/billing?status=success&provider=paypal`,
cancel_url: `${appUrl()}/billing?status=cancel`,
},
}),
});
if (!res.ok) throw new Error(`PayPal create subscription ${res.status}: ${await res.text()}`);
const data = (await res.json()) as { id: string; links: { rel: string; href: string }[] };
const approveUrl = data.links.find((l) => l.rel === "approve")?.href;
if (!approveUrl) throw new Error("PayPal did not return an approval URL");
return { id: data.id, approveUrl };
}
export async function getPaypalSubscription(id: string): Promise<Record<string, unknown>> {
const token = await accessToken();
const res = await fetch(`${base()}/v1/billing/subscriptions/${id}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`PayPal get subscription ${res.status}`);
return res.json();
}
export async function cancelPaypalSubscription(id: string, reason = "Customer requested"): Promise<void> {
const token = await accessToken();
const res = await fetch(`${base()}/v1/billing/subscriptions/${id}/cancel`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ reason }),
});
if (!res.ok && res.status !== 204) {
throw new Error(`PayPal cancel subscription ${res.status}: ${await res.text()}`);
}
}
/** Verify a PayPal webhook signature against PAYPAL_WEBHOOK_ID. */
export async function verifyPaypalWebhook(
headers: Record<string, string | undefined>,
rawBody: string
): Promise<boolean> {
const webhookId = process.env.PAYPAL_WEBHOOK_ID;
if (!webhookId) return false;
const token = await accessToken();
const res = await fetch(`${base()}/v1/notifications/verify-webhook-signature`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({
auth_algo: headers["paypal-auth-algo"],
cert_url: headers["paypal-cert-url"],
transmission_id: headers["paypal-transmission-id"],
transmission_sig: headers["paypal-transmission-sig"],
transmission_time: headers["paypal-transmission-time"],
webhook_id: webhookId,
webhook_event: JSON.parse(rawBody),
}),
});
if (!res.ok) return false;
const data = (await res.json()) as { verification_status: string };
return data.verification_status === "SUCCESS";
}
+162
View File
@@ -0,0 +1,162 @@
/**
* Plan catalog — the single source of truth for tiers, limits, and features.
* Provider-agnostic: Stripe and PayPal both map onto these plan keys, and
* `enforceLimit` / feature-gating read from here (never from a payment provider).
*
* Prices are in cents. A limit of `UNLIMITED` (-1) means no cap.
*/
export const UNLIMITED = -1;
export type PlanKey = "free" | "creator" | "pro" | "agency";
export type FeatureKey =
| "multi_voice"
| "cover_art"
| "content_repurposing"
| "all_languages"
| "ai_cohost"
| "series_generator"
| "api_access"
| "team_workspace"
| "white_label"
| "custom_branding"
| "priority_generation";
export type UsageMetric = "script" | "audio" | "art" | "repurpose";
export interface PlanLimits {
/** Monthly caps per metric; UNLIMITED (-1) = no cap. */
script: number;
audio: number;
art: number;
repurpose: number;
/** Seats in the team workspace (Agency). 1 for individual plans. */
seats: number;
/** Max length of a generated episode, in minutes. */
maxEpisodeMinutes: number;
}
export interface Plan {
key: PlanKey;
name: string;
/** Short marketing tagline. */
tagline: string;
priceMonthly: number; // cents
priceYearly: number; // cents (≈ 2 months free)
highlight?: boolean; // "most popular"
limits: PlanLimits;
features: FeatureKey[];
/** Human-readable bullet points for the pricing page. */
bullets: string[];
}
export const PLANS: Record<PlanKey, Plan> = {
free: {
key: "free",
name: "Free",
tagline: "Try it out, no card required.",
priceMonthly: 0,
priceYearly: 0,
limits: { script: 3, audio: 1, art: 3, repurpose: 1, seats: 1, maxEpisodeMinutes: 5 },
features: ["multi_voice", "cover_art"],
bullets: [
"3 scripts / month",
"1 audio generation / month",
"Up to 5-minute episodes",
"2 narrator voices",
"Cover art generation",
],
},
creator: {
key: "creator",
name: "Creator",
tagline: "For solo creators shipping regularly.",
priceMonthly: 900,
priceYearly: 9000,
limits: { script: 50, audio: 20, art: 50, repurpose: 50, seats: 1, maxEpisodeMinutes: 20 },
features: ["multi_voice", "cover_art", "content_repurposing", "all_languages"],
bullets: [
"50 scripts / month",
"20 audio generations / month",
"Up to 20-minute episodes",
"All 14+ voices",
"13+ languages",
"Content repurposing (blog, social, newsletter)",
],
},
pro: {
key: "pro",
name: "Pro",
tagline: "For serious podcasters and small studios.",
priceMonthly: 2900,
priceYearly: 29000,
highlight: true,
limits: { script: UNLIMITED, audio: 100, art: UNLIMITED, repurpose: UNLIMITED, seats: 1, maxEpisodeMinutes: 45 },
features: [
"multi_voice",
"cover_art",
"content_repurposing",
"all_languages",
"ai_cohost",
"series_generator",
"api_access",
"priority_generation",
],
bullets: [
"Unlimited scripts",
"100 audio generations / month",
"Up to 45-minute episodes",
"AI co-host mode",
"Series & season generator",
"API access",
"Priority generation queue",
],
},
agency: {
key: "agency",
name: "Agency",
tagline: "For teams and white-label studios.",
priceMonthly: 7900,
priceYearly: 79000,
limits: { script: UNLIMITED, audio: UNLIMITED, art: UNLIMITED, repurpose: UNLIMITED, seats: 5, maxEpisodeMinutes: 90 },
features: [
"multi_voice",
"cover_art",
"content_repurposing",
"all_languages",
"ai_cohost",
"series_generator",
"api_access",
"priority_generation",
"team_workspace",
"white_label",
"custom_branding",
],
bullets: [
"Everything in Pro, unlimited",
"5-seat team workspace",
"White-label mode",
"Custom branding",
"Up to 90-minute episodes",
"Priority support",
],
},
};
export const PLAN_ORDER: PlanKey[] = ["free", "creator", "pro", "agency"];
export function getPlan(key: string | null | undefined): Plan {
if (key && key in PLANS) return PLANS[key as PlanKey];
return PLANS.free;
}
export function planHasFeature(key: PlanKey, feature: FeatureKey): boolean {
return PLANS[key].features.includes(feature);
}
/** True when `count` is within the plan's cap for `metric` (UNLIMITED always passes). */
export function withinLimit(key: PlanKey, metric: UsageMetric, count: number): boolean {
const limit = PLANS[key].limits[metric];
return limit === UNLIMITED || count < limit;
}
+76
View File
@@ -0,0 +1,76 @@
import Stripe from "stripe";
import { prisma } from "@/lib/db";
import { stripePriceId, type BillingInterval } from "./catalog";
import type { PlanKey } from "./plans";
let client: Stripe | null = null;
export function stripe(): Stripe {
if (!client) {
const key = process.env.STRIPE_SECRET_KEY;
if (!key) throw new Error("STRIPE_SECRET_KEY is not set");
client = new Stripe(key);
}
return client;
}
export function isStripeConfigured(): boolean {
return !!process.env.STRIPE_SECRET_KEY;
}
const appUrl = () => process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
/** Find-or-create the Stripe customer for a user and persist the id. */
export async function ensureStripeCustomer(user: {
id: string;
email: string;
name: string;
stripeCustomerId: string | null;
}): Promise<string> {
if (user.stripeCustomerId) return user.stripeCustomerId;
const customer = await stripe().customers.create({
email: user.email,
name: user.name,
metadata: { userId: user.id },
});
await prisma.user.update({ where: { id: user.id }, data: { stripeCustomerId: customer.id } });
return customer.id;
}
/** Create a Stripe Checkout Session for a subscription; returns the redirect URL. */
export async function createStripeCheckout(args: {
user: { id: string; email: string; name: string; stripeCustomerId: string | null };
plan: PlanKey;
interval: BillingInterval;
subjectId: string;
subjectType: "user" | "organization";
}): Promise<string> {
const price = stripePriceId(args.plan, args.interval);
if (!price) throw new Error(`No Stripe price configured for ${args.plan}/${args.interval}`);
const customer = await ensureStripeCustomer(args.user);
const session = await stripe().checkout.sessions.create({
mode: "subscription",
customer,
line_items: [{ price, quantity: 1 }],
client_reference_id: args.subjectId,
metadata: { plan: args.plan, subjectId: args.subjectId, subjectType: args.subjectType },
subscription_data: {
metadata: { plan: args.plan, subjectId: args.subjectId, subjectType: args.subjectType },
},
allow_promotion_codes: true,
success_url: `${appUrl()}/billing?status=success`,
cancel_url: `${appUrl()}/billing?status=cancel`,
});
if (!session.url) throw new Error("Stripe did not return a checkout URL");
return session.url;
}
/** Create a Stripe Billing Portal session for managing/cancelling a subscription. */
export async function createStripePortal(customerId: string): Promise<string> {
const session = await stripe().billingPortal.sessions.create({
customer: customerId,
return_url: `${appUrl()}/billing`,
});
return session.url;
}
+104
View File
@@ -0,0 +1,104 @@
import { prisma } from "@/lib/db";
import { getPlan, type Plan, type PlanKey, type FeatureKey } from "./plans";
export interface UpsertSubscriptionInput {
provider: "stripe" | "paypal";
referenceId: string;
plan: PlanKey;
status: string;
billingInterval?: "month" | "year" | null;
seats?: number | null;
stripeCustomerId?: string | null;
stripeSubscriptionId?: string | null;
paypalSubscriptionId?: string | null;
paypalPlanId?: string | null;
periodStart?: Date | null;
periodEnd?: Date | null;
cancelAtPeriodEnd?: boolean | null;
}
/**
* The single writer both billing providers funnel into. Idempotent on the
* provider subscription id, so duplicate/replayed webhooks converge on one row.
*/
export async function upsertSubscription(input: UpsertSubscriptionInput) {
const existing = input.stripeSubscriptionId
? await prisma.subscription.findFirst({
where: { stripeSubscriptionId: input.stripeSubscriptionId },
})
: input.paypalSubscriptionId
? await prisma.subscription.findFirst({
where: { paypalSubscriptionId: input.paypalSubscriptionId },
})
: null;
const data = {
provider: input.provider,
plan: input.plan,
status: input.status,
billingInterval: input.billingInterval ?? undefined,
seats: input.seats ?? undefined,
stripeCustomerId: input.stripeCustomerId ?? undefined,
stripeSubscriptionId: input.stripeSubscriptionId ?? undefined,
paypalSubscriptionId: input.paypalSubscriptionId ?? undefined,
paypalPlanId: input.paypalPlanId ?? undefined,
periodStart: input.periodStart ?? undefined,
periodEnd: input.periodEnd ?? undefined,
cancelAtPeriodEnd: input.cancelAtPeriodEnd ?? undefined,
};
if (existing) {
return prisma.subscription.update({ where: { id: existing.id }, data });
}
return prisma.subscription.create({ data: { referenceId: input.referenceId, ...data } });
}
/**
* Resolve the active plan for a billing subject (a user id, or an organization id
* for Agency). Returns "free" when there is no active subscription.
*
* Both Stripe and PayPal write into the same `subscription` table keyed by
* `referenceId`, so this is provider-agnostic by construction.
*/
export async function getSubjectPlanKey(referenceId: string): Promise<PlanKey> {
const sub = await prisma.subscription.findFirst({
where: { referenceId, status: { in: ["active", "trialing"] } },
orderBy: { createdAt: "desc" },
});
return (sub?.plan as PlanKey) ?? "free";
}
/** The active subscription row for a subject, if any. */
export function getActiveSubscription(referenceId: string) {
return prisma.subscription.findFirst({
where: { referenceId, status: { in: ["active", "trialing", "past_due"] } },
orderBy: { createdAt: "desc" },
});
}
/**
* Resolve the effective plan for the current request. When an organization is
* active (Agency workspace), the org's plan governs; otherwise the user's own.
*/
export async function getEffectivePlan(
userId: string,
activeOrgId?: string | null
): Promise<{ plan: Plan; key: PlanKey; subjectId: string; subjectType: "user" | "organization" }> {
if (activeOrgId) {
const key = await getSubjectPlanKey(activeOrgId);
if (key !== "free") {
return { plan: getPlan(key), key, subjectId: activeOrgId, subjectType: "organization" };
}
}
const key = await getSubjectPlanKey(userId);
return { plan: getPlan(key), key, subjectId: userId, subjectType: "user" };
}
export async function subjectHasFeature(
userId: string,
feature: FeatureKey,
activeOrgId?: string | null
): Promise<boolean> {
const { plan } = await getEffectivePlan(userId, activeOrgId);
return plan.features.includes(feature);
}
+69
View File
@@ -0,0 +1,69 @@
import { upsertSubscription } from "../subscription";
import { planFromPaypalPlan } from "../catalog";
import type { PlanKey } from "../plans";
interface PaypalResource {
id?: string;
plan_id?: string;
custom_id?: string;
billing_info?: { next_billing_time?: string };
}
interface PaypalEvent {
event_type: string;
resource: PaypalResource;
}
function parseCustom(
custom?: string
): { subjectId: string; subjectType: "user" | "organization"; plan: PlanKey } | null {
if (!custom) return null;
try {
return JSON.parse(custom);
} catch {
return null;
}
}
async function sync(resource: PaypalResource, status: string) {
const subId = resource.id;
if (!subId) return;
const custom = parseCustom(resource.custom_id);
const planFromId = resource.plan_id ? planFromPaypalPlan(resource.plan_id) : null;
const plan = (custom?.plan || planFromId || "free") as PlanKey;
const referenceId = custom?.subjectId;
if (!referenceId) {
console.warn("[paypal] subscription without custom subjectId, skipping", subId);
return;
}
await upsertSubscription({
provider: "paypal",
referenceId,
plan,
status,
paypalSubscriptionId: subId,
paypalPlanId: resource.plan_id ?? null,
periodEnd: resource.billing_info?.next_billing_time
? new Date(resource.billing_info.next_billing_time)
: null,
});
}
export async function handlePaypalEvent(event: PaypalEvent): Promise<void> {
switch (event.event_type) {
case "BILLING.SUBSCRIPTION.ACTIVATED":
case "BILLING.SUBSCRIPTION.UPDATED":
case "BILLING.SUBSCRIPTION.RE-ACTIVATED":
await sync(event.resource, "active");
break;
case "BILLING.SUBSCRIPTION.SUSPENDED":
await sync(event.resource, "paused");
break;
case "BILLING.SUBSCRIPTION.CANCELLED":
case "BILLING.SUBSCRIPTION.EXPIRED":
await sync(event.resource, "canceled");
break;
default:
break;
}
}
+71
View File
@@ -0,0 +1,71 @@
import type Stripe from "stripe";
import { stripe } from "../stripe";
import { upsertSubscription } from "../subscription";
import { planFromStripePrice } from "../catalog";
import type { PlanKey } from "../plans";
function normalizeStatus(status: Stripe.Subscription.Status): string {
switch (status) {
case "active":
case "trialing":
case "past_due":
case "paused":
return status;
default:
return "canceled"; // canceled | incomplete | incomplete_expired | unpaid
}
}
async function syncStripeSubscription(
sub: Stripe.Subscription,
metadata?: Stripe.Metadata | null
) {
const item = sub.items.data[0];
const priceId = item?.price?.id;
const mapped = priceId ? planFromStripePrice(priceId) : null;
const plan = ((metadata?.plan as PlanKey) || mapped?.plan || "free") as PlanKey;
const referenceId = metadata?.subjectId || sub.metadata?.subjectId;
if (!referenceId) {
console.warn("[stripe] subscription without subjectId metadata, skipping", sub.id);
return;
}
const interval =
mapped?.interval ?? (item?.price?.recurring?.interval === "year" ? "year" : "month");
await upsertSubscription({
provider: "stripe",
referenceId,
plan,
status: normalizeStatus(sub.status),
billingInterval: interval,
stripeCustomerId: typeof sub.customer === "string" ? sub.customer : sub.customer.id,
stripeSubscriptionId: sub.id,
periodStart: sub.current_period_start ? new Date(sub.current_period_start * 1000) : null,
periodEnd: sub.current_period_end ? new Date(sub.current_period_end * 1000) : null,
cancelAtPeriodEnd: sub.cancel_at_period_end,
});
}
export async function handleStripeEvent(event: Stripe.Event): Promise<void> {
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session;
if (session.mode !== "subscription" || !session.subscription) break;
const subId =
typeof session.subscription === "string" ? session.subscription : session.subscription.id;
const sub = await stripe().subscriptions.retrieve(subId);
await syncStripeSubscription(sub, session.metadata);
break;
}
case "customer.subscription.created":
case "customer.subscription.updated":
case "customer.subscription.deleted": {
const sub = event.data.object as Stripe.Subscription;
await syncStripeSubscription(sub, sub.metadata);
break;
}
default:
break;
}
}