fix(odeme): önce kredi sonra paid damgası; çift pakette iade akışı; UNIQUE tespiti
All checks were successful
Deploy / deploy (push) Successful in 8m50s

- odemeyiSonuclandir krediyi yazıp sonra paid damgalar; araya kesinti girerse
  sipariş pending kalır ve yeniden denenir (eskiden: paid görünür, kredi yok).
  paid + deftersiz eski siparişler ilk ziyarette onarılır
- paket tek seferlik: grantCredits({ paketTekil }) zaten paketliye yazmaz
  (kontrol UPDATE'in WHERE'inde). Sipariş paid damgalanır, senkron log +
  destek@ e-postası, /odeme/sonuc 'Bu ödeme iade edilecek' kartı, /kosullar#iade
- drizzle DrizzleQueryError üst mesajında 'UNIQUE' yok → idempotent no-op yerine
  hata fırlıyordu (grant + iki spend yolu); cause zincirine bakılıyor
- callback geçici DB hatasında 500 yerine sonuç sayfasına yönlendirir
- krediKaydiVarMi (reason, ref_id) indeksini kullanır; haftalık mutabakat sorgusu

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
bilalgursen
2026-09-21 16:36:24 +03:00
parent 8f4669e31d
commit 024a2a49c4
6 changed files with 240 additions and 26 deletions

View File

