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

@@ -66,6 +66,25 @@ Ayrıntı: `src/lib/app-url.ts`.
`paid`'e döner. Para çekildiyse kredi mutlaka tanımlanır. `paid`'e döner. Para çekildiyse kredi mutlaka tanımlanır.
- **Tutar kontrolü**: `paidPrice` sipariş tutarıyla eşleşmiyorsa kredi otomatik - **Tutar kontrolü**: `paidPrice` sipariş tutarıyla eşleşmiyorsa kredi otomatik
tanımlanmaz, log'a düşer. tanımlanmaz, log'a düşer.
- **Önce kredi, sonra `paid` damgası.** `odemeyiSonuclandir` krediyi yazıp
ardından siparişi `paid` yapar. Ters sırada araya kesinti girerse sipariş
`paid` görünür ama kredi hiç yazılmaz. `paid` görülen ama defterde kaydı
olmayan siparişte kredi yeniden denenir (eski kayıpların onarımı).
- **Çift paket = iade (Bilal kararı, 21.09.2026).** Paket tek seferliktir:
`grantCredits({ paketTekil })` kullanıcı zaten paketliyse yazmaz
(`neden: "zaten-paketli"`, kontrol bakiye UPDATE'inin WHERE'inde → eşzamanlı
iki ödemede de tek paket). Sipariş yine `paid` damgalanır, `odeme_tamamlandi`
atılmaz, `destek@kolaytercih.com`'a "İade gerekli" e-postası gider ve
`/odeme/sonuc` "Bu ödeme iade edilecek" kartını gösterir
(`iadeEdilecekMi`: paid + paket + defterde kayıt yok). **İade otomatik
DEĞİL**: iyzico panelinden elle yapılır. İade sonrası defter satırı silinmez;
gerekirse ters kayıt atılır.
- **Haftalık mutabakat (salt okunur).** Ödenmiş ama kredisi yazılmamış siparişler:
`SELECT o.id, o.user_id, u.has_paket FROM orders o JOIN user u ON u.id=o.user_id
LEFT JOIN credit_ledger l ON l.ref_id=o.id AND l.reason IN ('purchase','topup')
WHERE o.status='paid' AND l.id IS NULL;` — `has_paket=0` satırlar kullanıcının
ilk `/odeme/sonuc` ziyaretinde kendiliğinden onarılır; `has_paket=1` satırlar
iade bekleyen çift paket ödemesidir (e-posta kaçmış olabilir → elle iade et).
## İmza doğrulaması ## İmza doğrulaması

View File

@@ -36,7 +36,16 @@ export async function POST(request: NextRequest) {
return NextResponse.redirect(`${appUrl}/paket?hata=siparis`, 303); return NextResponse.redirect(`${appUrl}/paket?hata=siparis`, 303);
} }
await odemeyiSonuclandir(order.id); // Geçici DB hatası (yazma kilidi vb.) kullanıcıya çıplak 500 göstermesin:
// sonuç sayfası aynı sonuçlandırmayı yeniden dener (self-healing).
try {
await odemeyiSonuclandir(order.id);
} catch (e) {
console.error("[odeme] callback sonuçlandırma hatası", {
siparis: order.id,
e,
});
}
// 303: iyzico'nun POST'u GET'e döner, kullanıcı sonuç sayfasında yenileme // 303: iyzico'nun POST'u GET'e döner, kullanıcı sonuç sayfasında yenileme
// yaptığında form yeniden gönderilmez. // yaptığında form yeniden gönderilmez.
return NextResponse.redirect(`${appUrl}/odeme/sonuc?siparis=${order.id}`, 303); return NextResponse.redirect(`${appUrl}/odeme/sonuc?siparis=${order.id}`, 303);

View File

