import { and, eq, gte, isNull, sql } from "drizzle-orm"; import { appDb, schema } from "./appdb"; const { user, creditLedger, chatMessages } = schema; export const URUNLER = { paket: { amountKurus: 29900, credits: 60, label: "Tercih Dönemi Paketi" }, topup: { amountKurus: 12900, credits: 30, label: "+30 Kredi" }, } as const; export const DENEME_KREDISI = 5; // Rapor üretimi/revizyonu başına düşen kredi export const RAPOR_KREDI = 3; /** Paketle gelen geri bildirimli liste revizyonu hakkı */ export const MAX_REVIZYON = 2; /** * UNIQUE ihlali mi? drizzle sürücü hatasını DrizzleQueryError'a sarar: üst * mesaj "Failed query: …" olur, "UNIQUE" yalnız `cause` zincirinde kalır. Yalnız * üst mesaja bakmak idempotent no-op yerine hatayı yukarı fırlatıyordu. */ export function uniqueIhlaliMi(err: unknown): boolean { for (let e: unknown = err, i = 0; e != null && i < 5; i++) { const h = e as { message?: unknown; code?: unknown; extendedCode?: unknown; cause?: unknown; }; if ( String(h.message ?? "").includes("UNIQUE") || String(h.extendedCode ?? h.code ?? "").includes("CONSTRAINT_UNIQUE") ) { return true; } e = h.cause; } return false; } export type SpendResult = | { ok: true } | { ok: false; error: "INSUFFICIENT" | "DUPLICATE" }; function newId() { return crypto.randomUUID(); } /** * Sabit tutarda kredi düşer + ledger kaydı — tek transaction, iade edilebilir. * Rapor üretimi ve revizyonu için (3 kredi). refId idempotency anahtarıdır: * aynı requestId ile ikinci çağrı DUPLICATE döner (çifte harcama olmaz). * Bakiye yetersizse hiçbir şey yazılmaz. */ export async function spendCredits(opts: { userId: string; amount: number; reason: "report_generate" | "report_revision"; refId: string; }): Promise { try { await appDb.transaction(async (tx) => { const res = await tx .update(user) .set({ creditBalance: sql`${user.creditBalance} - ${opts.amount}`, updatedAt: new Date(), }) .where( and(eq(user.id, opts.userId), gte(user.creditBalance, opts.amount)), ); if (res.rowsAffected === 0) throw new Error("INSUFFICIENT"); await tx.insert(creditLedger).values({ id: newId(), userId: opts.userId, delta: -opts.amount, reason: opts.reason, refId: opts.refId, createdAt: new Date(), }); }); return { ok: true }; } catch (err) { const msg = err instanceof Error ? err.message : String(err); if (msg === "INSUFFICIENT") return { ok: false, error: "INSUFFICIENT" }; if (uniqueIhlaliMi(err)) return { ok: false, error: "DUPLICATE" }; throw err; } } /** * 1 kredi düşer + ledger kaydı + kullanıcı mesajını persist eder — tek transaction. * Bakiye yetersizse hiçbir şey yazılmaz. clientMessageId unique → retry'da çifte * harcama yerine DUPLICATE döner. */ export async function spendCreditForMessage(opts: { userId: string; content: string; clientMessageId: string; }): Promise { const messageId = newId(); try { await appDb.transaction(async (tx) => { const res = await tx .update(user) .set({ creditBalance: sql`${user.creditBalance} - 1`, updatedAt: new Date(), }) .where(and(eq(user.id, opts.userId), gte(user.creditBalance, 1))); if (res.rowsAffected === 0) throw new Error("INSUFFICIENT"); await tx.insert(chatMessages).values({ id: messageId, userId: opts.userId, role: "user", content: opts.content, clientMessageId: opts.clientMessageId, createdAt: new Date(), }); await tx.insert(creditLedger).values({ id: newId(), userId: opts.userId, delta: -1, reason: "chat_message", refId: messageId, createdAt: new Date(), }); }); return { ok: true, messageId }; } catch (err) { const msg = err instanceof Error ? err.message : String(err); if (msg === "INSUFFICIENT") return { ok: false, error: "INSUFFICIENT" }; if (uniqueIhlaliMi(err)) return { ok: false, error: "DUPLICATE" }; throw err; } } /** `paketTekil` ile çağrılan tanımlamada kullanıcı zaten paketliyse fırlatılır. */ class ZatenPaketli extends Error {} /** * Kredi tanımlama (deneme, satın alma, top-up, iade). UNIQUE(reason, refId) * sayesinde aynı referansla ikinci çağrı sessizce no-op olur → idempotent. * * `paketTekil`: paket tek seferliktir. Kullanıcı başka bir siparişle zaten * paketliyse kredi YAZILMAZ (`neden: "zaten-paketli"`); çağıran bu ödemeyi * iade edilecek diye işaretler. Kontrol, bakiye güncellemesinin WHERE'inde * yapılır ki eşzamanlı iki ödeme de tek paket yazabilsin. */ export async function grantCredits(opts: { userId: string; delta: number; reason: "trial_grant" | "purchase" | "topup" | "refund"; refId: string; setHasPaket?: boolean; paketTekil?: boolean; }): Promise<{ granted: boolean; neden?: "tekrar" | "zaten-paketli" }> { try { await appDb.transaction(async (tx) => { await tx.insert(creditLedger).values({ id: newId(), userId: opts.userId, delta: opts.delta, reason: opts.reason, refId: opts.refId, createdAt: new Date(), }); const res = await tx .update(user) .set({ creditBalance: sql`${user.creditBalance} + ${opts.delta}`, ...(opts.setHasPaket ? { hasPaket: true } : {}), // Kredi gelince bekleyen kredi-bitti hatırlatması iptal olur krediBittiAt: null, updatedAt: new Date(), }) .where( opts.paketTekil ? and(eq(user.id, opts.userId), eq(user.hasPaket, false)) : eq(user.id, opts.userId), ); // 0 satır: kullanıcı zaten paketli → defter kaydını da geri al if (opts.paketTekil && res.rowsAffected === 0) throw new ZatenPaketli(); }); return { granted: true }; } catch (err) { if (err instanceof ZatenPaketli) { return { granted: false, neden: "zaten-paketli" }; } if (uniqueIhlaliMi(err)) return { granted: false, neden: "tekrar" }; throw err; } } /** * Bu siparişin kredisi deftere düşmüş mü? `reason` şart: UNIQUE(reason, ref_id) * indeksi kullanılsın (yalnız ref_id tam tablo tarar) ve aynı refId'li bir * `refund` satırı "kredi yazılmış" sayılmasın. */ export async function krediKaydiVarMi( refId: string, reason: "purchase" | "topup", ): Promise { const rows = await appDb .select({ id: creditLedger.id }) .from(creditLedger) .where(and(eq(creditLedger.reason, reason), eq(creditLedger.refId, refId))) .limit(1); return rows.length > 0; } /** * Kredi-bitti anını işaretler (48 saatlik hatırlatma e-postasının saati). * Yalnızca ilk bitişte yazar (kolon doluysa no-op) ve hatırlatma zaten * gönderildiyse yeniden kurmaz — e-posta ömür boyu 1 kez. Hata yolunda * çağrıldığı için asla throw etmez. */ export async function krediBittiIsaretle(userId: string): Promise { try { await appDb .update(user) .set({ krediBittiAt: new Date() }) .where( and( eq(user.id, userId), isNull(user.krediBittiAt), isNull(user.krediHatirlatmaGonderildiAt), ), ); } catch (err) { console.error(`[kredi] kredi-bitti işaretlenemedi (${userId}):`, err); } }