Initial OSS scaffold from Songs2VID (pre-strip)
This commit is contained in:
+307
@@ -0,0 +1,307 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user