@@ -75,6 +75,13 @@ export default function KosullarPage() {
; talepler 14 gün içinde sonuçlandırılır ve iade, ödeme yaptığın ; talepler 14 gün içinde sonuçlandırılır ve iade, ödeme yaptığın
karta yapılır. karta yapılır.
</p> </p>
<p className="mt-2">
Paket hesap başına tek seferliktir. Paketin zaten aktifken
yanlışlıkla ikinci kez ödeme yaptıysan (örneğin iki cihazdan aynı
anda), bu ikinci ödeme için kredi tanımlanmaz ve tutarın tamamı,
talep etmene gerek kalmadan, en geç 14 gün içinde ödeme yaptığın
karta iade edilir.
</p>
</section> </section>
<section> <section>
<h2 className="font-heading text-lg font-bold text-slate-900"> <h2 className="font-heading text-lg font-bold text-slate-900">

View File

@@ -1,9 +1,9 @@
import Link from "next/link"; import Link from "next/link";
import { ArrowRight, CheckCircle2, XCircle } from "lucide-react"; import { ArrowRight, CheckCircle2, RotateCcw, XCircle } from "lucide-react";
import { verifySession } from "@/lib/session"; import { verifySession } from "@/lib/session";
import { siparisGetir } from "../odeme-queries"; import { siparisGetir } from "../odeme-queries";
import { kullanicininRaporu } from "@/features/rapor/rapor-queries"; import { kullanicininRaporu } from "@/features/rapor/rapor-queries";
import { odemeyiSonuclandir } from "@/lib/odeme"; import { iadeEdilecekMi, odemeyiSonuclandir } from "@/lib/odeme";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { PagePixelDivider } from "@/components/pixel-decor"; import { PagePixelDivider } from "@/components/pixel-decor";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
@@ -23,16 +23,54 @@ export async function OdemeSonucKarti({ siparis }: { siparis?: string }) {
// Yalnızca kendi siparişi görüntülenebilir // Yalnızca kendi siparişi görüntülenebilir
const sahibiMi = order?.userId === session.user.id; const sahibiMi = order?.userId === session.user.id;
// Self-healing: callback kaybolduysa burada tekrar doğrula // Self-healing: callback kaybolduysa burada tekrar doğrula. "paid" siparişte
// de çağrılır — kredisi yazılmamış eski kayıtları onarır (idempotent).
const durum = const durum =
order && sahibiMi order && sahibiMi ? await odemeyiSonuclandir(order.id) : "not_found";
? order.status === "paid"
? "paid"
: await odemeyiSonuclandir(order.id)
: "not_found";
const basarili = durum === "paid"; const basarili = durum === "paid";
// Zaten paketliyken alınan ikinci paket ödemesi: kredi yazılmadı, iade edilir
const iade =
basarili && order
? await iadeEdilecekMi({ ...order, status: "paid" })
: false;
if (iade) {
return (
<div className="rounded-2xl border border-amber-200 bg-white p-8 shadow-sm">
<RotateCcw className="mx-auto size-12 text-amber-500" aria-hidden />
<h1 className="mt-4 font-heading text-2xl font-bold">
Bu ödeme iade edilecek
</h1>
<p className="mt-2 text-sm text-slate-600">
Tercih Dönemi Paketi&apos;n zaten aktifti; paket tek seferlik olduğu
için bu ikinci ödemeyi iade ediyoruz — senin bir şey yapmana gerek
yok. İade en geç 14 gün içinde yapılır; kartına yansıması bankana
göre birkaç gün daha sürebilir. Sorun olursa{" "}
<a
href="mailto:destek@kolaytercih.com"
className="font-medium text-primary underline-offset-2 hover:underline"
>
destek@kolaytercih.com
</a>
&apos;a yaz.
</p>
<PagePixelDivider seed={79} className="mx-auto mt-5" />
<div className="mt-6 flex flex-col gap-2">
<Button
asChild
className="h-11 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
>
<Link href="/listem">
Listeme dön
<ArrowRight className="size-4" aria-hidden />
</Link>
</Button>
</div>
</div>
);
}
// Rapor zaten üretildiyse ödül bir tık uzakta: kullanıcıyı doğrudan // Rapor zaten üretildiyse ödül bir tık uzakta: kullanıcıyı doğrudan
// rapor sayfasına döndür ki blur çözülme animasyonuyla tam listeyi görsün. // rapor sayfasına döndür ki blur çözülme animasyonuyla tam listeyi görsün.
const rapor = basarili ? await kullanicininRaporu(session.user.id) : null; const rapor = basarili ? await kullanicininRaporu(session.user.id) : null;

View File

@@ -16,6 +16,30 @@ export const RAPOR_KREDI = 3;
/** Paketle gelen geri bildirimli liste revizyonu hakkı */ /** Paketle gelen geri bildirimli liste revizyonu hakkı */
export const MAX_REVIZYON = 2; 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 = export type SpendResult =
| { ok: true } | { ok: true }
| { ok: false; error: "INSUFFICIENT" | "DUPLICATE" }; | { ok: false; error: "INSUFFICIENT" | "DUPLICATE" };
@@ -61,7 +85,7 @@ export async function spendCredits(opts: {
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : String(err); const msg = err instanceof Error ? err.message : String(err);
if (msg === "INSUFFICIENT") return { ok: false, error: "INSUFFICIENT" }; 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; throw err;
} }
} }
@@ -108,14 +132,22 @@ export async function spendCreditForMessage(opts: {
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : String(err); const msg = err instanceof Error ? err.message : String(err);
if (msg === "INSUFFICIENT") return { ok: false, error: "INSUFFICIENT" }; 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; 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) * Kredi tanımlama (deneme, satın alma, top-up, iade). UNIQUE(reason, refId)
* sayesinde aynı referansla ikinci çağrı sessizce no-op olur → idempotent. * 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: { export async function grantCredits(opts: {
userId: string; userId: string;
@@ -123,7 +155,8 @@ export async function grantCredits(opts: {
reason: "trial_grant" | "purchase" | "topup" | "refund"; reason: "trial_grant" | "purchase" | "topup" | "refund";
refId: string; refId: string;
setHasPaket?: boolean; setHasPaket?: boolean;
}): Promise<{ granted: boolean }> { paketTekil?: boolean;
}): Promise<{ granted: boolean; neden?: "tekrar" | "zaten-paketli" }> {
try { try {
await appDb.transaction(async (tx) => { await appDb.transaction(async (tx) => {
await tx.insert(creditLedger).values({ await tx.insert(creditLedger).values({
@@ -134,7 +167,7 @@ export async function grantCredits(opts: {
refId: opts.refId, refId: opts.refId,
createdAt: new Date(), createdAt: new Date(),
}); });
await tx const res = await tx
.update(user) .update(user)
.set({ .set({
creditBalance: sql`${user.creditBalance} + ${opts.delta}`, creditBalance: sql`${user.creditBalance} + ${opts.delta}`,
@@ -143,16 +176,41 @@ export async function grantCredits(opts: {
krediBittiAt: null, krediBittiAt: null,
updatedAt: new Date(), 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 }; return { granted: true };
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : String(err); if (err instanceof ZatenPaketli) {
if (msg.includes("UNIQUE")) return { granted: false }; return { granted: false, neden: "zaten-paketli" };
}
if (uniqueIhlaliMi(err)) return { granted: false, neden: "tekrar" };
throw err; 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). * 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 * 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 { after } from "next/server";
import { and, eq, ne } from "drizzle-orm"; import { and, eq, ne } from "drizzle-orm";
import { appDb, schema } from "./appdb"; import { appDb, schema } from "./appdb";
import { grantCredits, URUNLER } from "./credits"; import { grantCredits, krediKaydiVarMi, URUNLER } from "./credits";
import { import {
retrieveCheckoutForm, retrieveCheckoutForm,
retrieveImzaDurumu, retrieveImzaDurumu,
type CheckoutFormSonuc, type CheckoutFormSonuc,
} from "./iyzico"; } from "./iyzico";
import { sunucuOlayi } from "./analitik-sunucu"; import { sunucuOlayi } from "./analitik-sunucu";
import { epostaGonder } from "./eposta-gonder";
const { orders } = schema; const { orders } = schema;
@@ -46,6 +47,54 @@ function karar(
return "paid"; 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. * 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. * 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), where: eq(orders.id, orderId),
}); });
if (!order) return "not_found"; 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; if (!order.iyzicoToken) return order.status;
let sonuc; let sonuc;
@@ -114,6 +181,20 @@ export async function odemeyiSonuclandir(
return order.status; 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. // → paid koşullu geçiş: 0 satır = başka istek zaten işledi.
// ne(status,'paid') sayesinde erken "failed" damgalanmış bir sipariş de // ne(status,'paid') sayesinde erken "failed" damgalanmış bir sipariş de
// kurtarılabilir (para çekilmişse kredi mutlaka tanımlanır). // 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"))); .where(and(eq(orders.id, orderId), ne(orders.status, "paid")));
if (res.rowsAffected === 0) return "paid"; if (res.rowsAffected === 0) return "paid";
// UNIQUE(reason, refId) ikinci katman güvence if (kredi.neden === "zaten-paketli") {
await grantCredits({ // Log senkron: after() koşmadan süreç ölse ya da e-posta gitmese de iz kalsın
userId: order.userId, console.error("[odeme] ÇİFT PAKET — iade gerekli", {
delta: order.credits, orderId,
reason: order.product === "paket" ? "purchase" : "topup", userId: order.userId,
refId: order.id, paymentId: sonuc.paymentId ?? null,
setHasPaket: order.product === "paket", });
}); 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 // 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). // kez düşer (self-healing fallback tekrar çağrılsa da rowsAffected 0 döner).