Separate YouTube video titles from on-video song/artist fields with Pro gating, serve curated fonts and watermark assets for 1:1 preview parity, and include billing/API/docs/deploy stack for self-hosted release. Co-authored-by: Cursor <cursoragent@cursor.com>
305 lines
8.3 KiB
TypeScript
305 lines
8.3 KiB
TypeScript
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,
|
|
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",
|
|
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",
|
|
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);
|
|
}
|