import { prisma } from "./prisma"; import { spend, getWallet, InsufficientCreditsError } from "./wallet"; import { isFree } from "./ip"; import { invalidatePerks, getPerksForIp } from "./perks"; /** * Marketplace catalogue + purchase engine. * * Prices are centi-credits (mockup € → credits). The server is the ONLY * authority on price, stock, and per-IP limits — the client never decides. */ export interface PurchaseOptions { plan?: "monthly" | "annual"; // subscription durationDays?: number; // ad-frame format?: "static" | "gif"; // ad-frame url?: string; // ad-frame destination petDesign?: string; // pet slug petChar?: string; // pet glyph petPosition?: "left" | "right" | "both"; } export interface PurchaseResult { ok: true; productId: string; pricePaid: number; balance: number; entitlementKinds: string[]; } export class PurchaseError extends Error { status: number; constructor(message: string, status = 400) { super(message); this.name = "PurchaseError"; this.status = status; } } const DAY_MS = 24 * 60 * 60 * 1000; /** Effective unit price for a product given options (promo + add-ons). */ function effectivePrice(product: any, options: PurchaseOptions): number { let price = product.promoPrice ?? product.basePrice; let meta: any = {}; try { meta = product.metaJson ? JSON.parse(product.metaJson) : {}; } catch { meta = {}; } if (product.kind === "subscription") { const plan = (meta.plans ?? []).find((p: any) => p.id === (options.plan ?? "monthly")); if (plan) price = plan.price; } if (product.kind === "ad-frame") { const dur = (meta.durations ?? []).find( (d: any) => d.days === (options.durationDays ?? 7) ); const fmt = (meta.formats ?? []).find( (f: any) => f.id === (options.format ?? "static") ); price += (dur?.extra ?? 0) + (fmt?.extra ?? 0); } return price; } export async function listProducts(category?: string) { return prisma.product.findMany({ where: { active: true, ...(category ? { category } : {}) }, orderBy: [{ sortOrder: "asc" }, { name: "asc" }], }); } export function getProduct(id: string) { return prisma.product.findUnique({ where: { id } }); } /** All entitlements an IP owns (active), for "Mes achats". */ export async function getEntitlements(ip: string) { const now = new Date(); const rows = await prisma.entitlement.findMany({ where: { ip, active: true }, orderBy: { createdAt: "desc" }, }); return rows.filter((e) => !e.expiresAt || e.expiresAt >= now); } async function countActiveEntitlements(ip: string, kind: string): Promise { const now = new Date(); const rows = await prisma.entitlement.findMany({ where: { ip, kind, active: true } }); return rows.filter((e) => !e.expiresAt || e.expiresAt >= now).length; } /** * Buy a product. Enforces per-IP limits + stock, spends credits atomically, * grants the entitlement(s). Returns the new balance and granted kinds. * Side-effect: caller should bust perks cache + broadcast (done in the route). */ export async function purchase( ip: string, productId: string, options: PurchaseOptions = {} ): Promise<{ result: PurchaseResult; visiblePerkChanged: boolean; adCreated: boolean }> { const product = await getProduct(productId); if (!product || !product.active) throw new PurchaseError("Produit introuvable", 404); const free = isFree(ip); const price = effectivePrice(product, options); // Resolve which entitlement kind(s) this grants + per-IP limit checks. const grants: { kind: string; expiresAt?: Date; meta?: any }[] = []; let visiblePerkChanged = false; let adCreated = false; switch (product.kind) { case "subscription": { // NoAds: 1 active max. if ((await countActiveEntitlements(ip, "noads")) >= 1) throw new PurchaseError("Tu as déjà un abonnement NoAds actif", 409); const plan = options.plan ?? "monthly"; const days = plan === "annual" ? 365 : 30; grants.push({ kind: "noads", expiresAt: new Date(Date.now() + days * DAY_MS), meta: { plan } }); break; } case "ip-skin": { // Style Doré: 1 active max + global stock cap. if ((await countActiveEntitlements(ip, "style-dore")) >= 1) throw new PurchaseError("Tu possèdes déjà le Style Doré", 409); grants.push({ kind: "style-dore", meta: { variant: "gold" } }); visiblePerkChanged = true; break; } case "pet": { const char = options.petChar ?? "♥"; grants.push({ kind: "pet", meta: { design: options.petDesign ?? "coeur", char, position: options.petPosition ?? "left" }, }); visiblePerkChanged = true; break; } case "ad-frame": { if ((await countActiveEntitlements(ip, "ad-frame")) >= 1) throw new PurchaseError("Tu as déjà un cadre de pub actif", 409); const days = options.durationDays ?? 7; grants.push({ kind: "ad-frame", expiresAt: new Date(Date.now() + days * DAY_MS), meta: { format: options.format ?? "static", url: options.url ?? "", days }, }); break; } case "rich": { const kind = product.id === "rich-js" ? "rich-js" : "rich-htmlcss"; if ((await countActiveEntitlements(ip, kind)) >= 1) throw new PurchaseError("Déjà débloqué", 409); grants.push({ kind }); break; } case "unlock": { // no-file-limit, element-skin, etc. — slug == kind. if ((await countActiveEntitlements(ip, product.id)) >= 1) throw new PurchaseError("Déjà débloqué", 409); grants.push({ kind: product.id }); if (product.id === "element-skin" || product.id === "ip-colors") visiblePerkChanged = false; // viewer-scoped break; } case "consumable": { // audio-alert: grant the entitlement once; firing is a separate action. if ((await countActiveEntitlements(ip, "audio-alert")) < 1) { grants.push({ kind: "audio-alert" }); } else { // Already owned — buying again is a harmless top-up; just record it. grants.push({ kind: "audio-alert" }); } break; } case "send-skin": { if ((await countActiveEntitlements(ip, product.id)) >= 1) throw new PurchaseError("Déjà débloqué", 409); let skinMeta: any = {}; try { skinMeta = product.metaJson ? JSON.parse(product.metaJson) : {}; } catch {} grants.push({ kind: product.id, meta: skinMeta }); break; } case "bundle": { // Cosmetic bundle: Style Doré + 1 pet. if ((await countActiveEntitlements(ip, "style-dore")) < 1) grants.push({ kind: "style-dore", meta: { variant: "gold" } }); if ((await countActiveEntitlements(ip, "pet")) < 3) { const char = options.petChar ?? "★"; grants.push({ kind: "pet", meta: { design: options.petDesign ?? "etoile", char, position: options.petPosition ?? "left" }, }); } if (grants.length === 0) throw new PurchaseError("Tu possèdes déjà ce que contient le pack", 409); visiblePerkChanged = true; break; } default: throw new PurchaseError("Type de produit non géré", 400); } // Stock check for limited products (Style Doré). Done transactionally with the // spend so we can never oversell the 50-unit cap under concurrency. let balance = 0; try { balance = await prisma.$transaction(async (tx) => { if (product.stockLimit != null) { const fresh = await tx.product.findUnique({ where: { id: product.id } }); if (!fresh) throw new PurchaseError("Produit introuvable", 404); if (fresh.stockSold >= fresh.stockLimit) throw new PurchaseError("Stock épuisé", 409); await tx.product.update({ where: { id: product.id }, data: { stockSold: { increment: 1 } }, }); } // Spend (skips real deduction for localhost free mode). if (!free && price > 0) { const w = await tx.wallet.upsert({ where: { ip }, create: { ip, balance: 0 }, update: {}, }); if (w.balance < price) throw new InsufficientCreditsError(); const updated = await tx.wallet.update({ where: { ip }, data: { balance: { decrement: price } }, }); await tx.purchase.create({ data: { ip, type: "purchase", amount: -price, productId: product.id, metaJson: JSON.stringify(options) }, }); // Grant entitlements inside the tx too. for (const g of grants) { await tx.entitlement.create({ data: { ip, kind: g.kind, expiresAt: g.expiresAt ?? null, metaJson: g.meta ? JSON.stringify(g.meta) : null }, }); } return updated.balance; } // Free (localhost) or zero-price: record purchase + grants, no deduction. await tx.purchase.create({ data: { ip, type: "purchase", amount: 0, productId: product.id, metaJson: JSON.stringify(options) }, }); for (const g of grants) { await tx.entitlement.create({ data: { ip, kind: g.kind, expiresAt: g.expiresAt ?? null, metaJson: g.meta ? JSON.stringify(g.meta) : null }, }); } const w = await tx.wallet.findUnique({ where: { ip } }); return w?.balance ?? 0; }); } catch (e) { if (e instanceof InsufficientCreditsError) throw new PurchaseError("Crédits insuffisants", 402); throw e; } // Bump the global credits-spent money counter (outside the tx; best-effort). if (!free && price > 0) { const { redis } = await import("./redis"); void redis.incrby("xip:money:credits_spent", price).catch(() => {}); } // Ad-frame purchase => create a real Ad row that enters rotation (Phase 7). const adGrant = grants.find((g) => g.kind === "ad-frame"); if (adGrant) { await prisma.ad .create({ data: { brand: "VOTRE PUB", subtitle: "Espace acheté", url: adGrant.meta?.url || null, cta: "VOIR", icon: "📣", tone: "user", kind: "band", weight: 3, active: true, ownerIp: ip, format: adGrant.meta?.format ?? "static", expiresAt: adGrant.expiresAt ?? null, }, }) .catch(() => {}); adCreated = true; } const balanceView = free ? (await getWallet(ip)).balance : balance; return { result: { ok: true, productId: product.id, pricePaid: free ? 0 : price, balance: balanceView, entitlementKinds: grants.map((g) => g.kind), }, visiblePerkChanged, adCreated, }; } /** Recompute + cache perks after a purchase (caller broadcasts). */ export async function refreshPerks(ip: string) { await invalidatePerks(ip); return getPerksForIp(ip); }