feat: kimlik doğrulama, ödeme, kredi ve AI rapor sihirbazı ekle
- better-auth ile giriş/oturum yönetimi (src/lib/auth, giris sayfası) - iyzico ödeme entegrasyonu ve paket satın alma akışı - kredi sistemi ve uygulama veritabanı şeması (drizzle) - AI destekli rapor üretimi ve tercih sihirbazı - rapor yazdırma sayfası ve site header/kullanıcı menüsü Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
149
src/lib/credits.ts
Normal file
149
src/lib/credits.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { and, eq, gte, 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;
|
||||
|
||||
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 (msg.includes("UNIQUE")) 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<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,
|
||||
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 (msg.includes("UNIQUE")) return { ok: false, error: "DUPLICATE" };
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kredi tanımlama (deneme, satın alma, top-up, iade). UNIQUE(reason, refId)
|
||||
* sayesinde aynı referansla ikinci çağrı sessizce no-op olur → idempotent.
|
||||
*/
|
||||
export async function grantCredits(opts: {
|
||||
userId: string;
|
||||
delta: number;
|
||||
reason: "trial_grant" | "purchase" | "topup" | "refund";
|
||||
refId: string;
|
||||
setHasPaket?: boolean;
|
||||
}): Promise<{ granted: boolean }> {
|
||||
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(),
|
||||
});
|
||||
await tx
|
||||
.update(user)
|
||||
.set({
|
||||
creditBalance: sql`${user.creditBalance} + ${opts.delta}`,
|
||||
...(opts.setHasPaket ? { hasPaket: true } : {}),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(user.id, opts.userId));
|
||||
});
|
||||
return { granted: true };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (msg.includes("UNIQUE")) return { granted: false };
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user