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. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
935abfb22e
commit
925aadae75
@@ -1,15 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export function requireAdmin(req: NextRequest): NextResponse | null {
|
||||
const adminKey = process.env.ADMIN_API_KEY;
|
||||
if (!adminKey) {
|
||||
return NextResponse.json({ error: "Admin API not configured" }, { status: 503 });
|
||||
}
|
||||
|
||||
const auth = req.headers.get("authorization");
|
||||
if (auth !== `Bearer ${adminKey}`) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
+3
-24
@@ -1,7 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { findUserByApiKey } from "./api-keys";
|
||||
import { checkApiRateLimit } from "./api-rate-limit";
|
||||
import { hasProFeatures } from "./edition";
|
||||
import { getSessionUser } from "./session";
|
||||
|
||||
function extractBearerToken(req: NextRequest) {
|
||||
@@ -10,7 +9,7 @@ function extractBearerToken(req: NextRequest) {
|
||||
return auth.slice(7).trim();
|
||||
}
|
||||
|
||||
export async function requirePaidApiUser(req: NextRequest) {
|
||||
export async function requireApiUser(req: NextRequest) {
|
||||
const token = extractBearerToken(req);
|
||||
if (!token) {
|
||||
return {
|
||||
@@ -30,16 +29,6 @@ export async function requirePaidApiUser(req: NextRequest) {
|
||||
};
|
||||
}
|
||||
|
||||
if (!hasProFeatures(user.plan)) {
|
||||
return {
|
||||
error: NextResponse.json(
|
||||
{ error: "API access requires an active Pro subscription" },
|
||||
{ status: 403 },
|
||||
),
|
||||
user: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (!user.youtubeConnection) {
|
||||
return {
|
||||
error: NextResponse.json(
|
||||
@@ -70,8 +59,8 @@ export async function requirePaidApiUser(req: NextRequest) {
|
||||
return { error: null, user };
|
||||
}
|
||||
|
||||
export async function requirePaidUserFromSessionOrApi(req: NextRequest) {
|
||||
const apiResult = await requirePaidApiUser(req);
|
||||
export async function requireUserFromSessionOrApi(req: NextRequest) {
|
||||
const apiResult = await requireApiUser(req);
|
||||
if (apiResult.user) return apiResult;
|
||||
|
||||
const sessionUser = await getSessionUser();
|
||||
@@ -84,16 +73,6 @@ export async function requirePaidUserFromSessionOrApi(req: NextRequest) {
|
||||
};
|
||||
}
|
||||
|
||||
if (!hasProFeatures(sessionUser.plan)) {
|
||||
return {
|
||||
error: NextResponse.json(
|
||||
{ error: "API access requires an active Pro subscription" },
|
||||
{ status: 403 },
|
||||
),
|
||||
user: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (!sessionUser.youtubeConnection) {
|
||||
return {
|
||||
error: NextResponse.json({ error: "YouTube account not connected" }, { status: 403 }),
|
||||
|
||||
+3
-16
@@ -1,11 +1,6 @@
|
||||
import IORedis from "ioredis";
|
||||
import { prisma } from "./db";
|
||||
import { isSelfHostedEdition } from "./edition";
|
||||
|
||||
export const DEFAULT_API_RATE_LIMIT = 60;
|
||||
export const DEFAULT_API_RATE_LIMIT = 100_000;
|
||||
export const API_RATE_WINDOW_SECONDS = 60;
|
||||
export const MAX_ADMIN_API_RATE_BONUS = 120;
|
||||
const SELFHOSTED_API_RATE_LIMIT = 100_000;
|
||||
|
||||
type MemoryBucket = {
|
||||
count: number;
|
||||
@@ -36,13 +31,8 @@ function rateKey(userId: string) {
|
||||
}
|
||||
|
||||
export async function getEffectiveApiRateLimit(userId: string) {
|
||||
if (isSelfHostedEdition()) return SELFHOSTED_API_RATE_LIMIT;
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { apiRateLimitBonus: true, plan: true },
|
||||
});
|
||||
if (!user || user.plan !== "PREMIUM") return DEFAULT_API_RATE_LIMIT;
|
||||
return DEFAULT_API_RATE_LIMIT + Math.max(0, user.apiRateLimitBonus);
|
||||
void userId;
|
||||
return DEFAULT_API_RATE_LIMIT;
|
||||
}
|
||||
|
||||
function memoryStatus(userId: string, limit: number) {
|
||||
@@ -120,9 +110,6 @@ function checkMemoryRateLimit(userId: string, limit: number) {
|
||||
}
|
||||
|
||||
export async function checkApiRateLimit(userId: string) {
|
||||
if (isSelfHostedEdition()) {
|
||||
return { ok: true as const, limit: SELFHOSTED_API_RATE_LIMIT, remaining: SELFHOSTED_API_RATE_LIMIT };
|
||||
}
|
||||
const limit = await getEffectiveApiRateLimit(userId);
|
||||
const client = getRedis();
|
||||
if (!client) return checkMemoryRateLimit(userId, limit);
|
||||
|
||||
@@ -2,7 +2,6 @@ import { NextAuthOptions } from "next-auth";
|
||||
import GoogleProvider from "next-auth/providers/google";
|
||||
import { encryptSecret } from "./crypto/secrets";
|
||||
import { prisma } from "./db";
|
||||
import { getInitialQuotaResetAt } from "./quota";
|
||||
import { fetchYouTubeChannel } from "./youtube/upload";
|
||||
|
||||
export const authOptions: NextAuthOptions = {
|
||||
@@ -36,8 +35,6 @@ export const authOptions: NextAuthOptions = {
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
image: user.image,
|
||||
monthlyCredits: 10,
|
||||
quotaResetAt: getInitialQuotaResetAt(),
|
||||
},
|
||||
update: {
|
||||
name: user.name,
|
||||
@@ -88,7 +85,6 @@ export const authOptions: NextAuthOptions = {
|
||||
});
|
||||
if (dbUser) {
|
||||
session.user.id = dbUser.id;
|
||||
session.user.plan = dbUser.plan;
|
||||
session.user.youtubeConnected = !!dbUser.youtubeConnection;
|
||||
session.user.channelTitle = dbUser.youtubeConnection?.channelTitle;
|
||||
}
|
||||
|
||||
-307
@@ -1,307 +0,0 @@
|
||||
import { Plan } from "@prisma/client";
|
||||
import {
|
||||
EXTRA_CREDITS_MAX,
|
||||
FREE_TOP_UP_CREDITS,
|
||||
MAX_CREDIT_CAP,
|
||||
monthlyCreditsForPlan,
|
||||
} from "./credits";
|
||||
import { prisma } from "./db";
|
||||
import { getNextMonthlyQuotaReset } from "./plans";
|
||||
|
||||
export class CreditInsufficientError extends Error {
|
||||
readonly status = 402;
|
||||
constructor(message = "Payment Required: no credits remaining.") {
|
||||
super(message);
|
||||
this.name = "CreditInsufficientError";
|
||||
}
|
||||
}
|
||||
|
||||
export type DeductResult = {
|
||||
fromMonthly: number;
|
||||
fromExtra: number;
|
||||
};
|
||||
|
||||
export type RenewalBalances = {
|
||||
monthlyCredits: number;
|
||||
videosUsed: number;
|
||||
extraCredits: number;
|
||||
bonusQuota: number;
|
||||
trimmed: number;
|
||||
totalAfter: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Rollover + hard cap on renewal.
|
||||
* prospective = unused (monthly remaining + extras) + newMonthly
|
||||
* If prospective > MAX_CREDIT_CAP (30), trim excess (no refund).
|
||||
*
|
||||
* Storage: prefer filling the new monthly allocation first, leftover as extras.
|
||||
*/
|
||||
export function computeRenewalBalances(input: {
|
||||
monthlyCredits: number;
|
||||
videosUsed: number;
|
||||
bonusQuota?: number;
|
||||
extraCredits: number;
|
||||
newMonthlyCredits: number;
|
||||
maxCap?: number;
|
||||
}): RenewalBalances {
|
||||
const cap = input.maxCap ?? MAX_CREDIT_CAP;
|
||||
const bonus = input.bonusQuota ?? 0;
|
||||
const unusedMonthly = Math.max(0, input.monthlyCredits + bonus - input.videosUsed);
|
||||
const currentUnused = unusedMonthly + Math.max(0, input.extraCredits);
|
||||
const prospective = currentUnused + input.newMonthlyCredits;
|
||||
const totalAfter = Math.min(prospective, cap);
|
||||
const trimmed = Math.max(0, prospective - totalAfter);
|
||||
|
||||
const monthlyCredits = Math.min(input.newMonthlyCredits, totalAfter);
|
||||
const extraCredits = Math.max(0, totalAfter - monthlyCredits);
|
||||
|
||||
return {
|
||||
monthlyCredits,
|
||||
videosUsed: 0,
|
||||
extraCredits,
|
||||
bonusQuota: 0,
|
||||
trimmed,
|
||||
totalAfter,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply monthly credit renewal with MAX_CREDIT_CAP rollover trim.
|
||||
*/
|
||||
export async function applyMonthlyCreditRenewal(
|
||||
userId: string,
|
||||
opts: {
|
||||
plan?: Plan;
|
||||
newMonthlyCredits?: number;
|
||||
quotaResetAt?: Date;
|
||||
} = {},
|
||||
) {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const user = await tx.user.findUniqueOrThrow({ where: { id: userId } });
|
||||
const plan = opts.plan ?? user.plan;
|
||||
const newMonthly = opts.newMonthlyCredits ?? monthlyCreditsForPlan(plan);
|
||||
const balances = computeRenewalBalances({
|
||||
monthlyCredits: user.monthlyCredits,
|
||||
videosUsed: user.videosUsed,
|
||||
bonusQuota: user.bonusQuota,
|
||||
extraCredits: user.extraCredits,
|
||||
newMonthlyCredits: newMonthly,
|
||||
});
|
||||
|
||||
return tx.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
plan,
|
||||
monthlyCredits: balances.monthlyCredits,
|
||||
videosUsed: balances.videosUsed,
|
||||
extraCredits: balances.extraCredits,
|
||||
bonusQuota: balances.bonusQuota,
|
||||
quotaResetAt: opts.quotaResetAt ?? getNextMonthlyQuotaReset(),
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduct video creation credits for `count` items.
|
||||
* Order: monthly allocation first, then extraCredits.
|
||||
*/
|
||||
export async function deductUserCredit(
|
||||
userId: string,
|
||||
count = 1,
|
||||
): Promise<DeductResult> {
|
||||
if (count <= 0) return { fromMonthly: 0, fromExtra: 0 };
|
||||
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const rows = await tx.$queryRaw<
|
||||
Array<{
|
||||
id: string;
|
||||
plan: Plan;
|
||||
videosUsed: number;
|
||||
monthlyCredits: number;
|
||||
bonusQuota: number;
|
||||
videoCredits: number;
|
||||
}>
|
||||
>`SELECT id, plan, "videosUsed", "monthlyCredits", "bonusQuota", "videoCredits"
|
||||
FROM "User" WHERE id = ${userId} FOR UPDATE`;
|
||||
|
||||
const user = rows[0];
|
||||
if (!user) throw new Error("User not found");
|
||||
|
||||
const monthlyLimit = user.monthlyCredits + user.bonusQuota;
|
||||
const monthlyRemaining = Math.max(0, monthlyLimit - user.videosUsed);
|
||||
const extra = user.videoCredits;
|
||||
const total = monthlyRemaining + extra;
|
||||
|
||||
if (total < count) {
|
||||
throw new CreditInsufficientError(
|
||||
`Not enough credits. Available: ${total} (monthly remaining ${monthlyRemaining} + extras ${extra}).`,
|
||||
);
|
||||
}
|
||||
|
||||
const fromMonthly = Math.min(count, monthlyRemaining);
|
||||
const fromExtra = count - fromMonthly;
|
||||
|
||||
await tx.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
...(fromMonthly > 0 ? { videosUsed: { increment: fromMonthly } } : {}),
|
||||
...(fromExtra > 0 ? { extraCredits: { decrement: fromExtra } } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
return { fromMonthly, fromExtra };
|
||||
});
|
||||
}
|
||||
|
||||
/** Refund reserved credits (job create failure / worker failure). */
|
||||
export async function refundUserCredit(
|
||||
userId: string,
|
||||
fromMonthly: number,
|
||||
fromExtra: number,
|
||||
) {
|
||||
if (fromMonthly > 0) {
|
||||
await prisma.$executeRaw`
|
||||
UPDATE "User"
|
||||
SET "videosUsed" = GREATEST(0, "videosUsed" - ${fromMonthly})
|
||||
WHERE id = ${userId}
|
||||
`;
|
||||
}
|
||||
if (fromExtra > 0) {
|
||||
await prisma.$executeRaw`
|
||||
UPDATE "User"
|
||||
SET "videoCredits" = LEAST(${EXTRA_CREDITS_MAX}, "videoCredits" + ${fromExtra})
|
||||
WHERE id = ${userId}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin / support: reset or adjust a user's billing cycle and extras.
|
||||
*/
|
||||
export async function adminResetUserCredits(
|
||||
userId: string,
|
||||
options: {
|
||||
plan?: Plan;
|
||||
monthlyCredits?: number;
|
||||
creditsUsed?: number;
|
||||
extraCredits?: number;
|
||||
clearFreeTopUp?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
const user = await prisma.user.findUniqueOrThrow({ where: { id: userId } });
|
||||
const plan = options.plan ?? user.plan;
|
||||
const monthlyCredits =
|
||||
options.monthlyCredits ?? monthlyCreditsForPlan(plan);
|
||||
|
||||
const extras =
|
||||
options.extraCredits !== undefined
|
||||
? Math.max(0, Math.min(EXTRA_CREDITS_MAX, options.extraCredits))
|
||||
: user.extraCredits;
|
||||
const used = options.creditsUsed ?? 0;
|
||||
const remainingMonthly = Math.max(0, monthlyCredits - used);
|
||||
const total = remainingMonthly + extras;
|
||||
const cappedExtras =
|
||||
total > MAX_CREDIT_CAP
|
||||
? Math.max(0, MAX_CREDIT_CAP - remainingMonthly)
|
||||
: extras;
|
||||
|
||||
return prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
plan,
|
||||
subscriptionStatus: plan === "PREMIUM" ? "pro" : "free",
|
||||
monthlyCredits,
|
||||
videosUsed: used,
|
||||
extraCredits: cappedExtras,
|
||||
...(options.clearFreeTopUp ? { freeTopUpPurchased: false } : {}),
|
||||
quotaResetAt: getNextMonthlyQuotaReset(),
|
||||
...(plan === "FREE"
|
||||
? {
|
||||
stripeSubscriptionId: null,
|
||||
subscribedAt: null,
|
||||
cardLast4: null,
|
||||
bonusQuota: 0,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function activateProPlan(
|
||||
userId: string,
|
||||
opts: {
|
||||
stripeCustomerId?: string | null;
|
||||
stripeSubscriptionId?: string | null;
|
||||
cardLast4?: string | null;
|
||||
} = {},
|
||||
) {
|
||||
await applyMonthlyCreditRenewal(userId, {
|
||||
plan: "PREMIUM",
|
||||
newMonthlyCredits: monthlyCreditsForPlan("PREMIUM"),
|
||||
});
|
||||
|
||||
return prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
plan: "PREMIUM",
|
||||
subscriptionStatus: "pro",
|
||||
subscribedAt: new Date(),
|
||||
...(opts.stripeCustomerId !== undefined
|
||||
? { stripeCustomerId: opts.stripeCustomerId }
|
||||
: {}),
|
||||
...(opts.stripeSubscriptionId !== undefined
|
||||
? { stripeSubscriptionId: opts.stripeSubscriptionId }
|
||||
: {}),
|
||||
...(opts.cardLast4 !== undefined ? { cardLast4: opts.cardLast4 } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function downgradeToFreePlan(userId: string) {
|
||||
await applyMonthlyCreditRenewal(userId, {
|
||||
plan: "FREE",
|
||||
newMonthlyCredits: monthlyCreditsForPlan("FREE"),
|
||||
});
|
||||
|
||||
return prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
plan: "FREE",
|
||||
subscriptionStatus: "free",
|
||||
stripeSubscriptionId: null,
|
||||
subscribedAt: null,
|
||||
cardLast4: null,
|
||||
apiKeyHash: null,
|
||||
apiKeyPrefix: null,
|
||||
apiRateLimitBonus: 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function grantExtraCredits(userId: string, credits: number) {
|
||||
if (credits <= 0) return;
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const user = await tx.user.findUniqueOrThrow({ where: { id: userId } });
|
||||
const monthlyRemaining = Math.max(
|
||||
0,
|
||||
user.monthlyCredits + user.bonusQuota - user.videosUsed,
|
||||
);
|
||||
const room = Math.max(0, MAX_CREDIT_CAP - monthlyRemaining - user.extraCredits);
|
||||
const grant = Math.min(credits, room);
|
||||
if (grant <= 0) return;
|
||||
await tx.user.update({
|
||||
where: { id: userId },
|
||||
data: { extraCredits: { increment: grant } },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function markFreeTopUpPurchased(userId: string) {
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { freeTopUpPurchased: true },
|
||||
});
|
||||
await grantExtraCredits(userId, FREE_TOP_UP_CREDITS);
|
||||
}
|
||||
@@ -1,10 +1,3 @@
|
||||
export const FREE_PLAN = {
|
||||
monthlyQuota: 10,
|
||||
maxFileSizeBytes: 30 * 1024 * 1024,
|
||||
maxBatchSize: 3,
|
||||
watermarkOptional: true,
|
||||
} as const;
|
||||
|
||||
export const RESOLUTIONS = [
|
||||
{ value: "1920x1080", label: "1920x1080 (16:9)", width: 1920, height: 1080 },
|
||||
{ value: "1280x720", label: "1280x720 (16:9)", width: 1280, height: 720 },
|
||||
|
||||
-113
@@ -1,113 +0,0 @@
|
||||
/**
|
||||
* Billing / credit constants for Songs2VID hybrid pricing.
|
||||
* Plan enum in DB: FREE | PREMIUM (Pro = €5/mo "starter_5eur").
|
||||
*/
|
||||
|
||||
export const CREDIT_CURRENCY = "eur";
|
||||
|
||||
/** Free monthly allocation */
|
||||
export const FREE_MONTHLY_CREDITS = 10;
|
||||
|
||||
/** Pro monthly allocation */
|
||||
export const PRO_MONTHLY_CREDITS = 50;
|
||||
|
||||
/** Free-tier top-up: buyer picks 1–15 credits */
|
||||
export const FREE_TOP_UP_MIN = 1;
|
||||
export const FREE_TOP_UP_MAX = 15;
|
||||
|
||||
/** €0.25 per extra credit */
|
||||
export const CREDIT_PRICE_CENTS = 25;
|
||||
|
||||
/** @deprecated use FREE_TOP_UP_MAX */
|
||||
export const FREE_TOP_UP_CREDITS = FREE_TOP_UP_MAX;
|
||||
|
||||
/** @deprecated use creditPurchaseTotalCents(FREE_TOP_UP_MAX) */
|
||||
export const FREE_TOP_UP_PRICE_CENTS = FREE_TOP_UP_MAX * CREDIT_PRICE_CENTS;
|
||||
|
||||
/** Pro subscription €5 / month */
|
||||
export const PRO_PRICE_CENTS = 500;
|
||||
|
||||
/** Soft cap on never-expiring extra credits (Pro / legacy storage bound) */
|
||||
export const EXTRA_CREDITS_MAX = 1000;
|
||||
|
||||
/**
|
||||
* Hard cap on total accumulated credits (monthly remaining + extras).
|
||||
* On renewal, unused + new monthly is trimmed to this threshold.
|
||||
*/
|
||||
export const MAX_CREDIT_CAP = 30;
|
||||
|
||||
/**
|
||||
* Free plan: extras balance cannot exceed 15.
|
||||
* After monthly (10) + extras (≤15) are used, upgrade to Pro is required.
|
||||
*/
|
||||
export const FREE_EXTRA_CREDITS_MAX = FREE_TOP_UP_MAX;
|
||||
|
||||
/** Stripe Tax / Managed Payments SaaS personal use */
|
||||
export const STRIPE_PRODUCT_TAX_CODE = "txcd_10103000";
|
||||
|
||||
export const CREDIT_PURCHASE_MIN = FREE_TOP_UP_MIN;
|
||||
export const CREDIT_PURCHASE_MAX = FREE_TOP_UP_MAX;
|
||||
export const CREDIT_BALANCE_MAX = EXTRA_CREDITS_MAX;
|
||||
|
||||
/** Shown in quota UI errors so UpgradeProLink can attach a CTA */
|
||||
export const PRO_UPGRADE_REQUIRED_SUFFIX = " Upload up to 50 videos with Pro!";
|
||||
|
||||
export function monthlyCreditsForPlan(plan: "FREE" | "PREMIUM"): number {
|
||||
return plan === "PREMIUM" ? PRO_MONTHLY_CREDITS : FREE_MONTHLY_CREDITS;
|
||||
}
|
||||
|
||||
export function freeExtraCreditsCap(): number {
|
||||
return FREE_EXTRA_CREDITS_MAX;
|
||||
}
|
||||
|
||||
export function formatEuroFromCents(cents: number): string {
|
||||
const amount = cents / 100;
|
||||
const formatted = amount % 1 === 0 ? String(amount) : amount.toFixed(2);
|
||||
return `€${formatted}`;
|
||||
}
|
||||
|
||||
export function creditPurchaseTotalCents(credits: number): number {
|
||||
return credits * CREDIT_PRICE_CENTS;
|
||||
}
|
||||
|
||||
export function formatCreditPrice(credits = 1): string {
|
||||
return formatEuroFromCents(creditPurchaseTotalCents(credits));
|
||||
}
|
||||
|
||||
export function validateFreeTopUpAmount(
|
||||
credits: number,
|
||||
currentExtraBalance: number,
|
||||
monthlyRemaining = 0,
|
||||
): { ok: true; credits: number; amountCents: number } | { ok: false; error: string } {
|
||||
if (!Number.isInteger(credits)) {
|
||||
return { ok: false, error: "Credit amount must be a whole number." };
|
||||
}
|
||||
if (credits < FREE_TOP_UP_MIN || credits > FREE_TOP_UP_MAX) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `You can buy between ${FREE_TOP_UP_MIN} and ${FREE_TOP_UP_MAX} credits per top-up.`,
|
||||
};
|
||||
}
|
||||
if (currentExtraBalance >= FREE_EXTRA_CREDITS_MAX) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Free plan extras are capped at ${FREE_EXTRA_CREDITS_MAX}. Upgrade to Pro for 50 videos every month.${PRO_UPGRADE_REQUIRED_SUFFIX}`,
|
||||
};
|
||||
}
|
||||
const freeExtraRoom = FREE_EXTRA_CREDITS_MAX - currentExtraBalance;
|
||||
const totalRoom = Math.max(0, MAX_CREDIT_CAP - monthlyRemaining - currentExtraBalance);
|
||||
const room = Math.min(freeExtraRoom, totalRoom);
|
||||
if (room <= 0) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Your total credit balance cannot exceed ${MAX_CREDIT_CAP}. Upgrade to Pro or use existing credits first.${PRO_UPGRADE_REQUIRED_SUFFIX}`,
|
||||
};
|
||||
}
|
||||
if (credits > room) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `You can buy up to ${room} more credits under the ${MAX_CREDIT_CAP}-credit account cap.`,
|
||||
};
|
||||
}
|
||||
return { ok: true, credits, amountCents: creditPurchaseTotalCents(credits) };
|
||||
}
|
||||
+3
-5
@@ -1,9 +1,7 @@
|
||||
import { Plan } from "@prisma/client";
|
||||
|
||||
export function isSelfHostedEdition(): boolean {
|
||||
return process.env.S2VID_EDITION === "selfhosted";
|
||||
return true;
|
||||
}
|
||||
|
||||
export function hasProFeatures(plan: Plan): boolean {
|
||||
return isSelfHostedEdition() || plan === "PREMIUM";
|
||||
export function hasProFeatures(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
+7
-58
@@ -1,62 +1,11 @@
|
||||
import { Plan } from "@prisma/client";
|
||||
import { getPlanLimits } from "./plans";
|
||||
import { hasProFeatures } from "./edition";
|
||||
import {
|
||||
requiresArtTrackLayoutEntitlement,
|
||||
type LayoutSettings,
|
||||
} from "./layout";
|
||||
import {
|
||||
PREMIUM_REQUIRED_CODE,
|
||||
requiresCustomWatermarkEntitlement,
|
||||
type WatermarkSettings,
|
||||
} from "./watermark";
|
||||
import type { LayoutSettings } from "./layout";
|
||||
import type { WatermarkSettings } from "./watermark";
|
||||
|
||||
export class PremiumRequiredError extends Error {
|
||||
readonly code = PREMIUM_REQUIRED_CODE;
|
||||
readonly status = 403;
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "PremiumRequiredError";
|
||||
}
|
||||
}
|
||||
export function assertCustomWatermarkAllowed(_settings: WatermarkSettings) {}
|
||||
|
||||
export function assertCustomWatermarkAllowed(plan: Plan, settings: WatermarkSettings) {
|
||||
if (!requiresCustomWatermarkEntitlement(settings)) return;
|
||||
if (getPlanLimits(plan).customWatermark || hasProFeatures(plan)) return;
|
||||
throw new PremiumRequiredError(
|
||||
"Custom watermark, typography, logo overlay, and position controls require Pro.",
|
||||
);
|
||||
}
|
||||
|
||||
export function assertArtTrackLayoutAllowed(plan: Plan, settings: LayoutSettings) {
|
||||
if (!requiresArtTrackLayoutEntitlement(settings)) return;
|
||||
if (getPlanLimits(plan).artTrackLayouts || hasProFeatures(plan)) return;
|
||||
throw new PremiumRequiredError(
|
||||
"Blurred backgrounds and art-track layout templates require Pro.",
|
||||
);
|
||||
}
|
||||
export function assertArtTrackLayoutAllowed(_settings: LayoutSettings) {}
|
||||
|
||||
export function assertPerItemImagesAllowed(
|
||||
plan: Plan,
|
||||
sharedImagePath: string,
|
||||
itemImagePaths: Array<string | null | undefined>,
|
||||
) {
|
||||
const unique = new Set(
|
||||
itemImagePaths
|
||||
.map((p) => (p && p.trim() ? p.trim() : sharedImagePath))
|
||||
.filter(Boolean),
|
||||
);
|
||||
// More than one distinct cover in the batch → Pro
|
||||
if (unique.size <= 1) return;
|
||||
if (getPlanLimits(plan).perItemImages || hasProFeatures(plan)) return;
|
||||
throw new PremiumRequiredError(
|
||||
"Matching a unique image to each audio file requires Pro. Free plan uses one shared cover image.",
|
||||
);
|
||||
}
|
||||
|
||||
export function premiumRequiredResponse(message: string) {
|
||||
return {
|
||||
error: message,
|
||||
code: PREMIUM_REQUIRED_CODE,
|
||||
};
|
||||
}
|
||||
_sharedImagePath: string,
|
||||
_itemImagePaths: Array<string | null | undefined>,
|
||||
) {}
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
export type FooterBadgeInput = {
|
||||
name?: string;
|
||||
embedHtml?: string | null;
|
||||
imageUrl?: string | null;
|
||||
linkUrl?: string | null;
|
||||
isActive?: boolean;
|
||||
sortOrder?: number;
|
||||
};
|
||||
|
||||
export type ParsedBadgeEmbed = {
|
||||
imageUrl: string | null;
|
||||
linkUrl: string | null;
|
||||
};
|
||||
|
||||
/** Extract first <a href> + nested/nearby <img src> from pasted badge HTML. */
|
||||
export function parseBadgeEmbedHtml(html: string): ParsedBadgeEmbed {
|
||||
const trimmed = html.trim();
|
||||
if (!trimmed) return { imageUrl: null, linkUrl: null };
|
||||
|
||||
const hrefMatch =
|
||||
trimmed.match(/<a\b[^>]*\bhref\s*=\s*["']([^"']+)["'][^>]*>/i) ??
|
||||
trimmed.match(/href\s*=\s*["']([^"']+)["']/i);
|
||||
const srcMatch =
|
||||
trimmed.match(/<img\b[^>]*\bsrc\s*=\s*["']([^"']+)["'][^>]*>/i) ??
|
||||
trimmed.match(/src\s*=\s*["']([^"']+)["']/i);
|
||||
|
||||
return {
|
||||
linkUrl: hrefMatch?.[1]?.trim() || null,
|
||||
imageUrl: srcMatch?.[1]?.trim() || null,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeBadgeFields(input: FooterBadgeInput) {
|
||||
const name = (input.name ?? "").trim();
|
||||
const embedHtml = input.embedHtml?.trim() || null;
|
||||
let imageUrl = input.imageUrl?.trim() || null;
|
||||
let linkUrl = input.linkUrl?.trim() || null;
|
||||
|
||||
if (embedHtml) {
|
||||
const parsed = parseBadgeEmbedHtml(embedHtml);
|
||||
if (!imageUrl && parsed.imageUrl) imageUrl = parsed.imageUrl;
|
||||
if (!linkUrl && parsed.linkUrl) linkUrl = parsed.linkUrl;
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
embedHtml,
|
||||
imageUrl,
|
||||
linkUrl,
|
||||
isActive: input.isActive ?? true,
|
||||
sortOrder:
|
||||
typeof input.sortOrder === "number" && Number.isFinite(input.sortOrder)
|
||||
? Math.trunc(input.sortOrder)
|
||||
: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function validateBadgeFields(fields: ReturnType<typeof normalizeBadgeFields>): string | null {
|
||||
if (!fields.name) return "Name is required";
|
||||
if (!fields.embedHtml && !fields.imageUrl) {
|
||||
return "Provide embed HTML or an image URL";
|
||||
}
|
||||
if (fields.imageUrl && !fields.linkUrl && !fields.embedHtml) {
|
||||
return "Link URL is required when using image URL only";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
+25
-65
@@ -1,15 +1,13 @@
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import { Plan, Privacy } from "@prisma/client";
|
||||
import { Privacy } from "@prisma/client";
|
||||
import { readAudioTags } from "../audio-tags";
|
||||
import { filenameWithoutExtension, isAllowedResolution } from "../constants";
|
||||
import { prisma } from "../db";
|
||||
import { hasProFeatures } from "../edition";
|
||||
import {
|
||||
assertArtTrackLayoutAllowed,
|
||||
assertCustomWatermarkAllowed,
|
||||
assertPerItemImagesAllowed,
|
||||
PremiumRequiredError,
|
||||
} from "../entitlements";
|
||||
import { FONT_UPLOAD_MAX_BYTES } from "../fonts";
|
||||
import { assertFontFile } from "../fonts-server";
|
||||
@@ -28,7 +26,7 @@ import {
|
||||
isAudioExtensionAllowed,
|
||||
isResolutionAllowedForPlan,
|
||||
} from "../plans";
|
||||
import { releaseReservationSplit, reserveQuota } from "../quota";
|
||||
import { reserveQuota } from "../quota";
|
||||
import { getJobDir } from "../storage";
|
||||
import {
|
||||
assertPathInUserUploads,
|
||||
@@ -88,9 +86,8 @@ export function resolveLayoutFromMetadata(metadata: ItemMetadata): LayoutSetting
|
||||
|
||||
export function validateItemMetadata(
|
||||
metadata: CreateJobPayload["items"][0]["metadata"],
|
||||
plan: Plan,
|
||||
) {
|
||||
const titleErr = validateYouTubeTitle(metadata, plan);
|
||||
const titleErr = validateYouTubeTitle(metadata);
|
||||
if (titleErr) return titleErr;
|
||||
if (!isAllowedResolution(metadata.resolution)) return "Invalid resolution";
|
||||
if (!["PUBLIC", "PRIVATE", "UNLISTED"].includes(metadata.privacy)) {
|
||||
@@ -116,7 +113,7 @@ export function validateItemMetadata(
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function validateJobPayload(user: { id: string; plan: Plan }, body: CreateJobPayload) {
|
||||
export async function validateJobPayload(user: { id: string }, body: CreateJobPayload) {
|
||||
if (!body.imagePath || !body.items?.length) {
|
||||
return "Image and at least one audio file required";
|
||||
}
|
||||
@@ -124,13 +121,10 @@ export async function validateJobPayload(user: { id: string; plan: Plan }, body:
|
||||
const itemImagePaths: Array<string | null | undefined> = [];
|
||||
|
||||
for (const item of body.items) {
|
||||
const metaError = validateItemMetadata(item.metadata, user.plan);
|
||||
const metaError = validateItemMetadata(item.metadata);
|
||||
if (metaError) return metaError;
|
||||
if (!isResolutionAllowedForPlan(item.metadata.resolution, user.plan)) {
|
||||
return `Resolution ${item.metadata.resolution} is not available on your plan`;
|
||||
}
|
||||
if (item.metadata.playlistId && !hasProFeatures(user.plan)) {
|
||||
return "Adding videos to a YouTube playlist requires the Pro plan";
|
||||
if (!isResolutionAllowedForPlan(item.metadata.resolution)) {
|
||||
return `Resolution ${item.metadata.resolution} is not available`;
|
||||
}
|
||||
|
||||
const wm = normalizeWatermarkSettings(
|
||||
@@ -138,10 +132,9 @@ export async function validateJobPayload(user: { id: string; plan: Plan }, body:
|
||||
item.metadata.includeWatermark,
|
||||
);
|
||||
try {
|
||||
assertCustomWatermarkAllowed(user.plan, wm);
|
||||
assertArtTrackLayoutAllowed(user.plan, resolveLayoutFromMetadata(item.metadata));
|
||||
assertCustomWatermarkAllowed(wm);
|
||||
assertArtTrackLayoutAllowed(resolveLayoutFromMetadata(item.metadata));
|
||||
} catch (err) {
|
||||
if (err instanceof PremiumRequiredError) return err.message;
|
||||
if (err instanceof Error && err.message === INVALID_LAYOUT_TEMPLATE_MESSAGE) {
|
||||
return INVALID_LAYOUT_TEMPLATE_MESSAGE;
|
||||
}
|
||||
@@ -152,13 +145,12 @@ export async function validateJobPayload(user: { id: string; plan: Plan }, body:
|
||||
}
|
||||
|
||||
try {
|
||||
assertPerItemImagesAllowed(user.plan, body.imagePath, itemImagePaths);
|
||||
assertPerItemImagesAllowed(body.imagePath, itemImagePaths);
|
||||
} catch (err) {
|
||||
if (err instanceof PremiumRequiredError) return err.message;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const limits = getPlanLimits(user.plan);
|
||||
const limits = getPlanLimits();
|
||||
|
||||
try {
|
||||
const imagePath = assertPathInUserUploads(user.id, body.imagePath);
|
||||
@@ -169,8 +161,8 @@ export async function validateJobPayload(user: { id: string; plan: Plan }, body:
|
||||
}
|
||||
|
||||
for (const item of body.items) {
|
||||
if (!isAudioExtensionAllowed(item.audioFilename, user.plan)) {
|
||||
return `Audio file ${item.audioFilename} is not supported on your plan`;
|
||||
if (!isAudioExtensionAllowed(item.audioFilename)) {
|
||||
return `Audio file ${item.audioFilename} is not supported`;
|
||||
}
|
||||
const audioPath = assertPathInUserUploads(user.id, item.audioPath);
|
||||
await fs.access(audioPath);
|
||||
@@ -218,7 +210,6 @@ export async function validateJobPayload(user: { id: string; plan: Plan }, body:
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof PremiumRequiredError) return err.message;
|
||||
if (err instanceof Error && err.message === "Invalid upload path") {
|
||||
return "Invalid upload path";
|
||||
}
|
||||
@@ -239,7 +230,7 @@ export async function validateJobPayload(user: { id: string; plan: Plan }, body:
|
||||
}
|
||||
|
||||
export async function createVideoJob(
|
||||
user: { id: string; plan: Plan },
|
||||
user: { id: string },
|
||||
body: CreateJobPayload,
|
||||
) {
|
||||
const validationError = await validateJobPayload(user, body);
|
||||
@@ -247,21 +238,9 @@ export async function createVideoJob(
|
||||
if (validationError === INVALID_LAYOUT_TEMPLATE_MESSAGE) {
|
||||
throw new Error(validationError);
|
||||
}
|
||||
const isPremium =
|
||||
validationError.includes("requires Pro") ||
|
||||
validationError.includes("Pro plan");
|
||||
const err = isPremium
|
||||
? new PremiumRequiredError(validationError)
|
||||
: new Error(validationError);
|
||||
throw err;
|
||||
throw new Error(validationError);
|
||||
}
|
||||
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { createdVideoCount: true },
|
||||
});
|
||||
const createdVideoCount = dbUser?.createdVideoCount ?? 0;
|
||||
|
||||
const imagePath = assertPathInUserUploads(user.id, body.imagePath);
|
||||
const items = body.items.map((item) => ({
|
||||
...item,
|
||||
@@ -278,7 +257,7 @@ export async function createVideoJob(
|
||||
: null,
|
||||
}));
|
||||
|
||||
const reservation = await reserveQuota(user.id, items.length);
|
||||
await reserveQuota(user.id, items.length);
|
||||
|
||||
try {
|
||||
const job = await prisma.job.create({
|
||||
@@ -296,21 +275,13 @@ export async function createVideoJob(
|
||||
}
|
||||
: null,
|
||||
item.metadata.includeWatermark,
|
||||
{
|
||||
plan: user.plan,
|
||||
createdVideoCount,
|
||||
itemIndex: index,
|
||||
},
|
||||
);
|
||||
const layout = resolveLayoutFromMetadata(item.metadata);
|
||||
const pro = hasProFeatures(user.plan);
|
||||
const youtubeTitle = resolveYouTubeTitle(item.metadata, user.plan);
|
||||
const burnedTitle = pro
|
||||
? resolveBurnedSongTitle(
|
||||
item.metadata,
|
||||
filenameWithoutExtension(item.audioFilename),
|
||||
)
|
||||
: null;
|
||||
const youtubeTitle = resolveYouTubeTitle(item.metadata);
|
||||
const burnedTitle = resolveBurnedSongTitle(
|
||||
item.metadata,
|
||||
filenameWithoutExtension(item.audioFilename),
|
||||
);
|
||||
return {
|
||||
audioPath: item.audioPath,
|
||||
audioFilename: item.audioFilename,
|
||||
@@ -336,9 +307,7 @@ export async function createVideoJob(
|
||||
watermarkPosition: wm.position,
|
||||
watermarkOffsetX: wm.offsetX,
|
||||
watermarkOffsetY: wm.offsetY,
|
||||
artist: pro
|
||||
? item.metadata.artist?.trim().slice(0, ARTIST_MAX) || null
|
||||
: null,
|
||||
artist: item.metadata.artist?.trim().slice(0, ARTIST_MAX) || null,
|
||||
layoutTemplate: layout.template,
|
||||
blurAmount: layout.blurAmount,
|
||||
blurOpacity: layout.blurOpacity,
|
||||
@@ -347,7 +316,6 @@ export async function createVideoJob(
|
||||
textOffsetX: layout.textOffsetX,
|
||||
textOffsetY: layout.textOffsetY,
|
||||
playlistId: item.metadata.playlistId?.trim() || null,
|
||||
billingSource: index < reservation.fromQuota ? "QUOTA" : "CREDIT",
|
||||
};
|
||||
}),
|
||||
},
|
||||
@@ -430,7 +398,6 @@ export async function createVideoJob(
|
||||
|
||||
return job;
|
||||
} catch (err) {
|
||||
await releaseReservationSplit(user.id, reservation).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -441,15 +408,11 @@ export async function saveUploadedFile(
|
||||
userId: string,
|
||||
file: File,
|
||||
type: UploadFileType,
|
||||
plan: Plan,
|
||||
options?: { sessionKey?: string },
|
||||
) {
|
||||
const limits = getPlanLimits(plan);
|
||||
const limits = getPlanLimits();
|
||||
|
||||
if (type === "font") {
|
||||
if (!limits.customWatermark && !hasProFeatures(plan)) {
|
||||
throw new PremiumRequiredError("Custom watermark fonts require Pro.");
|
||||
}
|
||||
if (file.size > FONT_UPLOAD_MAX_BYTES) {
|
||||
throw new Error("Font file must be 10 MB or smaller");
|
||||
}
|
||||
@@ -461,9 +424,6 @@ export async function saveUploadedFile(
|
||||
}
|
||||
|
||||
if (type === "logo") {
|
||||
if (!limits.customWatermark && !hasProFeatures(plan)) {
|
||||
throw new PremiumRequiredError("Custom logo watermarks require Pro.");
|
||||
}
|
||||
const nameOk = /\.png$/i.test(file.name);
|
||||
const typeOk = file.type === "image/png" || file.type === "";
|
||||
if (!nameOk && !typeOk) {
|
||||
@@ -498,9 +458,9 @@ export async function saveUploadedFile(
|
||||
) {
|
||||
throw new Error("Invalid audio file type");
|
||||
}
|
||||
if (!isAudioExtensionAllowed(file.name, plan)) {
|
||||
if (!isAudioExtensionAllowed(file.name)) {
|
||||
const allowed = limits.allowedAudioExtensions.join(", ");
|
||||
throw new Error(`Your plan supports ${allowed} audio files only`);
|
||||
throw new Error(`Supported audio formats: ${allowed}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { Plan } from "@prisma/client";
|
||||
import { hasProFeatures } from "../edition";
|
||||
import type { CreateJobPayload, CreatePlaylistRequest, ItemMetadata } from "../types";
|
||||
import { createYouTubePlaylist } from "../youtube/upload";
|
||||
|
||||
@@ -22,7 +20,7 @@ export function parseCreatePlaylistInput(raw: unknown): CreatePlaylistRequest |
|
||||
}
|
||||
|
||||
export async function applyCreatePlaylistToItems(
|
||||
user: { id: string; plan: Plan },
|
||||
user: { id: string },
|
||||
items: CreateJobPayload["items"],
|
||||
createPlaylist: CreatePlaylistRequest | null | undefined,
|
||||
) {
|
||||
@@ -30,10 +28,6 @@ export async function applyCreatePlaylistToItems(
|
||||
return { items, playlist: null as Awaited<ReturnType<typeof createYouTubePlaylist>> | null };
|
||||
}
|
||||
|
||||
if (!hasProFeatures(user.plan)) {
|
||||
throw new Error("Creating a YouTube playlist requires the Pro plan");
|
||||
}
|
||||
|
||||
const playlist = await createYouTubePlaylist(user.id, createPlaylist);
|
||||
const nextItems = items.map((item) => ({
|
||||
...item,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SUPPORT_EMAIL, SALES_EMAIL, QUOTA_REQUEST_EMAIL } from "@/lib/plans";
|
||||
import { SUPPORT_EMAIL } from "@/lib/plans";
|
||||
import { BRAND_DOMAIN, BRAND_NAME } from "@/lib/branding";
|
||||
|
||||
export const LEGAL_LAST_UPDATED = "July 30, 2026";
|
||||
@@ -9,7 +9,5 @@ export const LEGAL_OPERATOR = {
|
||||
address: "Universitas u. 2/A",
|
||||
city: "7622 Pécs, Hungary",
|
||||
email: SUPPORT_EMAIL,
|
||||
salesEmail: SALES_EMAIL,
|
||||
quotaRequestEmail: QUOTA_REQUEST_EMAIL,
|
||||
website: `https://www.${BRAND_DOMAIN}`,
|
||||
} as const;
|
||||
|
||||
+10
-53
@@ -1,6 +1,4 @@
|
||||
import { Plan } from "@prisma/client";
|
||||
import { getResolution, RESOLUTIONS } from "./constants";
|
||||
import { isSelfHostedEdition } from "./edition";
|
||||
|
||||
export type PlanLimits = {
|
||||
monthlyQuota: number;
|
||||
@@ -18,35 +16,8 @@ export type PlanLimits = {
|
||||
id3TagSupport: boolean;
|
||||
};
|
||||
|
||||
export const PLAN_LIMITS: Record<Plan, PlanLimits> = {
|
||||
FREE: {
|
||||
monthlyQuota: 10,
|
||||
maxBatchSize: 3,
|
||||
maxResolutionHeight: 720,
|
||||
maxFileSizeBytes: 30 * 1024 * 1024,
|
||||
watermarkOptional: true,
|
||||
customWatermark: false,
|
||||
perItemImages: false,
|
||||
artTrackLayouts: false,
|
||||
allowedAudioExtensions: [".mp3"],
|
||||
id3TagSupport: true,
|
||||
},
|
||||
PREMIUM: {
|
||||
monthlyQuota: 50,
|
||||
maxBatchSize: 5,
|
||||
maxResolutionHeight: 1080,
|
||||
maxFileSizeBytes: 30 * 1024 * 1024,
|
||||
watermarkOptional: true,
|
||||
customWatermark: true,
|
||||
perItemImages: true,
|
||||
artTrackLayouts: true,
|
||||
allowedAudioExtensions: [".mp3", ".wav", ".flac"],
|
||||
id3TagSupport: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const SELFHOSTED_LIMITS: PlanLimits = {
|
||||
monthlyQuota: 1_000_000,
|
||||
export const APP_LIMITS: PlanLimits = {
|
||||
monthlyQuota: Number.MAX_SAFE_INTEGER,
|
||||
maxBatchSize: 100,
|
||||
maxResolutionHeight: Math.max(...RESOLUTIONS.map((r) => r.height)),
|
||||
maxFileSizeBytes: 500 * 1024 * 1024,
|
||||
@@ -58,11 +29,7 @@ export const SELFHOSTED_LIMITS: PlanLimits = {
|
||||
id3TagSupport: true,
|
||||
};
|
||||
|
||||
export const SALES_EMAIL = "songs2vid@atakanozban.com";
|
||||
export const SUPPORT_EMAIL = "songs2vid@atakanozban.com";
|
||||
/** Pro quota reset / extension requests */
|
||||
export const QUOTA_REQUEST_EMAIL = "songs2vid@atakanozban.com";
|
||||
export const UPGRADE_URL = "/#pricing";
|
||||
export const GITEA_ISSUES_URL =
|
||||
process.env.NEXT_PUBLIC_GITEA_ISSUES_URL ?? "https://git.atakanozban.com/Songs2VID/songs2vid/issues";
|
||||
export const GITEA_URL =
|
||||
@@ -70,42 +37,32 @@ export const GITEA_URL =
|
||||
export const DOCKER_HUB_URL =
|
||||
process.env.NEXT_PUBLIC_DOCKER_HUB_URL ?? "https://hub.docker.com/r/atakanozban/songs2vid";
|
||||
|
||||
/** Docusaurus docs base URL. Local: http://localhost:3001 (npm run docs:dev). */
|
||||
export function resolveDocsUrl(): string {
|
||||
if (process.env.NEXT_PUBLIC_DOCS_URL?.trim()) {
|
||||
return process.env.NEXT_PUBLIC_DOCS_URL.replace(/\/$/, "");
|
||||
}
|
||||
const auth = process.env.NEXTAUTH_URL ?? "";
|
||||
if (/localhost|127\.0\.0\.1/i.test(auth)) {
|
||||
return "http://localhost:3001";
|
||||
}
|
||||
return "https://docs.songs2vid.com";
|
||||
}
|
||||
|
||||
export const DOCS_URL = resolveDocsUrl();
|
||||
export const API_DOCS_URL = `${DOCS_URL}/docs/api/overview`;
|
||||
|
||||
export function getPlanLimits(plan: Plan): PlanLimits {
|
||||
if (isSelfHostedEdition()) return SELFHOSTED_LIMITS;
|
||||
return PLAN_LIMITS[plan];
|
||||
export function getPlanLimits(): PlanLimits {
|
||||
return APP_LIMITS;
|
||||
}
|
||||
|
||||
export function getResolutionsForPlan(plan: Plan) {
|
||||
const maxHeight = getPlanLimits(plan).maxResolutionHeight;
|
||||
export function getResolutionsForPlan() {
|
||||
const maxHeight = APP_LIMITS.maxResolutionHeight;
|
||||
return RESOLUTIONS.filter((r) => r.height <= maxHeight);
|
||||
}
|
||||
|
||||
export function isResolutionAllowedForPlan(resolution: string, plan: Plan): boolean {
|
||||
export function isResolutionAllowedForPlan(resolution: string): boolean {
|
||||
const res = getResolution(resolution);
|
||||
if (!res) return false;
|
||||
return res.height <= getPlanLimits(plan).maxResolutionHeight;
|
||||
return res.height <= APP_LIMITS.maxResolutionHeight;
|
||||
}
|
||||
|
||||
export function isAudioExtensionAllowed(filename: string, plan: Plan): boolean {
|
||||
export function isAudioExtensionAllowed(filename: string): boolean {
|
||||
const ext = filename.slice(filename.lastIndexOf(".")).toLowerCase();
|
||||
return getPlanLimits(plan).allowedAudioExtensions.includes(ext);
|
||||
}
|
||||
|
||||
export function getNextMonthlyQuotaReset(from: Date = new Date()): Date {
|
||||
return new Date(from.getFullYear(), from.getMonth() + 1, 1, 0, 0, 0, 0);
|
||||
return APP_LIMITS.allowedAudioExtensions.includes(ext);
|
||||
}
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
import { QuotaExtensionRequestStatus } from "@prisma/client";
|
||||
import { MAX_ADMIN_API_RATE_BONUS } from "./api-rate-limit";
|
||||
import { prisma } from "./db";
|
||||
import { getPlanLimits } from "./plans";
|
||||
|
||||
export const QUOTA_EXTENSION_ANNUAL_LIMIT = 5;
|
||||
/** Max bonus videos an admin may grant per approval. */
|
||||
export const MAX_ADMIN_BONUS_QUOTA = 50;
|
||||
|
||||
export const EXTENSION_KIND = {
|
||||
VIDEO_QUOTA: "VIDEO_QUOTA",
|
||||
API_RATE_LIMIT: "API_RATE_LIMIT",
|
||||
} as const;
|
||||
|
||||
export type ExtensionKind = (typeof EXTENSION_KIND)[keyof typeof EXTENSION_KIND];
|
||||
|
||||
function getCalendarYearBounds(year = new Date().getFullYear()) {
|
||||
return {
|
||||
start: new Date(year, 0, 1),
|
||||
end: new Date(year + 1, 0, 1),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getQuotaExtensionUsage(
|
||||
userId: string,
|
||||
kind: ExtensionKind = EXTENSION_KIND.VIDEO_QUOTA,
|
||||
) {
|
||||
const { start, end } = getCalendarYearBounds();
|
||||
|
||||
const requests = await prisma.quotaExtensionRequest.findMany({
|
||||
where: {
|
||||
userId,
|
||||
kind,
|
||||
requestedAt: { gte: start, lt: end },
|
||||
},
|
||||
orderBy: { requestedAt: "desc" },
|
||||
});
|
||||
|
||||
const used = requests.filter((r) => r.status !== QuotaExtensionRequestStatus.REJECTED).length;
|
||||
|
||||
return {
|
||||
used,
|
||||
limit: QUOTA_EXTENSION_ANNUAL_LIMIT,
|
||||
remaining: Math.max(0, QUOTA_EXTENSION_ANNUAL_LIMIT - used),
|
||||
kind,
|
||||
requests: requests.map((r) => ({
|
||||
id: r.id,
|
||||
kind: r.kind,
|
||||
status: r.status,
|
||||
message: r.message,
|
||||
requestedAt: r.requestedAt.toISOString(),
|
||||
processedAt: r.processedAt?.toISOString() ?? null,
|
||||
adminNote: r.adminNote,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function createQuotaExtensionRequest(
|
||||
userId: string,
|
||||
message = "",
|
||||
kind: ExtensionKind = EXTENSION_KIND.VIDEO_QUOTA,
|
||||
) {
|
||||
const user = await prisma.user.findUniqueOrThrow({ where: { id: userId } });
|
||||
|
||||
if (user.plan !== "PREMIUM") {
|
||||
throw new Error("Only Pro subscribers can request quota resets or extensions.");
|
||||
}
|
||||
|
||||
const usage = await getQuotaExtensionUsage(userId, kind);
|
||||
if (usage.remaining <= 0) {
|
||||
throw new Error(
|
||||
`You have used all ${QUOTA_EXTENSION_ANNUAL_LIMIT} ${
|
||||
kind === EXTENSION_KIND.API_RATE_LIMIT ? "API rate limit" : "quota"
|
||||
} extension requests for this year.`,
|
||||
);
|
||||
}
|
||||
|
||||
const pending = await prisma.quotaExtensionRequest.findFirst({
|
||||
where: { userId, kind, status: QuotaExtensionRequestStatus.PENDING },
|
||||
});
|
||||
if (pending) {
|
||||
throw new Error("You already have a pending request. Please wait for it to be processed.");
|
||||
}
|
||||
|
||||
const request = await prisma.quotaExtensionRequest.create({
|
||||
data: {
|
||||
userId,
|
||||
kind,
|
||||
message: message.trim().slice(0, 1000),
|
||||
status: QuotaExtensionRequestStatus.PENDING,
|
||||
},
|
||||
});
|
||||
|
||||
const updatedUsage = await getQuotaExtensionUsage(userId, kind);
|
||||
|
||||
return {
|
||||
requestId: request.id,
|
||||
...updatedUsage,
|
||||
};
|
||||
}
|
||||
|
||||
/** Call when approving a request in the database (admin / support). */
|
||||
export async function approveQuotaExtensionRequest(
|
||||
requestId: string,
|
||||
options?: { bonusQuota?: number; bonusRateLimit?: number; adminNote?: string },
|
||||
) {
|
||||
const request = await prisma.quotaExtensionRequest.findUniqueOrThrow({
|
||||
where: { id: requestId },
|
||||
include: { user: true },
|
||||
});
|
||||
|
||||
if (request.status !== QuotaExtensionRequestStatus.PENDING) {
|
||||
throw new Error("Request is not pending.");
|
||||
}
|
||||
|
||||
if (request.kind === EXTENSION_KIND.API_RATE_LIMIT) {
|
||||
const bonus = Math.min(
|
||||
MAX_ADMIN_API_RATE_BONUS,
|
||||
Math.max(0, Math.floor(options?.bonusRateLimit ?? 30)),
|
||||
);
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.quotaExtensionRequest.update({
|
||||
where: { id: requestId },
|
||||
data: {
|
||||
status: QuotaExtensionRequestStatus.APPROVED,
|
||||
processedAt: new Date(),
|
||||
adminNote: options?.adminNote?.trim().slice(0, 500) ?? null,
|
||||
},
|
||||
}),
|
||||
prisma.user.update({
|
||||
where: { id: request.userId },
|
||||
data: {
|
||||
apiRateLimitBonus: request.user.apiRateLimitBonus + bonus,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
const bonus = Math.min(
|
||||
MAX_ADMIN_BONUS_QUOTA,
|
||||
Math.max(0, Math.floor(options?.bonusQuota ?? 0)),
|
||||
);
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.quotaExtensionRequest.update({
|
||||
where: { id: requestId },
|
||||
data: {
|
||||
status: QuotaExtensionRequestStatus.APPROVED,
|
||||
processedAt: new Date(),
|
||||
adminNote: options?.adminNote?.trim().slice(0, 500) ?? null,
|
||||
},
|
||||
}),
|
||||
prisma.user.update({
|
||||
where: { id: request.userId },
|
||||
data: {
|
||||
videosUsed: 0,
|
||||
bonusQuota: request.user.bonusQuota + bonus,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
export async function rejectQuotaExtensionRequest(requestId: string, adminNote?: string) {
|
||||
const request = await prisma.quotaExtensionRequest.findUniqueOrThrow({
|
||||
where: { id: requestId },
|
||||
});
|
||||
|
||||
if (request.status !== QuotaExtensionRequestStatus.PENDING) {
|
||||
throw new Error("Request is not pending.");
|
||||
}
|
||||
|
||||
await prisma.quotaExtensionRequest.update({
|
||||
where: { id: requestId },
|
||||
data: {
|
||||
status: QuotaExtensionRequestStatus.REJECTED,
|
||||
processedAt: new Date(),
|
||||
adminNote: adminNote?.trim().slice(0, 500) ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function getEffectiveQuotaLimit(plan: Parameters<typeof getPlanLimits>[0], bonusQuota: number) {
|
||||
return getPlanLimits(plan).monthlyQuota + bonusQuota;
|
||||
}
|
||||
+23
-266
@@ -1,282 +1,39 @@
|
||||
import { Plan } from "@prisma/client";
|
||||
import {
|
||||
CreditInsufficientError,
|
||||
applyMonthlyCreditRenewal,
|
||||
deductUserCredit,
|
||||
refundUserCredit,
|
||||
} from "./billing";
|
||||
import { FREE_EXTRA_CREDITS_MAX, MAX_CREDIT_CAP, PRO_UPGRADE_REQUIRED_SUFFIX, monthlyCreditsForPlan } from "./credits";
|
||||
import { prisma } from "./db";
|
||||
import { hasProFeatures, isSelfHostedEdition } from "./edition";
|
||||
import { getNextMonthlyQuotaReset, getPlanLimits } from "./plans";
|
||||
import { watermarkFreeRendersRemaining } from "./watermark-policy";
|
||||
|
||||
export function formatQuotaResetCountdown(resetsAt: Date | string): string {
|
||||
const target = typeof resetsAt === "string" ? new Date(resetsAt) : resetsAt;
|
||||
const ms = Math.max(0, target.getTime() - Date.now());
|
||||
const totalSec = Math.ceil(ms / 1000);
|
||||
const hours = Math.floor(totalSec / 3600);
|
||||
const minutes = Math.floor((totalSec % 3600) / 60);
|
||||
const seconds = totalSec % 60;
|
||||
return `${hours}h ${minutes}m ${seconds}s`;
|
||||
}
|
||||
|
||||
export function formatQuotaResetDisplay(plan: Plan, resetsAt: Date | string): string {
|
||||
const target = typeof resetsAt === "string" ? new Date(resetsAt) : resetsAt;
|
||||
return target.toLocaleDateString(undefined, {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function getQuotaExceededMessage(
|
||||
plan: Plan,
|
||||
resetsAt: Date,
|
||||
extraCredits: number,
|
||||
): string {
|
||||
const resetLabel = formatQuotaResetDisplay(plan, resetsAt);
|
||||
if (plan === "PREMIUM") {
|
||||
return extraCredits > 0
|
||||
? `Monthly Pro quota exhausted. You still have ${extraCredits} extra credits.`
|
||||
: `Quota exceeded. Your Pro monthly credits reset on ${resetLabel}.`;
|
||||
}
|
||||
if (extraCredits > 0) {
|
||||
return `Monthly free quota exhausted. You still have ${extraCredits} extra credits.`;
|
||||
}
|
||||
return (
|
||||
`You've used your free monthly credits (10) and any extra top-ups (max ${FREE_EXTRA_CREDITS_MAX}). ` +
|
||||
`Upgrade to the Pro monthly plan to continue uploading.` +
|
||||
PRO_UPGRADE_REQUIRED_SUFFIX
|
||||
);
|
||||
}
|
||||
|
||||
/** Reset cycle when due: rollover unused into extras, add new monthly, trim to MAX_CREDIT_CAP. */
|
||||
export async function ensureQuotaReset(userId: string) {
|
||||
const user = await prisma.user.findUniqueOrThrow({ where: { id: userId } });
|
||||
const now = new Date();
|
||||
const expectedMonthly = monthlyCreditsForPlan(user.plan);
|
||||
|
||||
if (now >= user.quotaResetAt) {
|
||||
return applyMonthlyCreditRenewal(userId, {
|
||||
plan: user.plan,
|
||||
newMonthlyCredits: expectedMonthly,
|
||||
quotaResetAt: getNextMonthlyQuotaReset(now),
|
||||
});
|
||||
}
|
||||
|
||||
// Keep monthlyCredits in sync if plan was changed mid-cycle without reset
|
||||
if (user.monthlyCredits !== expectedMonthly && user.videosUsed === 0 && user.extraCredits === 0) {
|
||||
return prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { monthlyCredits: expectedMonthly },
|
||||
});
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
import { APP_LIMITS } from "./plans";
|
||||
|
||||
export async function getQuotaInfo(userId: string) {
|
||||
const user = await ensureQuotaReset(userId);
|
||||
const limits = getPlanLimits(user.plan);
|
||||
const limit = isSelfHostedEdition()
|
||||
? limits.monthlyQuota
|
||||
: user.monthlyCredits + (user.plan === "PREMIUM" ? user.bonusQuota : 0);
|
||||
const remaining = Math.max(0, limit - user.videosUsed);
|
||||
const extraCredits = isSelfHostedEdition() ? 0 : user.extraCredits;
|
||||
const user = await prisma.user.findUniqueOrThrow({ where: { id: userId } });
|
||||
|
||||
return {
|
||||
used: user.videosUsed,
|
||||
creditsUsed: user.videosUsed,
|
||||
limit,
|
||||
monthlyCredits: user.monthlyCredits,
|
||||
baseLimit: limits.monthlyQuota,
|
||||
bonusQuota: isSelfHostedEdition() ? 0 : user.bonusQuota,
|
||||
remaining,
|
||||
/** @deprecated use extraCredits */
|
||||
videoCredits: extraCredits,
|
||||
extraCredits,
|
||||
creditBalanceMax: MAX_CREDIT_CAP,
|
||||
requiresProUpgrade:
|
||||
!isSelfHostedEdition() &&
|
||||
user.plan === "FREE" &&
|
||||
remaining === 0 &&
|
||||
extraCredits === 0,
|
||||
freeTopUpPurchased: user.freeTopUpPurchased,
|
||||
totalAvailable: remaining + extraCredits,
|
||||
resetsAt: user.quotaResetAt.toISOString(),
|
||||
resetsIn: formatQuotaResetDisplay(user.plan, user.quotaResetAt),
|
||||
plan: user.plan,
|
||||
subscriptionStatus: hasProFeatures(user.plan) ? ("pro" as const) : ("free" as const),
|
||||
used: user.createdVideoCount,
|
||||
createdVideoCount: user.createdVideoCount,
|
||||
watermarkFreeRemaining: hasProFeatures(user.plan)
|
||||
? null
|
||||
: watermarkFreeRendersRemaining(user.createdVideoCount),
|
||||
maxBatchSize: limits.maxBatchSize,
|
||||
watermarkOptional: limits.watermarkOptional,
|
||||
customWatermark: limits.customWatermark,
|
||||
perItemImages: limits.perItemImages,
|
||||
artTrackLayouts: limits.artTrackLayouts,
|
||||
maxResolutionHeight: limits.maxResolutionHeight,
|
||||
selfHosted: isSelfHostedEdition(),
|
||||
maxBatchSize: APP_LIMITS.maxBatchSize,
|
||||
maxResolutionHeight: APP_LIMITS.maxResolutionHeight,
|
||||
watermarkOptional: true,
|
||||
customWatermark: true,
|
||||
perItemImages: true,
|
||||
artTrackLayouts: true,
|
||||
selfHosted: true,
|
||||
unlimited: true,
|
||||
};
|
||||
}
|
||||
|
||||
export type ReservationSplit = {
|
||||
fromQuota: number;
|
||||
fromCredits: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Monthly first, then never-expiring extras for both FREE and PREMIUM.
|
||||
*/
|
||||
export function planReservation(
|
||||
_plan: Plan,
|
||||
remainingQuota: number,
|
||||
extraCredits: number,
|
||||
count: number,
|
||||
): ReservationSplit | null {
|
||||
if (count <= 0) return { fromQuota: 0, fromCredits: 0 };
|
||||
const fromQuota = Math.min(count, Math.max(0, remainingQuota));
|
||||
const fromCredits = count - fromQuota;
|
||||
if (fromCredits > extraCredits) return null;
|
||||
return { fromQuota, fromCredits };
|
||||
}
|
||||
|
||||
export async function checkQuota(userId: string, requestedCount: number) {
|
||||
if (isSelfHostedEdition()) {
|
||||
const info = await getQuotaInfo(userId);
|
||||
if (requestedCount > info.maxBatchSize) {
|
||||
return {
|
||||
ok: false as const,
|
||||
error: `Batch limit exceeded. Max ${info.maxBatchSize} files per batch.`,
|
||||
used: info.used,
|
||||
limit: info.limit,
|
||||
remaining: info.remaining,
|
||||
videoCredits: 0,
|
||||
extraCredits: 0,
|
||||
resetsAt: info.resetsAt,
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true as const,
|
||||
...info,
|
||||
reservation: { fromQuota: requestedCount, fromCredits: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
const user = await ensureQuotaReset(userId);
|
||||
const limits = getPlanLimits(user.plan);
|
||||
const limit = user.monthlyCredits + (user.plan === "PREMIUM" ? user.bonusQuota : 0);
|
||||
const remaining = Math.max(0, limit - user.videosUsed);
|
||||
|
||||
if (requestedCount > limits.maxBatchSize) {
|
||||
return {
|
||||
ok: false as const,
|
||||
error: `Batch limit exceeded. Your plan allows up to ${limits.maxBatchSize} files per batch.`,
|
||||
used: user.videosUsed,
|
||||
limit,
|
||||
remaining,
|
||||
videoCredits: user.extraCredits,
|
||||
extraCredits: user.extraCredits,
|
||||
resetsAt: user.quotaResetAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
const split = planReservation(user.plan, remaining, user.extraCredits, requestedCount);
|
||||
if (!split) {
|
||||
return {
|
||||
ok: false as const,
|
||||
error: getQuotaExceededMessage(user.plan, user.quotaResetAt, user.extraCredits),
|
||||
used: user.videosUsed,
|
||||
limit,
|
||||
remaining,
|
||||
videoCredits: user.extraCredits,
|
||||
extraCredits: user.extraCredits,
|
||||
resetsAt: user.quotaResetAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
const info = await getQuotaInfo(userId);
|
||||
return { ok: true as const, ...info, reservation: split };
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically reserve monthly credits first, then extras.
|
||||
*/
|
||||
export async function reserveQuota(userId: string, count: number): Promise<ReservationSplit> {
|
||||
if (count <= 0) return { fromQuota: 0, fromCredits: 0 };
|
||||
|
||||
if (isSelfHostedEdition()) {
|
||||
const limits = getPlanLimits("FREE");
|
||||
if (count > limits.maxBatchSize) {
|
||||
throw new Error(`Batch limit exceeded. Max ${limits.maxBatchSize} files per batch.`);
|
||||
}
|
||||
await ensureQuotaReset(userId);
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { videosUsed: { increment: count } },
|
||||
});
|
||||
return { fromQuota: count, fromCredits: 0 };
|
||||
if (requestedCount > info.maxBatchSize) {
|
||||
return {
|
||||
ok: false as const,
|
||||
error: `Batch limit exceeded. Max ${info.maxBatchSize} files per batch.`,
|
||||
...info,
|
||||
};
|
||||
}
|
||||
|
||||
await ensureQuotaReset(userId);
|
||||
return { ok: true as const, ...info };
|
||||
}
|
||||
|
||||
const limits = getPlanLimits(
|
||||
(await prisma.user.findUniqueOrThrow({ where: { id: userId } })).plan,
|
||||
);
|
||||
if (count > limits.maxBatchSize) {
|
||||
throw new Error(
|
||||
`Batch limit exceeded. Your plan allows up to ${limits.maxBatchSize} files per batch.`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await deductUserCredit(userId, count);
|
||||
return { fromQuota: result.fromMonthly, fromCredits: result.fromExtra };
|
||||
} catch (err) {
|
||||
if (err instanceof CreditInsufficientError) {
|
||||
const user = await prisma.user.findUniqueOrThrow({ where: { id: userId } });
|
||||
throw new Error(
|
||||
getQuotaExceededMessage(user.plan, user.quotaResetAt, user.extraCredits),
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
export async function reserveQuota(userId: string, count: number) {
|
||||
if (count > APP_LIMITS.maxBatchSize) {
|
||||
throw new Error(`Batch limit exceeded. Max ${APP_LIMITS.maxBatchSize} files per batch.`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function releaseQuota(userId: string, count: number) {
|
||||
if (count <= 0) return;
|
||||
await refundUserCredit(userId, count, 0);
|
||||
}
|
||||
|
||||
export async function releaseCredits(userId: string, count: number) {
|
||||
if (count <= 0) return;
|
||||
await refundUserCredit(userId, 0, count);
|
||||
}
|
||||
|
||||
export async function releaseReservation(
|
||||
userId: string,
|
||||
billingSource: "QUOTA" | "CREDIT",
|
||||
count = 1,
|
||||
) {
|
||||
if (billingSource === "CREDIT") {
|
||||
await releaseCredits(userId, count);
|
||||
} else {
|
||||
await releaseQuota(userId, count);
|
||||
}
|
||||
}
|
||||
|
||||
export async function releaseReservationSplit(userId: string, split: ReservationSplit) {
|
||||
if (split.fromQuota > 0) await releaseQuota(userId, split.fromQuota);
|
||||
if (split.fromCredits > 0) await releaseCredits(userId, split.fromCredits);
|
||||
}
|
||||
|
||||
/** @deprecated Prefer reserveQuota at create */
|
||||
export async function incrementQuota(userId: string, count: number) {
|
||||
await reserveQuota(userId, count);
|
||||
}
|
||||
|
||||
export function getInitialQuotaResetAt(): Date {
|
||||
return getNextMonthlyQuotaReset();
|
||||
await prisma.user.findUniqueOrThrow({ where: { id: userId } });
|
||||
}
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import type Stripe from "stripe";
|
||||
import { STRIPE_PRODUCT_TAX_CODE } from "@/lib/credits";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getStripe } from "@/lib/stripe";
|
||||
|
||||
/** Enable Stripe Tax only when Dashboard registrations are active (STRIPE_AUTOMATIC_TAX=true). */
|
||||
export function isStripeAutomaticTaxEnabled(): boolean {
|
||||
return process.env.STRIPE_AUTOMATIC_TAX === "true";
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared Checkout Session options required/recommended by Stripe for SaaS:
|
||||
* - client_reference_id for linking
|
||||
* - automatic_tax (opt-in) + customer_update.address when tax is on
|
||||
* - tax_code already set on product_data by callers
|
||||
*/
|
||||
export function checkoutTaxAndReferenceOptions(userId: string): Partial<Stripe.Checkout.SessionCreateParams> {
|
||||
const opts: Partial<Stripe.Checkout.SessionCreateParams> = {
|
||||
client_reference_id: userId,
|
||||
};
|
||||
|
||||
if (isStripeAutomaticTaxEnabled()) {
|
||||
opts.automatic_tax = { enabled: true };
|
||||
opts.customer_update = { address: "auto", name: "auto" };
|
||||
// Collect billing address so tax can be calculated for new/returning customers
|
||||
opts.billing_address_collection = "required";
|
||||
}
|
||||
|
||||
return opts;
|
||||
}
|
||||
|
||||
/** tax_behavior required on Prices when automatic tax is enabled. */
|
||||
export function priceDataTaxFields(): { tax_behavior?: Stripe.Checkout.SessionCreateParams.LineItem.PriceData["tax_behavior"] } {
|
||||
if (!isStripeAutomaticTaxEnabled()) return {};
|
||||
// Exclusive: listed prices are pre-tax; VAT/GST added at checkout
|
||||
return { tax_behavior: "exclusive" };
|
||||
}
|
||||
|
||||
export function productDataWithTaxCode(
|
||||
name: string,
|
||||
description: string,
|
||||
): Stripe.Checkout.SessionCreateParams.LineItem.PriceData.ProductData {
|
||||
return {
|
||||
name,
|
||||
description,
|
||||
tax_code: STRIPE_PRODUCT_TAX_CODE,
|
||||
};
|
||||
}
|
||||
|
||||
/** Ensure the app user has a Stripe Customer and return its id. */
|
||||
export async function ensureStripeCustomer(userId: string, email: string): Promise<string> {
|
||||
const dbUser = await prisma.user.findUniqueOrThrow({
|
||||
where: { id: userId },
|
||||
select: { stripeCustomerId: true },
|
||||
});
|
||||
|
||||
if (dbUser.stripeCustomerId) return dbUser.stripeCustomerId;
|
||||
|
||||
const stripe = getStripe();
|
||||
const customer = await stripe.customers.create({
|
||||
email,
|
||||
metadata: { userId },
|
||||
});
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { stripeCustomerId: customer.id },
|
||||
});
|
||||
|
||||
return customer.id;
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import Stripe from "stripe";
|
||||
|
||||
let stripe: Stripe | null = null;
|
||||
|
||||
export function getStripe(): Stripe {
|
||||
const key = process.env.STRIPE_SECRET_KEY;
|
||||
if (!key) {
|
||||
throw new Error("STRIPE_SECRET_KEY is not configured");
|
||||
}
|
||||
if (!stripe) {
|
||||
stripe = new Stripe(key);
|
||||
}
|
||||
return stripe;
|
||||
}
|
||||
|
||||
export function isStripeConfigured(): boolean {
|
||||
return Boolean(process.env.STRIPE_SECRET_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bypass Stripe Checkout when:
|
||||
* - BILLING_DEV_MOCK=true, or
|
||||
* - development and no STRIPE_SECRET_KEY (so UI still works without stripe listen)
|
||||
* Set BILLING_DEV_MOCK=false to force real Stripe even without keys (will 503).
|
||||
*/
|
||||
export function isBillingDevMock(): boolean {
|
||||
if (process.env.BILLING_DEV_MOCK === "true") return true;
|
||||
if (process.env.BILLING_DEV_MOCK === "false") return false;
|
||||
return process.env.NODE_ENV === "development" && !process.env.STRIPE_SECRET_KEY;
|
||||
}
|
||||
+10
-18
@@ -1,6 +1,3 @@
|
||||
import { Plan } from "@prisma/client";
|
||||
import { hasProFeatures } from "./edition";
|
||||
|
||||
export type TitleFields = {
|
||||
/** YouTube video title */
|
||||
title?: string | null;
|
||||
@@ -9,18 +6,16 @@ export type TitleFields = {
|
||||
artist?: string | null;
|
||||
};
|
||||
|
||||
/** Resolve the title sent to YouTube. Pro may omit video title → `${artist} - ${songTitle}`. */
|
||||
export function resolveYouTubeTitle(meta: TitleFields, plan: Plan): string {
|
||||
/** Resolve the title sent to YouTube, falling back to artist and song metadata. */
|
||||
export function resolveYouTubeTitle(meta: TitleFields): string {
|
||||
const videoTitle = meta.title?.trim();
|
||||
if (videoTitle) return videoTitle;
|
||||
|
||||
if (hasProFeatures(plan)) {
|
||||
const artist = meta.artist?.trim();
|
||||
const song = meta.songTitle?.trim();
|
||||
if (artist && song) return `${artist} - ${song}`;
|
||||
if (song) return song;
|
||||
if (artist) return artist;
|
||||
}
|
||||
const artist = meta.artist?.trim();
|
||||
const song = meta.songTitle?.trim();
|
||||
if (artist && song) return `${artist} - ${song}`;
|
||||
if (song) return song;
|
||||
if (artist) return artist;
|
||||
|
||||
return "";
|
||||
}
|
||||
@@ -33,13 +28,10 @@ export function resolveBurnedSongTitle(
|
||||
return meta.songTitle?.trim() || fallback;
|
||||
}
|
||||
|
||||
export function validateYouTubeTitle(meta: TitleFields, plan: Plan): string | null {
|
||||
const resolved = resolveYouTubeTitle(meta, plan);
|
||||
export function validateYouTubeTitle(meta: TitleFields): string | null {
|
||||
const resolved = resolveYouTubeTitle(meta);
|
||||
if (!resolved) {
|
||||
if (hasProFeatures(plan)) {
|
||||
return "Enter a video title, or both artist and song title for the YouTube fallback";
|
||||
}
|
||||
return "Each video must have a video title";
|
||||
return "Enter a video title, song title, or artist";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
+7
-7
@@ -13,23 +13,23 @@ export type ItemMetadata = {
|
||||
madeForKids: boolean;
|
||||
embeddable: boolean;
|
||||
creativeCommons: boolean;
|
||||
/** Legacy Free toggle default Songs2VID branding when watermark.mode is omitted. */
|
||||
/** Legacy toggle — default Songs2VID branding when watermark.mode is omitted. */
|
||||
includeWatermark: boolean;
|
||||
/** YouTube playlist ID (Pro only). Video is added after upload. */
|
||||
/** YouTube playlist ID. Video is added after upload. */
|
||||
playlistId?: string | null;
|
||||
/**
|
||||
* Optional per-item cover image path (Pro / perItemImages).
|
||||
* Optional per-item cover image path.
|
||||
* When omitted, the job-level shared imagePath is used.
|
||||
*/
|
||||
imagePath?: string | null;
|
||||
/** Pro custom branding. Free may only use mode none|default at bottom-right. */
|
||||
/** Custom branding watermark settings. */
|
||||
watermark?: Partial<WatermarkSettings> | null;
|
||||
/** Artist line for art-track layouts (Pro, on-video only). */
|
||||
/** Artist line for art-track layouts (on-video only). */
|
||||
artist?: string | null;
|
||||
/** Song / track title burned into art-track layouts (Pro, on-video only). */
|
||||
/** Song / track title burned into art-track layouts (on-video only). */
|
||||
songTitle?: string | null;
|
||||
/**
|
||||
* Pro art-track layout. Prefer camelCase; snake_case aliases accepted:
|
||||
* Art-track layout. Prefer camelCase; snake_case aliases accepted:
|
||||
* layout_template, blur_amount, text_padding.
|
||||
*/
|
||||
layout?: Partial<LayoutSettings> & {
|
||||
|
||||
+2
-66
@@ -1,79 +1,15 @@
|
||||
import path from "path";
|
||||
import type { Plan } from "@prisma/client";
|
||||
import { hasProFeatures } from "./edition";
|
||||
import {
|
||||
DEFAULT_WATERMARK,
|
||||
normalizeWatermarkSettings,
|
||||
type WatermarkSettings,
|
||||
} from "./watermark";
|
||||
|
||||
/** Free users get this many lifetime watermark-free successful videos. */
|
||||
export const FREE_UNWATERMARKED_VIDEO_LIMIT = 2;
|
||||
|
||||
export type SubscriptionStatusLabel = "free" | "pro";
|
||||
|
||||
export function subscriptionStatusFromPlan(plan: Plan): SubscriptionStatusLabel {
|
||||
return hasProFeatures(plan) ? "pro" : "free";
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the Songs2VID brand watermark must be applied for this render slot.
|
||||
* Pro / self-hosted: never forced. Free: forced after FREE_UNWATERMARKED_VIDEO_LIMIT.
|
||||
*/
|
||||
export function shouldApplyBrandWatermark(options: {
|
||||
plan: Plan;
|
||||
createdVideoCount: number;
|
||||
/** 0-based index within the current batch (accounts for multi-file jobs). */
|
||||
itemIndex?: number;
|
||||
}): boolean {
|
||||
if (hasProFeatures(options.plan)) return false;
|
||||
const slot = options.createdVideoCount + (options.itemIndex ?? 0);
|
||||
return slot >= FREE_UNWATERMARKED_VIDEO_LIMIT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply free/pro brand-watermark policy to normalized settings.
|
||||
* - Pro: strip default brand watermark (custom text/logo kept).
|
||||
* - Free under limit: force mode `none`.
|
||||
* - Free at/over limit: force default brand overlay (bottom-right).
|
||||
*/
|
||||
/** Preserve the user's optional watermark choice without tier-based forcing. */
|
||||
export function applyBrandWatermarkPolicy(
|
||||
input: Partial<WatermarkSettings> | null | undefined,
|
||||
includeWatermarkFallback: boolean,
|
||||
options: {
|
||||
plan: Plan;
|
||||
createdVideoCount: number;
|
||||
itemIndex?: number;
|
||||
},
|
||||
): WatermarkSettings {
|
||||
const base = normalizeWatermarkSettings(input, includeWatermarkFallback);
|
||||
|
||||
if (hasProFeatures(options.plan)) {
|
||||
if (base.mode === "default") {
|
||||
return { ...base, mode: "none" };
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
if (shouldApplyBrandWatermark(options)) {
|
||||
return {
|
||||
...DEFAULT_WATERMARK,
|
||||
mode: "default",
|
||||
position: "bottom-right",
|
||||
offsetX: 20,
|
||||
offsetY: 20,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
mode: "none",
|
||||
};
|
||||
return normalizeWatermarkSettings(input, includeWatermarkFallback);
|
||||
}
|
||||
|
||||
export function watermarkFreeRendersRemaining(createdVideoCount: number): number {
|
||||
return Math.max(0, FREE_UNWATERMARKED_VIDEO_LIMIT - createdVideoCount);
|
||||
}
|
||||
|
||||
/** Absolute path to the Songs2VID brand watermark asset (PNG). */
|
||||
export const WATERMARK_FILE_PATH = path.join(process.cwd(), "assets", "watermark.png");
|
||||
|
||||
+2
-3
@@ -30,7 +30,7 @@ export type WatermarkSettings = {
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
/**
|
||||
* Typography (Pro / text mode).
|
||||
* Typography (text mode).
|
||||
* `system` = FFmpeg default; curated keys map to assets/fonts; `custom` uses fontPath.
|
||||
*/
|
||||
fontKey?: WatermarkFontKey;
|
||||
@@ -178,7 +178,7 @@ export function normalizeWatermarkSettings(
|
||||
};
|
||||
}
|
||||
|
||||
/** True when settings go beyond Free-tier default branding toggle. */
|
||||
/** True when settings go beyond the default branding toggle. */
|
||||
export function requiresCustomWatermarkEntitlement(settings: WatermarkSettings): boolean {
|
||||
if (settings.mode === "text" || settings.mode === "logo") return true;
|
||||
if (settings.mode === "none") return false;
|
||||
@@ -189,4 +189,3 @@ export function requiresCustomWatermarkEntitlement(settings: WatermarkSettings):
|
||||
return false;
|
||||
}
|
||||
|
||||
export const PREMIUM_REQUIRED_CODE = "PREMIUM_REQUIRED" as const;
|
||||
|
||||
Reference in New Issue
Block a user