fix(odeme): önce kredi sonra paid damgası; çift pakette iade akışı; UNIQUE tespiti
All checks were successful
Deploy / deploy (push) Successful in 8m50s
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:
@@ -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ı
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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);
|
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);
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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'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>
|
||||||
|
'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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
console.error("[odeme] ÇİFT PAKET — iade gerekli", {
|
||||||
|
orderId,
|
||||||
userId: order.userId,
|
userId: order.userId,
|
||||||
delta: order.credits,
|
paymentId: sonuc.paymentId ?? null,
|
||||||
reason: order.product === "paket" ? "purchase" : "topup",
|
|
||||||
refId: order.id,
|
|
||||||
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).
|
||||||
|
|||||||
Reference in New Issue
Block a user