Initial commit: PodcastYes — AI podcast platform
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user