@@ -16,6 +16,30 @@ 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.
*/
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" };
@@ -61,7 +85,7 @@ export async function spendCredits(opts: {
} 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" };
if (uniqueIhlaliMi(err)) return { ok: false, error: "DUPLICATE" };
throw err;
}
}
@@ -108,14 +132,22 @@ export async function spendCreditForMessage(opts: {
} 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" };
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;
@@ -123,7 +155,8 @@ export async function grantCredits(opts: {
reason: "trial_grant" | "purchase" | "topup" | "refund";
refId: string;
setHasPaket?: boolean;
}): Promise<{ granted: boolean }> {
paketTekil?: boolean;
}): Promise<{ granted: boolean; neden?: "tekrar" | "zaten-paketli" }> {
try {
await appDb.transaction(async (tx) => {
await tx.insert(creditLedger).values({
@@ -134,7 +167,7 @@ export async function grantCredits(opts: {
refId: opts.refId,
createdAt: new Date(),
});
await tx
const res = await tx
.update(user)
.set({
creditBalance: sql`${user.creditBalance} + ${opts.delta}`,
@@ -143,16 +176,41 @@ export async function grantCredits(opts: {
krediBittiAt: null,
updatedAt: new Date(),
})
.where(eq(user.id, opts.userId));
.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) {
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes("UNIQUE")) return { granted: false };
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

View File

@@ -2,13 +2,14 @@ import "server-only";
import { after } from "next/server";
import { and, eq, ne } from "drizzle-orm";
import { appDb, schema } from "./appdb";
import { grantCredits, URUNLER } from "./credits";
import { grantCredits, krediKaydiVarMi, URUNLER } from "./credits";
import {
retrieveCheckoutForm,
retrieveImzaDurumu,
type CheckoutFormSonuc,
} from "./iyzico";
import { sunucuOlayi } from "./analitik-sunucu";
import { epostaGonder } from "./eposta-gonder";
const { orders } = schema;
@@ -46,6 +47,54 @@ function karar(
return "paid";
}
type Siparis = typeof orders.$inferSelect;
const DESTEK_ADRESI = "destek@kolaytercih.com";
function krediNedeni(order: Siparis) {
return order.product === "paket" ? ("purchase" as const) : ("topup" as const);
}
/** Siparişin kredisini idempotent tanımlar; paket tek seferliktir. */
function krediyiTanimla(order: Siparis) {
return grantCredits({
userId: order.userId,
delta: order.credits,
reason: krediNedeni(order),
refId: order.id,
setHasPaket: order.product === "paket",
paketTekil: order.product === "paket",
});
}
/**
* Zaten paketli kullanıcıdan ikinci paket ödemesi alındı (iki cihaz/sekme aynı
* anda ödedi). Politika: kredi yazılmaz, tutar iade edilir. İade iyzico
* panelinden elle yapılır; burada yalnızca iz + destek kutusuna haber.
*/
async function ciftPaketBildir(order: Siparis, paymentId: string | null) {
try {
const metin = `Zaten paketli bir kullanıcıdan ikinci paket ödemesi alındı. Kredi yazılmadı; tutarın iyzico panelinden iade edilmesi gerekiyor.\n\nSipariş: ${order.id}\niyzico ödeme no: ${paymentId ?? "—"}\nTutar: ${(order.amountKurus / 100).toFixed(2)} TL\nKullanıcı: ${order.userId}`;
await epostaGonder(DESTEK_ADRESI, {
subject: "İade gerekli: çift paket ödemesi",
text: metin,
html: `<pre style="font-family:inherit;white-space:pre-wrap">${metin}</pre>`,
});
} catch (err) {
console.error("[odeme] çift paket bildirimi gönderilemedi", err);
}
}
/**
* Ödenmiş ama kredisi yazılmamış paket siparişi = iade edilecek çift ödeme
* (krediyiTanimla her paid siparişte kredi yazmayı dener; yazılamamasının tek
* kalıcı nedeni kullanıcının başka siparişle zaten paketli olmasıdır).
*/
export async function iadeEdilecekMi(order: Siparis): Promise<boolean> {
if (order.status !== "paid" || order.product !== "paket") return false;
return !(await krediKaydiVarMi(order.id, "purchase"));
}
/**
* Token'la iyzico'dan sonucu çeker ve başarılıysa krediyi İDEMPOTENT tanımlar.
* Callback route'u, webhook ve /odeme/sonuc self-healing fallback'i bunu kullanır.
@@ -58,7 +107,25 @@ export async function odemeyiSonuclandir(
where: eq(orders.id, orderId),
});
if (!order) return "not_found";
if (order.status === "paid") return "paid";
if (order.status === "paid") {
// Onarım: eski sürümde paid damgası krediden ÖNCE yazılıyordu; araya
// kesinti girdiyse kredi hiç yazılmamış olabilir. İdempotent olduğu için
// yeniden denemek güvenli (çift paket ise "zaten-paketli" döner, yazmaz).
try {
if (!(await krediKaydiVarMi(order.id, krediNedeni(order)))) {
const k = await krediyiTanimla(order);
if (k.neden === "zaten-paketli") {
console.error("[odeme] iade bekleyen çift paket", { orderId });
}
}
} catch (err) {
// "paid" dönersek sonuç sayfası deftersiz siparişi "iade edilecek" sanar;
// pending → "hâlâ işleniyor, yenile" kartı (doğrusu bu).
console.error("[odeme] kredi onarımı başarısız", { orderId, err });
return "pending";
}
return "paid";
}
if (!order.iyzicoToken) return order.status;
let sonuc;
@@ -114,6 +181,20 @@ export async function odemeyiSonuclandir(
return order.status;
}
// Önce kredi, sonra paid damgası. Ters sırada araya kesinti (deploy, ağ)
// girerse sipariş "paid" görünür ama kredi hiç yazılmaz ve hiçbir yol
// kurtarmaz. Bu sırada kesinti olursa sipariş pending kalır; webhook ya da
// /odeme/sonuc yeniden dener, UNIQUE(reason, refId) çift yazımı engeller.
let kredi;
try {
kredi = await krediyiTanimla(order);
} catch (err) {
// Geçici DB hatası (kilit, ağ): sipariş pending kalır, sonraki tetikleyici
// (webhook yeniden denemesi, /odeme/sonuc) krediyi yazar.
console.error("[odeme] kredi yazılamadı, yeniden denenecek", { orderId, err });
return order.status;
}
// → paid koşullu geçiş: 0 satır = başka istek zaten işledi.
// ne(status,'paid') sayesinde erken "failed" damgalanmış bir sipariş de
// kurtarılabilir (para çekilmişse kredi mutlaka tanımlanır).
@@ -128,14 +209,16 @@ export async function odemeyiSonuclandir(
.where(and(eq(orders.id, orderId), ne(orders.status, "paid")));
if (res.rowsAffected === 0) return "paid";
// UNIQUE(reason, refId) ikinci katman güvence
await grantCredits({
userId: order.userId,
delta: order.credits,
reason: order.product === "paket" ? "purchase" : "topup",
refId: order.id,
setHasPaket: order.product === "paket",
});
if (kredi.neden === "zaten-paketli") {
// Log senkron: after() koşmadan süreç ölse ya da e-posta gitmese de iz kalsın
console.error("[odeme] ÇİFT PAKET — iade gerekli", {
orderId,
userId: order.userId,
paymentId: sonuc.paymentId ?? null,
});
after(() => ciftPaketBildir(order, sonuc.paymentId ?? null));
return "paid";
}
// Dönüşüm kaydı. Koşullu geçişin ardında olduğu için sipariş başına tam bir
// kez düşer (self-healing fallback tekrar çağrılsa da rowsAffected 0 döner).