Files
kolaytercih/src/lib/credits.ts
bilalgursen e3d79f582e feat(revizyon): revizyon sayısı sınırı kaldırıldı — tek koşul paket + sohbet kredisi
MAX_REVIZYON silindi; liste-revizyon.ts'teki HAK kapısı ve RevizyonSonucu'nun
"HAK" kodu kalktı, ozetCikar kalanHak döndürmüyor (sohbet-meta tipi de).
reports.revision_count sayaç olarak kalır (göç yok). /listem: "Revize et"
düğmesi paketlide hep görünür, "kalan hak" ifadeleri kalktı; sohbet-client
prop'u 0/1 bayrak olarak besleniyor (entegrasyonda revizyonAcik olacak).
Pazarlama metni: "Sınırsız liste revizyonu (danışmanla sohbetten)" (ana sayfa
ve paket kartı). Paketsiz-kredili adayda paket şartı korunur.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 23:16:34 +03:00

266 lines
8.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { and, eq, gte, isNull, sql } from "drizzle-orm";
import { appDb, schema } from "./appdb";
import type { SohbetMeta } from "./ai/sohbet-meta";
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;
/**
* 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<SpendResult> {
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;
meta?: SohbetMeta;
}): Promise<SpendResult & { messageId?: string }> {
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,
meta: opts.meta ?? null,
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;
}
}
/**
* Kredi DÜŞMEDEN kullanıcı mesajı kaydeder — profil onayı ("kaydedeyim mi?"
* → "evet"; Bilal kararı, 26 Eyl 2026: onay ücretsiz) ve danışmanın soru
* formuna verilen cevap (7 Eki 2026) için. Ledger'a satır yazılmaz;
* clientMessageId çakışırsa DUPLICATE.
*/
export async function bedelsizMesajKaydet(opts: {
userId: string;
content: string;
clientMessageId: string;
meta?: SohbetMeta;
}): Promise<SpendResult & { messageId?: string }> {
const messageId = newId();
try {
await appDb.insert(chatMessages).values({
id: messageId,
userId: opts.userId,
role: "user",
content: opts.content,
clientMessageId: opts.clientMessageId,
meta: opts.meta ?? null,
createdAt: new Date(),
});
return { ok: true, messageId };
} catch (err) {
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<boolean> {
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<void> {
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);
}
}