Strip Songs2VID to a payment-free self-hosted OSS core.

Remove Stripe/billing/pricing/marketing, simplify schema and entitlements for unlimited self-host use, and keep auth, encode, and YouTube upload.
This commit is contained in:
Atakan Doğan Özban
2026-08-03 06:52:00 +02:00
parent 506d56fa92
commit 6476bb42a4
108 changed files with 397 additions and 8061 deletions
-10
View File
@@ -1,6 +1,5 @@
import { NextResponse } from "next/server";
import { createUserApiKey, getUserApiKeyStatus, revokeUserApiKey } from "@/lib/api-keys";
import { hasProFeatures } from "@/lib/edition";
import { getSessionUser } from "@/lib/session";
async function requireApiKeyAccess() {
@@ -8,15 +7,6 @@ async function requireApiKeyAccess() {
if (!user) {
return { error: NextResponse.json({ error: "Unauthorized" }, { status: 401 }), user: null };
}
if (!hasProFeatures(user.plan)) {
return {
error: NextResponse.json(
{ error: "API keys are available on the Pro plan only" },
{ status: 403 },
),
user: null,
};
}
return { error: null, user };
}
@@ -1,51 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import {
createQuotaExtensionRequest,
EXTENSION_KIND,
getQuotaExtensionUsage,
} from "@/lib/quota-extensions";
import { hasProFeatures } from "@/lib/edition";
import { getSessionUser } from "@/lib/session";
export async function GET() {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!hasProFeatures(user.plan)) {
return NextResponse.json({ error: "Pro plan required" }, { status: 403 });
}
const usage = await getQuotaExtensionUsage(user.id, EXTENSION_KIND.API_RATE_LIMIT);
return NextResponse.json(usage);
}
export async function POST(req: NextRequest) {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!hasProFeatures(user.plan)) {
return NextResponse.json({ error: "Pro plan required" }, { status: 403 });
}
let message = "";
try {
const body = await req.json();
if (typeof body.message === "string") message = body.message;
} catch {
// optional body
}
try {
const result = await createQuotaExtensionRequest(
user.id,
message,
EXTENSION_KIND.API_RATE_LIMIT,
);
return NextResponse.json(result);
} catch (err) {
const msg = err instanceof Error ? err.message : "Failed to submit request";
return NextResponse.json({ error: msg }, { status: 400 });
}
}
-8
View File
@@ -1,6 +1,5 @@
import { NextResponse } from "next/server";
import { getApiRateLimitStatus } from "@/lib/api-rate-limit";
import { hasProFeatures } from "@/lib/edition";
import { getSessionUser } from "@/lib/session";
export async function GET() {
@@ -8,13 +7,6 @@ export async function GET() {
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!hasProFeatures(user.plan)) {
return NextResponse.json(
{ error: "API rate limits are available on the Pro plan only" },
{ status: 403 },
);
}
const status = await getApiRateLimitStatus(user.id);
return NextResponse.json(status);
}
-64
View File
@@ -1,64 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getSessionUser } from "@/lib/session";
import { getStripe, isBillingDevMock, isStripeConfigured } from "@/lib/stripe";
/**
* Stripe Customer Portal update payment method, view invoices, cancel (per Dashboard config).
* @see https://docs.stripe.com/customer-management/integrate-customer-portal
*/
export async function POST(req: NextRequest) {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (isBillingDevMock()) {
return NextResponse.json(
{ error: "Billing portal is unavailable in mock mode." },
{ status: 503 },
);
}
if (!isStripeConfigured()) {
return NextResponse.json(
{ error: "Payments are not configured yet. Set STRIPE_SECRET_KEY." },
{ status: 503 },
);
}
const dbUser = await prisma.user.findUniqueOrThrow({
where: { id: user.id },
select: { stripeCustomerId: true },
});
if (!dbUser.stripeCustomerId) {
return NextResponse.json(
{ error: "No Stripe customer on this account. Complete a checkout first." },
{ status: 400 },
);
}
const origin = process.env.NEXTAUTH_URL || req.nextUrl.origin;
const stripe = getStripe();
try {
const session = await stripe.billingPortal.sessions.create({
customer: dbUser.stripeCustomerId,
return_url: `${origin}/dashboard/settings`,
});
return NextResponse.json({ url: session.url });
} catch (err) {
const message = err instanceof Error ? err.message : "Could not open billing portal";
console.error("[stripe] billing portal failed", err);
return NextResponse.json(
{
error:
message.includes("No configuration") || message.includes("portal")
? "Billing portal is not configured in Stripe Dashboard yet. Enable it under Settings → Billing → Customer portal."
: message,
},
{ status: 502 },
);
}
}
@@ -1,217 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { downgradeToFreePlan } from "@/lib/billing";
import { prisma } from "@/lib/db";
import { getSessionUser } from "@/lib/session";
import { getStripe, isBillingDevMock, isStripeConfigured } from "@/lib/stripe";
type CancelWhen = "immediate" | "period_end";
type CancelAction = "cancel" | "resume";
function periodEndIso(sub: {
cancel_at?: number | null;
current_period_end?: number;
items?: { data?: Array<{ current_period_end?: number }> };
}): string | null {
const fromItem = sub.items?.data?.[0]?.current_period_end;
const ts = sub.cancel_at ?? fromItem ?? sub.current_period_end ?? null;
return ts ? new Date(ts * 1000).toISOString() : null;
}
/**
* Cancel Pro subscription via Stripe.
* Body: { when: "immediate" | "period_end" } or { action: "resume" }
*/
export async function POST(req: NextRequest) {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (user.plan !== "PREMIUM") {
return NextResponse.json({ error: "No active subscription to cancel" }, { status: 400 });
}
let action: CancelAction = "cancel";
let when: CancelWhen = "immediate";
try {
const body = await req.json();
if (body?.action === "resume") action = "resume";
if (body?.when === "period_end" || body?.when === "immediate") when = body.when;
} catch {
// empty body → default immediate cancel (legacy)
}
const dbUser = await prisma.user.findUniqueOrThrow({
where: { id: user.id },
select: { stripeSubscriptionId: true },
});
const subId = dbUser.stripeSubscriptionId;
const isMockSub = !subId || subId.startsWith("dev_sub_");
const useStripe =
Boolean(subId) &&
!isMockSub &&
isStripeConfigured() &&
!isBillingDevMock();
// Resume (undo cancel-at-period-end)
if (action === "resume") {
if (useStripe && subId) {
try {
const stripe = getStripe();
const sub = await stripe.subscriptions.update(subId, {
cancel_at_period_end: false,
});
return NextResponse.json({
ok: true,
action: "resume",
cancelAtPeriodEnd: false,
currentPeriodEnd: periodEndIso(sub as { current_period_end?: number }),
});
} catch (err) {
console.error("[stripe] resume subscription failed", err);
return NextResponse.json(
{ error: err instanceof Error ? err.message : "Failed to keep subscription" },
{ status: 502 },
);
}
}
return NextResponse.json({
ok: true,
action: "resume",
cancelAtPeriodEnd: false,
mocked: true,
});
}
// Cancel
if (when === "period_end") {
if (useStripe && subId) {
try {
const stripe = getStripe();
const sub = await stripe.subscriptions.update(subId, {
cancel_at_period_end: true,
});
const endsAt = periodEndIso(
sub as {
cancel_at?: number | null;
current_period_end?: number;
items?: { data?: Array<{ current_period_end?: number }> };
},
);
return NextResponse.json({
ok: true,
when: "period_end",
cancelAtPeriodEnd: true,
endsAt,
// Keep Pro until Stripe sends customer.subscription.deleted
});
} catch (err) {
console.error("[stripe] cancel at period end failed", err);
return NextResponse.json(
{ error: err instanceof Error ? err.message : "Failed to schedule cancellation" },
{ status: 502 },
);
}
}
// Mock / no Stripe id: schedule locally by leaving Pro and reporting next month
const endsAt = new Date();
endsAt.setMonth(endsAt.getMonth() + 1);
return NextResponse.json({
ok: true,
when: "period_end",
cancelAtPeriodEnd: true,
endsAt: endsAt.toISOString(),
mocked: true,
});
}
// immediate
if (useStripe && subId) {
try {
const stripe = getStripe();
await stripe.subscriptions.cancel(subId);
} catch (err) {
console.error("[stripe] cancel subscription failed", err);
return NextResponse.json(
{
error:
err instanceof Error
? err.message
: "Stripe could not cancel the subscription. Your plan was not changed.",
},
{ status: 502 },
);
}
}
await downgradeToFreePlan(user.id);
return NextResponse.json({
ok: true,
when: "immediate",
cancelAtPeriodEnd: false,
plan: "FREE",
});
}
/** Current Stripe subscription cancel status for settings UI. */
export async function GET() {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (user.plan !== "PREMIUM") {
return NextResponse.json({
plan: user.plan,
cancelAtPeriodEnd: false,
endsAt: null,
});
}
const dbUser = await prisma.user.findUniqueOrThrow({
where: { id: user.id },
select: { stripeSubscriptionId: true },
});
const subId = dbUser.stripeSubscriptionId;
if (
!subId ||
subId.startsWith("dev_sub_") ||
!isStripeConfigured() ||
isBillingDevMock()
) {
return NextResponse.json({
plan: "PREMIUM",
cancelAtPeriodEnd: false,
endsAt: null,
mocked: true,
});
}
try {
const stripe = getStripe();
const sub = await stripe.subscriptions.retrieve(subId);
return NextResponse.json({
plan: "PREMIUM",
status: sub.status,
cancelAtPeriodEnd: Boolean(sub.cancel_at_period_end),
endsAt: periodEndIso(
sub as {
cancel_at?: number | null;
current_period_end?: number;
items?: { data?: Array<{ current_period_end?: number }> };
},
),
});
} catch (err) {
console.error("[stripe] retrieve subscription failed", err);
return NextResponse.json({
plan: "PREMIUM",
cancelAtPeriodEnd: false,
endsAt: null,
error: "Could not load subscription status from Stripe",
});
}
}
-192
View File
@@ -1,192 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import {
CREDIT_PRICE_CENTS,
FREE_EXTRA_CREDITS_MAX,
FREE_TOP_UP_MAX,
FREE_TOP_UP_MIN,
formatCreditPrice,
validateFreeTopUpAmount,
} from "@/lib/credits";
import { prisma } from "@/lib/db";
import { getSessionUser } from "@/lib/session";
import {
checkoutTaxAndReferenceOptions,
ensureStripeCustomer,
priceDataTaxFields,
productDataWithTaxCode,
} from "@/lib/stripe-checkout";
import { getStripe, isBillingDevMock, isStripeConfigured } from "@/lib/stripe";
import { grantExtraCredits } from "@/lib/billing";
export async function GET() {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const dbUser = await prisma.user.findUniqueOrThrow({
where: { id: user.id },
select: {
extraCredits: true,
plan: true,
monthlyCredits: true,
videosUsed: true,
},
});
const purchases = await prisma.creditPurchase.findMany({
where: { userId: user.id, status: "COMPLETED" },
orderBy: { completedAt: "desc" },
take: 10,
});
const cap = FREE_EXTRA_CREDITS_MAX;
const room = Math.max(0, cap - dbUser.extraCredits);
return NextResponse.json({
extraCredits: dbUser.extraCredits,
videoCredits: dbUser.extraCredits,
topUpMin: FREE_TOP_UP_MIN,
topUpMax: FREE_TOP_UP_MAX,
priceCentsPerCredit: CREDIT_PRICE_CENTS,
priceLabelPerCredit: formatCreditPrice(1),
creditBalanceMax: cap,
monthlyCredits: dbUser.monthlyCredits,
creditsUsed: dbUser.videosUsed,
plan: dbUser.plan,
stripeConfigured: isStripeConfigured() || isBillingDevMock(),
room,
atExtraCap: room === 0,
purchases: purchases.map((p) => ({
id: p.id,
credits: p.credits,
amountCents: p.amountCents,
completedAt: p.completedAt?.toISOString() ?? null,
})),
});
}
/** Free tier: top up 115 extra credits (price = credits × €0.25). */
export async function POST(req: NextRequest) {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (user.plan === "PREMIUM") {
return NextResponse.json(
{
error:
"Credit top-ups are for the Free plan. Pro includes 50 monthly credits; existing extras never expire.",
},
{ status: 403 },
);
}
let credits = 0;
try {
const body = await req.json();
credits = Math.floor(Number(body.credits));
} catch {
return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
}
const dbUser = await prisma.user.findUniqueOrThrow({
where: { id: user.id },
select: { extraCredits: true, email: true, monthlyCredits: true, videosUsed: true, bonusQuota: true },
});
const monthlyRemaining = Math.max(
0,
dbUser.monthlyCredits + dbUser.bonusQuota - dbUser.videosUsed,
);
const validation = validateFreeTopUpAmount(credits, dbUser.extraCredits, monthlyRemaining);
if (!validation.ok) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { credits: amount, amountCents } = validation;
if (isBillingDevMock()) {
const purchase = await prisma.creditPurchase.create({
data: {
userId: user.id,
credits: amount,
amountCents,
status: "COMPLETED",
completedAt: new Date(),
stripeSessionId: `dev_topup_${Date.now()}`,
},
});
await grantExtraCredits(user.id, amount);
return NextResponse.json({
mocked: true,
granted: amount,
amountCents,
purchaseId: purchase.id,
});
}
if (!isStripeConfigured()) {
return NextResponse.json(
{ error: "Payments are not configured yet. Set STRIPE_SECRET_KEY." },
{ status: 503 },
);
}
const origin = process.env.NEXTAUTH_URL || req.nextUrl.origin;
const purchase = await prisma.creditPurchase.create({
data: {
userId: user.id,
credits: amount,
amountCents,
status: "PENDING",
},
});
try {
const stripe = getStripe();
const customerId = await ensureStripeCustomer(user.id, dbUser.email);
const session = await stripe.checkout.sessions.create({
mode: "payment",
customer: customerId,
line_items: [
{
quantity: amount,
price_data: {
currency: "eur",
unit_amount: CREDIT_PRICE_CENTS,
...priceDataTaxFields(),
product_data: productDataWithTaxCode(
"Songs2VID video credit",
`1 credit = 1 video upload (${formatCreditPrice(1)} each)`,
),
},
},
],
metadata: {
type: "free_top_up",
userId: user.id,
purchaseId: purchase.id,
credits: String(amount),
},
...checkoutTaxAndReferenceOptions(user.id),
success_url: `${origin}/dashboard/settings?credits=success`,
cancel_url: `${origin}/dashboard/settings?credits=cancelled`,
});
await prisma.creditPurchase.update({
where: { id: purchase.id },
data: { stripeSessionId: session.id },
});
return NextResponse.json({ url: session.url, sessionId: session.id });
} catch (err) {
await prisma.creditPurchase.update({
where: { id: purchase.id },
data: { status: "FAILED" },
});
const message = err instanceof Error ? err.message : "Checkout failed";
return NextResponse.json({ error: message }, { status: 500 });
}
}
@@ -1,36 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { createQuotaExtensionRequest, getQuotaExtensionUsage } from "@/lib/quota-extensions";
import { getSessionUser } from "@/lib/session";
export async function GET() {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const usage = await getQuotaExtensionUsage(user.id);
return NextResponse.json(usage);
}
export async function POST(req: NextRequest) {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
let message = "";
try {
const body = await req.json();
if (typeof body.message === "string") message = body.message;
} catch {
// optional body
}
try {
const result = await createQuotaExtensionRequest(user.id, message);
return NextResponse.json(result);
} catch (err) {
const msg = err instanceof Error ? err.message : "Failed to submit request";
return NextResponse.json({ error: msg }, { status: 400 });
}
}
-87
View File
@@ -1,87 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { activateProPlan } from "@/lib/billing";
import { PRO_PRICE_CENTS } from "@/lib/credits";
import { prisma } from "@/lib/db";
import { getSessionUser } from "@/lib/session";
import {
checkoutTaxAndReferenceOptions,
ensureStripeCustomer,
priceDataTaxFields,
productDataWithTaxCode,
} from "@/lib/stripe-checkout";
import { getStripe, isBillingDevMock, isStripeConfigured } from "@/lib/stripe";
/** Start Pro subscription Checkout (€5/mo) or mock-upgrade in development. */
export async function POST(req: NextRequest) {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (user.plan === "PREMIUM") {
return NextResponse.json({ error: "Already on Pro." }, { status: 400 });
}
const dbUser = await prisma.user.findUniqueOrThrow({
where: { id: user.id },
select: { email: true, stripeCustomerId: true },
});
if (isBillingDevMock()) {
await activateProPlan(user.id, {
stripeCustomerId: dbUser.stripeCustomerId,
stripeSubscriptionId: `dev_sub_${Date.now()}`,
cardLast4: "4242",
});
return NextResponse.json({ mocked: true, plan: "PREMIUM" });
}
if (!isStripeConfigured()) {
return NextResponse.json(
{ error: "Payments are not configured yet. Set STRIPE_SECRET_KEY." },
{ status: 503 },
);
}
const origin = process.env.NEXTAUTH_URL || req.nextUrl.origin;
const stripe = getStripe();
const customerId = await ensureStripeCustomer(user.id, dbUser.email);
const priceId = process.env.STRIPE_PRO_PRICE_ID;
const session = await stripe.checkout.sessions.create({
mode: "subscription",
customer: customerId,
line_items: priceId
? [{ price: priceId, quantity: 1 }]
: [
{
quantity: 1,
price_data: {
currency: "eur",
unit_amount: PRO_PRICE_CENTS,
recurring: { interval: "month" },
...priceDataTaxFields(),
product_data: productDataWithTaxCode(
"Songs2VID Pro",
"50 video credits / month · 1080p · API access · Includes Custom Branding, Watermark Positioning, and Bulk Image Matching",
),
},
},
],
metadata: {
type: "pro_subscription",
userId: user.id,
},
subscription_data: {
metadata: {
type: "pro_subscription",
userId: user.id,
},
},
...checkoutTaxAndReferenceOptions(user.id),
success_url: `${origin}/dashboard/settings?upgrade=success`,
cancel_url: `${origin}/dashboard/settings?upgrade=cancelled`,
});
return NextResponse.json({ url: session.url, sessionId: session.id });
}